Files
windmill/typescript-client/client.ts
T
claude[bot] 161dbbc6d6 feat: add native result streaming (#6242)
* feat: add stream output feature to SSE job updates

Adds stream_output field to JobUpdate struct that extracts log lines
starting with '[wm_stream]:' from job logs. Regular logs now exclude
stream lines, which are captured separately for specialized handling.

- Added stream_output: Option<String> field to JobUpdate struct
- Created extract_stream_output_from_logs() function to filter stream lines
- Modified get_job_update_data() to use stream extraction logic
- SSE clients now receive both new_logs and stream_output in job updates

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* feat: rename stream_output to stream and handle newlines as \n

- Renamed `stream_output` field to `stream` in JobUpdate struct
- Updated extract_stream_output_from_logs to extract_stream_from_logs
- Changed stream output to join with literal \n instead of actual newlines
- Stream lines are properly excluded from regular new_logs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* decision tree nits

* push ee ref

* push ee ref

* fix: fix id renaming in apps

* remove duplicate caching (#6285)

* feat: migrate audit log ids to bigints (blocking migration for EE)

* fix(mcp): add proper check for mcp routes (#6282)

* add proper check for mcp routes

* cleaner

* apply to flow

* fix add checks scopes

---------

Co-authored-by: dieriba <dieriba.pro@gmail.com>

* chore(main): release 1.514.0 (#6283)

* chore(main): release 1.514.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* fix: pin tokio to 1.46.1 and aws-sdks-ts

* pin rustls to 0.23.29 + pin aws-sdk

* chore(main): release 1.514.1 (#6288)

* chore(main): release 1.514.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* fix: improve docker logs collection in docker mode

* support $res: string in form inputs of arrays

* fix import nit

* fix: fix DynSelect

* nits

* fix: resource-type-ts-parser (#6289)

* fix: resource types as arg in typescript handle imported defined types

* Update nix flake (#6291)

* merge

* Small UI fixes (#6294)

* fix step history not refreshing with staticInputs

* fix array of obj not showing up in json editor in test this step

* datatable scales correctly in DisplayResult and scrolling is much more usable

* avoid next button disapearing and changing layout / hurting ux

* nits

* fix bug when renaming module A to B then module C to A, C takes the schema of A

* fix bug with comments in sql repl

* fix aggrid theme randomly not loading

* bindable script

* better delete button in db manager

* property select doesnt exist

* fix all warnings

* delete $flowStateStore[id] on delete

* feat(cli): generate cursor rules on init (#6270)

* create cursor rules on init

* change gen

* add missing resource-type command

* add resource type command in guidance

* add schema option

* revert

* nit

* nit

* add flow guidance

* nit

* chore(main): release 1.515.0 (#6292)

* chore(main): release 1.515.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* fix: improved logs for script

* nits logs

* chore(main): release 1.515.1 (#6295)

* chore(main): release 1.515.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* merge

* even more indexer tracings

* add more tracing logs

* feat: prevent too large results (>500Mb) from OOMing database

* nit naming

* feat: add CA certificate update at startup via environment variable (#6280)

* feat: add CA certificate update at startup via environment variable

Add support for running 'update-ca-certificates' at binary startup
when RUN_UPDATE_CA_CERTIFICATE_AT_START environment variable is set to "true".

- Check for RUN_UPDATE_CA_CERTIFICATE_AT_START env var on startup
- Execute update-ca-certificates command if env var is set to "true"
- Log success/failure appropriately with tracing
- Continue startup even if CA certificate update fails
- Non-blocking implementation with proper error handling

Fixes #6279

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* refactor: extract CA certificate update logic into separate function

Extract the CA certificate update logic from windmill_main() into a
dedicated update_ca_certificates_if_requested() function for better
code organization and maintainability.

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* improvements

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: Alexander Petric <alex@windmill.dev>

* fix: indexer collection of job logs before indexing (#6300)

* Add flume as dependecy for indexer

* Update ee-repo-ref

* Remove flags from cargo.toml

* Update ee-repo-ref

* Update ee-repo-ref

* fix rust sdk build error (#6305)

Signed-off-by: pyranota <pyra@duck.com>

* fix broken audit logs filter (#6304)

* rename to from to

* goto fix

* default to false if field not present operator settings (#6301)

* git sync UI improvements (#6303)

* ui improvements round 1

* modal cleanup

* init

* UI refactor

* UI cleanup + refactor

* legacy cleanup

* success model -> github actions, non-ee warnings

* sqlx

* npm check

* ee warning everywhere

* last comments

* formatting

* no hardcoded theme

* claude review improvemenets

* fix: no process relative imports for scripts with codebase

* fix: sqs oidc authentication disconnect #6307

* handle metadata for new scripts happen after commit

* handle_deployment_metadata in a task

* nits

* chore: add windmill-utils-internal package (#6299)

* add utils package

* naming

* cleaning

* add docs

* remove log

* use autogenerated types

* remove old

* fix

* cleaning

* add docs

* chore(main): release 1.516.0 (#6298)

* chore(main): release 1.516.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* merge

* indexer improvements

* upgrade tantivy to 0.24.2

* use tantivy fork

* nit warnings

* fix oss build

* improve indexer

* chore: use windmill-utils-internal for cli (#6297)

* add utils package

* naming

* cleaning

* simplify assignPath

* rename old files

* same for locks

* create on confirm

* default true

* use replaceinlinescripts from utils

* use extractscriptfromflows

* make it compile

* cleaning

* use argsigtojson

* fix

* fix missing await

* cleaner

* cleaning

* cleaning

* use in frontend

* add docs

* testing

* remove log

* use autogenerated types

* remove old

* fix

* cleaning

* adapt usage

* draft

* better build script

* fix build

* revert to default creation

* add docs

* remove and rename

* make everything work

* add await

* only if not installed

* add vs code setting

* add to publish action

* fix bc

* safer use of sep

* fix

* do not rename on push

* no publish on release

* use published package on frontend

* nit

* Add dependencies to run sqlx prepare to nix flake (#6309)

* feat(cli): wmill-lock.yaml v2 for easier git merge diffs

* merge

* merge

* all

* all

* rm warnings

* fix styling on aichatinput (#6312)

* fix: use with_capacity back presusre for tantivy directory multipart writes (#6313)

* use with capacity for tantivy directory multi part uploads

* Update ee repo ref

* Update ee-repo-ref

* Update ee-repo-ref

* chore(main): release 1.517.0 (#6310)

* chore(main): release 1.517.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* fix typo on cli build (#6314)

* cleanup

* feat(utils): add flow.yaml validation function (#6316)

* add validateflow function

* cleaner code

* preprocess json

* cleaning

* create specific package

* cleaning

* add tests

* fix: cleanup concurrency_counter automatically + remove orphans keys automatically

* fix: add disabled support to resource picker in schema forms

* fix: add wm_labels to tracing spans

* all

* merge

* all

* fix: delete empty git connection (#6318)

* fix checks

* bun handling

* all

* all?

* all

* all

* update

* all

* update

* check

* fix history

* all

* all

* all

* Remove leftover debug tracing statements

- Remove commented debug trace in jobs.rs for stream output
- Remove commented debug trace in result_stream.rs for stream processing

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

* fix test

* all

* handle iter

* fix

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: centdix <40307056+centdix@users.noreply.github.com>
Co-authored-by: dieriba <dieriba.pro@gmail.com>
Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
Co-authored-by: wendrul <53628737+wendrul@users.noreply.github.com>
Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: Alexander Petric <alex@windmill.dev>
Co-authored-by: pyranota <92104930+pyranota@users.noreply.github.com>
2025-08-06 22:40:42 +00:00

1285 lines
36 KiB
TypeScript

import {
ResourceService,
VariableService,
JobService,
HelpersService,
AppService,
MetricsService,
OidcService,
UserService,
TeamsService,
} from "./index";
import { OpenAPI } from "./index";
// import type { DenoS3LightClientSettings } from "./index";
import {
DenoS3LightClientSettings,
S3ObjectRecord,
type S3Object,
} from "./s3Types";
export {
AdminService,
AuditService,
FlowService,
GranularAclService,
GroupService,
JobService,
ResourceService,
VariableService,
ScriptService,
ScheduleService,
SettingsService,
UserService,
WorkspaceService,
TeamsService,
} from "./index";
export type Sql = string;
export type Email = string;
export type Base64 = string;
export type Resource<S extends string> = any;
export const SHARED_FOLDER = "/shared";
let mockedApi: MockedApi | undefined = undefined;
export function setClient(token?: string, baseUrl?: string) {
if (baseUrl === undefined) {
baseUrl =
getEnv("BASE_INTERNAL_URL") ??
getEnv("BASE_URL") ??
"http://localhost:8000";
}
if (token === undefined) {
token = getEnv("WM_TOKEN") ?? "no_token";
}
OpenAPI.WITH_CREDENTIALS = true;
OpenAPI.TOKEN = token;
OpenAPI.BASE = baseUrl + "/api";
}
const getEnv = (key: string) => {
if (typeof window === "undefined") {
// node
return process?.env?.[key];
}
// browser
return window?.process?.env?.[key];
};
/**
* Create a client configuration from env variables
* @returns client configuration
*/
export function getWorkspace(): string {
return getEnv("WM_WORKSPACE") ?? "no_workspace";
}
/**
* Get a resource value by path
* @param path path of the resource, default to internal state path
* @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error
* @returns resource value
*/
export async function getResource(
path?: string,
undefinedIfEmpty?: boolean
): Promise<any> {
path = parseResourceSyntax(path) ?? path ?? getStatePath();
const mockedApi = await getMockedApi();
if (mockedApi) {
if (mockedApi.resources[path]) {
return mockedApi.resources[path];
} else {
console.log(
`MockedAPI present, but resource not found at ${path}, falling back to real API`
);
}
}
const workspace = getWorkspace();
try {
return await ResourceService.getResourceValueInterpolated({
workspace,
path,
});
} catch (e: any) {
if (undefinedIfEmpty && e.status === 404) {
return undefined;
} else {
throw Error(
`Resource not found at ${path} or not visible to you: ${e.body}`
);
}
}
}
/**
* Get the true root job id
* @param jobId job id to get the root job id from (default to current job)
* @returns root job id
*/
export async function getRootJobId(jobId?: string): Promise<string> {
const workspace = getWorkspace();
jobId = jobId ?? getEnv("WM_JOB_ID");
if (jobId === undefined) {
throw Error("Job ID not set");
}
return await JobService.getRootJobId({ workspace, id: jobId });
}
/**
* @deprecated Use runScriptByPath or runScriptByHash instead
*/
export async function runScript(
path: string | null = null,
hash_: string | null = null,
args: Record<string, any> | null = null,
verbose: boolean = false
): Promise<any> {
console.warn('runScript is deprecated. Use runScriptByPath or runScriptByHash instead.');
if (path && hash_) {
throw new Error("path and hash_ are mutually exclusive");
}
return _runScriptInternal(path, hash_, args, verbose);
}
async function _runScriptInternal(
path: string | null = null,
hash_: string | null = null,
args: Record<string, any> | null = null,
verbose: boolean = false
): Promise<any> {
args = args || {};
if (verbose) {
if (path) {
console.info(`running \`${path}\` synchronously with args:`, args);
} else if (hash_) {
console.info(`running script with hash \`${hash_}\` synchronously with args:`, args);
}
}
const jobId = await _runScriptAsyncInternal(path, hash_, args);
return await waitJob(jobId, verbose);
}
export async function runScriptByPath(
path: string,
args: Record<string, any> | null = null,
verbose: boolean = false
): Promise<any> {
return _runScriptInternal(path, null, args, verbose);
}
export async function runScriptByHash(
hash_: string,
args: Record<string, any> | null = null,
verbose: boolean = false
): Promise<any> {
return _runScriptInternal(null, hash_, args, verbose);
}
/**
* Append a text to the result stream
* @param text text to append to the result stream
*/
export function appendToResultStream(
text: string
) {
console.log("WM_STREAM: " + text.replaceAll("\n", "\\n"));
}
/**
* Stream to the result stream
* @param stream stream to stream to the result stream
*/
export async function streamResult(
stream: AsyncIterable<string>
) {
for await (const text of stream) {
appendToResultStream(text);
}
}
export async function runFlow(
path: string | null = null,
args: Record<string, any> | null = null,
verbose: boolean = false
): Promise<any> {
args = args || {};
if (verbose) {
console.info(`running \`${path}\` synchronously with args:`, args);
}
const jobId = await runFlowAsync(path, args, null, false);
return await waitJob(jobId, verbose);
}
export async function waitJob(
jobId: string,
verbose: boolean = false
): Promise<any> {
while (true) {
// Implement your HTTP request logic here to get job result
const resultRes = await getResultMaybe(jobId);
const started = resultRes.started;
const completed = resultRes.completed;
const success = resultRes.success;
if (!started && verbose) {
console.info(`job ${jobId} has not started yet`);
}
if (completed) {
const result = resultRes.result;
if (success) {
return result;
} else {
const error = result.error;
throw new Error(
`Job ${jobId} was not successful: ${JSON.stringify(error)}`
);
}
}
if (verbose) {
console.info(`sleeping 0.5 seconds for jobId: ${jobId}`);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
export async function getResult(jobId: string): Promise<any> {
const workspace = getWorkspace();
return await JobService.getCompletedJobResult({ workspace, id: jobId });
}
export async function getResultMaybe(jobId: string): Promise<any> {
const workspace = getWorkspace();
return await JobService.getCompletedJobResultMaybe({ workspace, id: jobId });
}
const STRIP_COMMENTS =
/(\/\/.*$)|(\/\*[\s\S]*?\*\/)|(\s*=[^,\)]*(('(?:\\'|[^'\r\n])*')|("(?:\\"|[^"\r\n])*"))|(\s*=[^,\)]*))/gm;
const ARGUMENT_NAMES = /([^\s,]+)/g;
function getParamNames(func: Function): string[] {
const fnStr = func.toString().replace(STRIP_COMMENTS, "");
let result: string[] | null = fnStr
.slice(fnStr.indexOf("(") + 1, fnStr.indexOf(")"))
.match(ARGUMENT_NAMES);
if (result === null) result = [];
return result;
}
export function task<P, T>(f: (_: P) => T): (_: P) => Promise<T> {
return async (...y) => {
const args: Record<string, any> = {};
const paramNames = getParamNames(f);
y.forEach((x, i) => (args[paramNames[i]] = x));
let req = await fetch(
`${OpenAPI.BASE}/w/${getWorkspace()}/jobs/run/workflow_as_code/${getEnv(
"WM_JOB_ID"
)}/${f.name}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getEnv("WM_TOKEN")}`,
},
body: JSON.stringify({ args }),
}
);
let jobId = await req.text();
console.log(`Started task ${f.name} as job ${jobId}`);
let r = await waitJob(jobId);
console.log(`Task ${f.name} (${jobId}) completed`);
return r;
};
}
/**
* @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead
*/
export async function runScriptAsync(
path: string | null,
hash_: string | null,
args: Record<string, any> | null,
scheduledInSeconds: number | null = null
): Promise<string> {
console.warn('runScriptAsync is deprecated. Use runScriptByPathAsync or runScriptByHashAsync instead.');
// Create a script job and return its job id.
if (path && hash_) {
throw new Error("path and hash_ are mutually exclusive");
}
return _runScriptAsyncInternal(path, hash_, args, scheduledInSeconds);
}
async function _runScriptAsyncInternal(
path: string | null = null,
hash_: string | null = null,
args: Record<string, any> | null = null,
scheduledInSeconds: number | null = null
): Promise<string> {
// Create a script job and return its job id.
args = args || {};
const params: Record<string, any> = {};
if (scheduledInSeconds) {
params["scheduled_in_secs"] = scheduledInSeconds;
}
let parentJobId = getEnv("WM_JOB_ID");
if (parentJobId !== undefined) {
params["parent_job"] = parentJobId;
}
let rootJobId = getEnv("WM_ROOT_FLOW_JOB_ID");
if (rootJobId != undefined && rootJobId != "") {
params["root_job"] = rootJobId;
}
let endpoint: string;
if (path) {
endpoint = `/w/${getWorkspace()}/jobs/run/p/${path}`;
} else if (hash_) {
endpoint = `/w/${getWorkspace()}/jobs/run/h/${hash_}`;
} else {
throw new Error("path or hash_ must be provided");
}
let url = new URL(OpenAPI.BASE + endpoint);
url.search = new URLSearchParams(params).toString();
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OpenAPI.TOKEN}`,
},
body: JSON.stringify(args),
}).then((res) => res.text());
}
export async function runScriptByPathAsync(
path: string,
args: Record<string, any> | null = null,
scheduledInSeconds: number | null = null
): Promise<string> {
return _runScriptAsyncInternal(path, null, args, scheduledInSeconds);
}
export async function runScriptByHashAsync(
hash_: string,
args: Record<string, any> | null = null,
scheduledInSeconds: number | null = null
): Promise<string> {
return _runScriptAsyncInternal(null, hash_, args, scheduledInSeconds);
}
export async function runFlowAsync(
path: string | null,
args: Record<string, any> | null,
scheduledInSeconds: number | null = null,
// can only be set to false if this the job will be fully await and not concurrent with any other job
// as otherwise the child flow and its own child will store their state in the parent job which will
// lead to incorrectness and failures
doNotTrackInParent: boolean = true
): Promise<string> {
// Create a script job and return its job id.
args = args || {};
const params: Record<string, any> = {};
if (scheduledInSeconds) {
params["scheduled_in_secs"] = scheduledInSeconds;
}
if (!doNotTrackInParent) {
let parentJobId = getEnv("WM_JOB_ID");
if (parentJobId !== undefined) {
params["parent_job"] = parentJobId;
}
let rootJobId = getEnv("WM_ROOT_FLOW_JOB_ID");
if (rootJobId != undefined && rootJobId != "") {
params["root_job"] = rootJobId;
}
}
let endpoint: string;
if (path) {
endpoint = `/w/${getWorkspace()}/jobs/run/f/${path}`;
} else {
throw new Error("path must be provided");
}
let url = new URL(OpenAPI.BASE + endpoint);
url.search = new URLSearchParams(params).toString();
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OpenAPI.TOKEN}`,
},
body: JSON.stringify(args),
}).then((res) => res.text());
}
/**
* Resolve a resource value in case the default value was picked because the input payload was undefined
* @param obj resource value or path of the resource under the format `$res:path`
* @returns resource value
*/
export async function resolveDefaultResource(obj: any): Promise<any> {
if (typeof obj === "string" && obj.startsWith("$res:")) {
return await getResource(obj.substring(5), true);
} else {
return obj;
}
}
export function getStatePath(): string {
const state_path = getEnv("WM_STATE_PATH_NEW") ?? getEnv("WM_STATE_PATH");
if (state_path === undefined) {
throw Error("State path not set");
}
return state_path;
}
/**
* Set a resource value by path
* @param path path of the resource to set, default to state path
* @param value new value of the resource to set
* @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type
*/
export async function setResource(
value: any,
path?: string,
initializeToTypeIfNotExist?: string
): Promise<void> {
path = parseResourceSyntax(path) ?? path ?? getStatePath();
const mockedApi = await getMockedApi();
if (mockedApi) {
mockedApi.resources[path] = value;
return;
}
const workspace = getWorkspace();
if (await ResourceService.existsResource({ workspace, path })) {
await ResourceService.updateResourceValue({
workspace,
path,
requestBody: { value },
});
} else if (initializeToTypeIfNotExist) {
await ResourceService.createResource({
workspace,
requestBody: { path, value, resource_type: initializeToTypeIfNotExist },
});
} else {
throw Error(
`Resource at path ${path} does not exist and no type was provided to initialize it`
);
}
}
/**
* Set the state
* @param state state to set
* @deprecated use setState instead
*/
export async function setInternalState(state: any): Promise<void> {
await setResource(state, undefined, "state");
}
/**
* Set the state
* @param state state to set
*/
export async function setState(state: any): Promise<void> {
await setResource(state, undefined, "state");
}
/**
* Set the progress
* Progress cannot go back and limited to 0% to 99% range
* @param percent Progress to set in %
* @param jobId? Job to set progress for
*/
export async function setProgress(percent: number, jobId?: any): Promise<void> {
const workspace = getWorkspace();
let flowId = getEnv("WM_FLOW_JOB_ID");
// If jobId specified we need to find if there is a parent/flow
if (jobId) {
const job = await JobService.getJob({
id: jobId ?? "NO_JOB_ID",
workspace,
noLogs: true,
});
// Could be actual flowId or undefined
flowId = job.parent_job;
}
await MetricsService.setJobProgress({
id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
workspace,
requestBody: {
// In case user inputs float, it should be converted to int
percent: Math.floor(percent),
flow_job_id: flowId == "" ? undefined : flowId,
},
});
}
/**
* Get the progress
* @param jobId? Job to get progress from
* @returns Optional clamped between 0 and 100 progress value
*/
export async function getProgress(jobId?: any): Promise<number | null> {
// TODO: Delete or set to 100 completed job metrics
return await MetricsService.getJobProgress({
id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
workspace: getWorkspace(),
});
}
/**
* Set a flow user state
* @param key key of the state
* @param value value of the state
*/
export async function setFlowUserState(
key: string,
value: any,
errorIfNotPossible?: boolean
): Promise<void> {
if (value === undefined) {
value = null;
}
const workspace = getWorkspace();
try {
await JobService.setFlowUserState({
workspace,
id: await getRootJobId(),
key,
requestBody: value,
});
} catch (e: any) {
if (errorIfNotPossible) {
throw Error(`Error setting flow user state at ${key}: ${e.body}`);
} else {
console.error(`Error setting flow user state at ${key}: ${e.body}`);
}
}
}
/**
* Get a flow user state
* @param path path of the variable
*/
export async function getFlowUserState(
key: string,
errorIfNotPossible?: boolean
): Promise<any> {
const workspace = getWorkspace();
try {
return await JobService.getFlowUserState({
workspace,
id: await getRootJobId(),
key,
});
} catch (e: any) {
if (errorIfNotPossible) {
throw Error(`Error setting flow user state at ${key}: ${e.body}`);
} else {
console.error(`Error setting flow user state at ${key}: ${e.body}`);
}
}
}
// /**
// * Set the shared state
// * @param state state to set
// */
// export async function setSharedState(
// state: any,
// path = "state.json"
// ): Promise<void> {
// await Deno.writeTextFile(SHARED_FOLDER + "/" + path, JSON.stringify(state));
// }
// /**
// * Get the shared state
// * @param state state to set
// */
// export async function getSharedState(path = "state.json"): Promise<any> {
// return JSON.parse(await Deno.readTextFile(SHARED_FOLDER + "/" + path));
// }
/**
* Get the internal state
* @deprecated use getState instead
*/
export async function getInternalState(): Promise<any> {
return await getResource(getStatePath(), true);
}
/**
* Get the state shared across executions
*/
export async function getState(): Promise<any> {
return await getResource(getStatePath(), true);
}
/**
* Get a variable by path
* @param path path of the variable
* @returns variable value
*/
export async function getVariable(path: string): Promise<string> {
path = parseVariableSyntax(path) ?? path;
const mockedApi = await getMockedApi();
if (mockedApi) {
if (mockedApi.variables[path]) {
return mockedApi.variables[path];
} else {
console.log(
`MockedAPI present, but variable not found at ${path}, falling back to real API`
);
}
}
const workspace = getWorkspace();
try {
return await VariableService.getVariableValue({ workspace, path });
} catch (e: any) {
throw Error(
`Variable not found at ${path} or not visible to you: ${e.body}`
);
}
}
/**
* Set a variable by path, create if not exist
* @param path path of the variable
* @param value value of the variable
* @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)
* @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "")
*/
export async function setVariable(
path: string,
value: string,
isSecretIfNotExist?: boolean,
descriptionIfNotExist?: string
): Promise<void> {
path = parseVariableSyntax(path) ?? path;
const mockedApi = await getMockedApi();
if (mockedApi) {
mockedApi.variables[path] = value;
return;
}
const workspace = getWorkspace();
if (await VariableService.existsVariable({ workspace, path })) {
await VariableService.updateVariable({
workspace,
path,
requestBody: { value },
});
} else {
await VariableService.createVariable({
workspace,
requestBody: {
path,
value,
is_secret: isSecretIfNotExist ?? false,
description: descriptionIfNotExist ?? "",
},
});
}
}
export async function databaseUrlFromResource(path: string): Promise<string> {
const resource = await getResource(path);
return `postgresql://${resource.user}:${resource.password}@${resource.host}:${resource.port}/${resource.dbname}?sslmode=${resource.sslmode}`;
}
// TODO(gb): need to investigate more how Polars and DuckDB work in TS
// export async function polarsConnectionSettings(s3_resource_path: string | undefined): Promise<any> {
// const workspace = getWorkspace();
// return await HelpersService.polarsConnectionSettingsV2({
// workspace: workspace,
// requestBody: {
// s3_resource_path: s3_resource_path
// }
// });
// }
// export async function duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<any> {
// const workspace = getWorkspace();
// return await HelpersService.duckdbConnectionSettingsV2({
// workspace: workspace,
// requestBody: {
// s3_resource_path: s3_resource_path
// }
// });
// }
export async function denoS3LightClientSettings(
s3_resource_path: string | undefined
): Promise<DenoS3LightClientSettings> {
const workspace = getWorkspace();
const s3Resource = await HelpersService.s3ResourceInfo({
workspace: workspace,
requestBody: {
s3_resource_path:
parseResourceSyntax(s3_resource_path) ?? s3_resource_path,
},
});
let settings: DenoS3LightClientSettings = {
...s3Resource,
};
return settings;
}
/**
* Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
*
* ```typescript
* let fileContent = await wmill.loadS3FileContent(inputFile)
* // if the file is a raw text file, it can be decoded and printed directly:
* const text = new TextDecoder().decode(fileContentStream)
* console.log(text);
* ```
*/
export async function loadS3File(
s3object: S3Object,
s3ResourcePath: string | undefined = undefined
): Promise<Uint8Array | undefined> {
const fileContentBlob = await loadS3FileStream(s3object, s3ResourcePath);
if (fileContentBlob === undefined) {
return undefined;
}
// we read the stream until completion and put the content in an Uint8Array
const reader = fileContentBlob.stream().getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { value: chunk, done } = await reader.read();
if (done) {
break;
}
chunks.push(chunk);
}
let fileContentLength = 0;
chunks.forEach((item) => {
fileContentLength += item.length;
});
let fileContent = new Uint8Array(fileContentLength);
let offset = 0;
chunks.forEach((chunk) => {
fileContent.set(chunk, offset);
offset += chunk.length;
});
return fileContent;
}
/**
* Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
*
* ```typescript
* let fileContentBlob = await wmill.loadS3FileStream(inputFile)
* // if the content is plain text, the blob can be read directly:
* console.log(await fileContentBlob.text());
* ```
*/
export async function loadS3FileStream(
s3object: S3Object,
s3ResourcePath: string | undefined = undefined
): Promise<Blob | undefined> {
let s3Obj = s3object && parseS3Object(s3object);
let params: Record<string, string> = {};
params["file_key"] = s3Obj.s3;
if (s3ResourcePath !== undefined) {
params["s3_resource_path"] = s3ResourcePath;
}
if (s3Obj.storage !== undefined) {
params["storage"] = s3Obj.storage;
}
const queryParams = new URLSearchParams(params);
// We use raw fetch here b/c OpenAPI generated client doesn't handle Blobs nicely
const response = await fetch(
`${OpenAPI.BASE
}/w/${getWorkspace()}/job_helpers/download_s3_file?${queryParams}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${OpenAPI.TOKEN}`,
},
}
);
// Check if the response was successful
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to load S3 file: ${response.status} ${response.statusText} - ${errorText}`
);
}
return response.blob();
}
/**
* Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
*
* ```typescript
* const s3object = await writeS3File(s3Object, "Hello Windmill!")
* const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')
* console.log(fileContentAsUtf8Str)
* ```
*/
export async function writeS3File(
s3object: S3Object | undefined,
fileContent: string | Blob,
s3ResourcePath: string | undefined = undefined,
contentType: string | undefined = undefined,
contentDisposition: string | undefined = undefined
): Promise<S3Object> {
let fileContentBlob: Blob;
if (typeof fileContent === "string") {
fileContentBlob = new Blob([fileContent as string], {
type: "text/plain",
});
} else {
fileContentBlob = fileContent as Blob;
}
let s3Obj = s3object && parseS3Object(s3object);
const response = await HelpersService.fileUpload({
workspace: getWorkspace(),
fileKey: s3Obj?.s3,
fileExtension: undefined,
s3ResourcePath: s3ResourcePath,
requestBody: fileContentBlob,
storage: s3Obj?.storage,
contentType,
contentDisposition,
});
return {
s3: response.file_key,
};
}
/**
* Sign S3 objects to be used by anonymous users in public apps
* @param s3objects s3 objects to sign
* @returns signed s3 objects
*/
export async function signS3Objects(
s3objects: S3Object[]
): Promise<S3Object[]> {
const signedKeys = await AppService.signS3Objects({
workspace: getWorkspace(),
requestBody: {
s3_objects: s3objects.map(parseS3Object),
},
});
return signedKeys;
}
/**
* Sign S3 object to be used by anonymous users in public apps
* @param s3object s3 object to sign
* @returns signed s3 object
*/
export async function signS3Object(s3object: S3Object): Promise<S3Object> {
const [signedObject] = await signS3Objects([s3object]);
return signedObject;
}
/**
* Get URLs needed for resuming a flow after this step
* @param approver approver name
* @returns approval page UI URL, resume and cancel API URLs for resuming the flow
*/
export async function getResumeUrls(approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}> {
const nonce = Math.floor(Math.random() * 4294967295);
const workspace = getWorkspace();
return await JobService.getResumeUrls({
workspace,
resumeId: nonce,
approver,
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
});
}
/**
* @deprecated use getResumeUrls instead
*/
export function getResumeEndpoints(approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}> {
return getResumeUrls(approver);
}
/**
* Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)
* @param audience audience of the token
* @returns jwt token
*/
export async function getIdToken(audience: string): Promise<string> {
const workspace = getWorkspace();
return await OidcService.getOidcToken({
workspace,
audience,
});
}
export function base64ToUint8Array(data: string): Uint8Array {
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
}
export function uint8ArrayToBase64(arrayBuffer: Uint8Array): string {
let base64 = "";
const encodings =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const bytes = new Uint8Array(arrayBuffer);
const byteLength = bytes.byteLength;
const byteRemainder = byteLength % 3;
const mainLength = byteLength - byteRemainder;
let a, b, c, d;
let chunk;
// Main loop deals with bytes in chunks of 3
for (let i = 0; i < mainLength; i = i + 3) {
// Combine the three bytes into a single integer
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
// Use bitmasks to extract 6-bit segments from the triplet
a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
d = chunk & 63; // 63 = 2^6 - 1
// Convert the raw binary segments to the appropriate ASCII encoding
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
}
// Deal with the remaining bytes and padding
if (byteRemainder == 1) {
chunk = bytes[mainLength];
a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
// Set the 4 least significant bits to zero
b = (chunk & 3) << 4; // 3 = 2^2 - 1
base64 += encodings[a] + encodings[b] + "==";
} else if (byteRemainder == 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
// Set the 2 least significant bits to zero
c = (chunk & 15) << 2; // 15 = 2^4 - 1
base64 += encodings[a] + encodings[b] + encodings[c] + "=";
}
return base64;
}
/**
* Get email from workspace username
* This method is particularly useful for apps that require the email address of the viewer.
* Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.
* @param username
* @returns email address
*/
export async function usernameToEmail(username: string): Promise<string> {
const workspace = getWorkspace();
return await UserService.usernameToEmail({ username, workspace });
}
interface SlackApprovalOptions {
slackResourcePath: string;
channelId: string;
message?: string;
approver?: string;
defaultArgsJson?: Record<string, any>;
dynamicEnumsJson?: Record<string, any>;
}
interface TeamsApprovalOptions {
teamName: string;
channelName: string;
message?: string;
approver?: string;
defaultArgsJson?: Record<string, any>;
dynamicEnumsJson?: Record<string, any>;
}
/**
* Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
*
* **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**
* and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).
*
* @param {Object} options - The configuration options for the Slack approval request.
* @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.
* @param {string} options.channelId - The Slack channel ID where the approval request will be sent.
* @param {string} [options.message] - Optional custom message to include in the Slack approval request.
* @param {string} [options.approver] - Optional user ID or name of the approver for the request.
* @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.
* @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.
*
* @returns {Promise<void>} Resolves when the Slack approval request is successfully sent.
*
* @throws {Error} If the function is not called within a flow or flow preview.
* @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.
*
* **Usage Example:**
* ```typescript
* await requestInteractiveSlackApproval({
* slackResourcePath: "/u/alex/my_slack_resource",
* channelId: "admins-slack-channel",
* message: "Please approve this request",
* approver: "approver123",
* defaultArgsJson: { key1: "value1", key2: 42 },
* dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] },
* });
* ```
*
* **Note:** This function requires execution within a Windmill flow or flow preview.
*/
export async function requestInteractiveSlackApproval({
slackResourcePath,
channelId,
message,
approver,
defaultArgsJson,
dynamicEnumsJson,
}: SlackApprovalOptions): Promise<void> {
const workspace = getWorkspace();
const flowJobId = getEnv("WM_FLOW_JOB_ID");
if (!flowJobId) {
throw new Error(
"You can't use this function in a standalone script or flow step preview. Please use it in a flow or a flow preview."
);
}
const flowStepId = getEnv("WM_FLOW_STEP_ID");
if (!flowStepId) {
throw new Error("This function can only be called as a flow step");
}
// Only include non-empty parameters
const params: {
approver?: string;
message?: string;
slackResourcePath: string;
channelId: string;
flowStepId: string;
defaultArgsJson?: string;
dynamicEnumsJson?: string;
} = {
slackResourcePath,
channelId,
flowStepId,
};
if (message) {
params.message = message;
}
if (approver) {
params.approver = approver;
}
if (defaultArgsJson) {
params.defaultArgsJson = JSON.stringify(defaultArgsJson);
}
if (dynamicEnumsJson) {
params.dynamicEnumsJson = JSON.stringify(dynamicEnumsJson);
}
await JobService.getSlackApprovalPayload({
workspace,
...params,
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
});
}
/**
* Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.
*
* **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**
* and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).
*
* @param {Object} options - The configuration options for the Teams approval request.
* @param {string} options.teamName - The Teams team name where the approval request will be sent.
* @param {string} options.channelName - The Teams channel name where the approval request will be sent.
* @param {string} [options.message] - Optional custom message to include in the Teams approval request.
* @param {string} [options.approver] - Optional user ID or name of the approver for the request.
* @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.
* @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.
*
* @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.
*
* @throws {Error} If the function is not called within a flow or flow preview.
* @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.
*
* **Usage Example:**
* ```typescript
* await requestInteractiveTeamsApproval({
* teamName: "admins-teams",
* channelName: "admins-teams-channel",
* message: "Please approve this request",
* approver: "approver123",
* defaultArgsJson: { key1: "value1", key2: 42 },
* dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] },
* });
* ```
*
* **Note:** This function requires execution within a Windmill flow or flow preview.
*/
export async function requestInteractiveTeamsApproval({
teamName,
channelName,
message,
approver,
defaultArgsJson,
dynamicEnumsJson,
}: TeamsApprovalOptions): Promise<void> {
const workspace = getWorkspace();
const flowJobId = getEnv("WM_FLOW_JOB_ID");
if (!flowJobId) {
throw new Error(
"You can't use this function in a standalone script or flow step preview. Please use it in a flow or a flow preview."
);
}
const flowStepId = getEnv("WM_FLOW_STEP_ID");
if (!flowStepId) {
throw new Error("This function can only be called as a flow step");
}
// Only include non-empty parameters
const params: {
approver?: string;
message?: string;
teamName: string;
channelName: string;
flowStepId: string;
defaultArgsJson?: string;
dynamicEnumsJson?: string;
} = {
teamName,
channelName,
flowStepId,
};
if (message) {
params.message = message;
}
if (approver) {
params.approver = approver;
}
if (defaultArgsJson) {
params.defaultArgsJson = JSON.stringify(defaultArgsJson);
}
if (dynamicEnumsJson) {
params.dynamicEnumsJson = JSON.stringify(dynamicEnumsJson);
}
await JobService.getTeamsApprovalPayload({
workspace,
...params,
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
});
}
async function getMockedApi(): Promise<MockedApi | undefined> {
if (mockedApi) {
return mockedApi;
}
const mockedPath = getEnv("WM_MOCKED_API_FILE");
if (mockedPath) {
console.info("Using mocked API from", mockedPath);
} else {
return undefined;
}
try {
const fs = await import("node:fs/promises");
const file = await fs.readFile(mockedPath, "utf-8");
try {
mockedApi = JSON.parse(file) as MockedApi;
if (!mockedApi.variables) {
mockedApi.variables = {};
}
if (!mockedApi.resources) {
mockedApi.resources = {};
}
return mockedApi;
} catch {
console.warn("Error parsing mocked API file at path", mockedPath);
}
} catch {
console.warn("Error reading mocked API file at path", mockedPath);
}
if (!mockedApi) {
console.warn(
"No mocked API file path provided at env variable WM_MOCKED_API_FILE. Using empty mocked API."
);
mockedApi = {
variables: {},
resources: {},
};
return mockedApi;
}
}
interface MockedApi {
variables: Record<string, string>;
resources: Record<string, any>;
}
function parseResourceSyntax(s: string | undefined) {
if (s?.startsWith("$res:")) return s.substring(5);
if (s?.startsWith("res://")) return s.substring(6);
}
export function parseS3Object(s3Object: S3Object): S3ObjectRecord {
if (typeof s3Object === "object") return s3Object;
const match = s3Object.match(/^s3:\/\/([^/]*)\/(.*)$/);
return { storage: match?.[1] || undefined, s3: match?.[2] ?? "" };
}
function parseVariableSyntax(s: string) {
if (s.startsWith("var://")) return s.substring(6);
}