@ezghcloud/workflows 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +417 -2
- package/dist/client.d.ts +209 -0
- package/dist/client.js +259 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.js +32 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +7 -0
- package/dist/protocol/bytes.d.ts +8 -0
- package/dist/protocol/bytes.js +49 -0
- package/dist/protocol/callback.d.ts +6 -0
- package/dist/protocol/callback.js +31 -0
- package/dist/protocol/codec.d.ts +6 -0
- package/dist/protocol/codec.js +38 -0
- package/dist/protocol/derived.d.ts +5 -0
- package/dist/protocol/derived.js +41 -0
- package/dist/protocol/index.d.ts +14 -0
- package/dist/protocol/index.js +21 -0
- package/dist/protocol/public.d.ts +2 -0
- package/dist/protocol/public.js +1 -0
- package/dist/protocol/signature.d.ts +17 -0
- package/dist/protocol/signature.js +101 -0
- package/dist/protocol/stepid.d.ts +4 -0
- package/dist/protocol/stepid.js +9 -0
- package/dist/protocol/tokens.d.ts +2 -0
- package/dist/protocol/tokens.js +38 -0
- package/dist/protocol/types.d.ts +185 -0
- package/dist/protocol/types.js +1 -0
- package/dist/protocol/validate.d.ts +15 -0
- package/dist/protocol/validate.js +279 -0
- package/dist/queues.d.ts +47 -0
- package/dist/queues.js +126 -0
- package/dist/runtime/context.d.ts +82 -0
- package/dist/runtime/context.js +1 -0
- package/dist/runtime/errors.d.ts +49 -0
- package/dist/runtime/errors.js +160 -0
- package/dist/runtime/execution.d.ts +17 -0
- package/dist/runtime/execution.js +608 -0
- package/dist/runtime/invocation.d.ts +5 -0
- package/dist/runtime/invocation.js +47 -0
- package/dist/runtime/live.d.ts +23 -0
- package/dist/runtime/live.js +103 -0
- package/dist/runtime/workflow.d.ts +26 -0
- package/dist/runtime/workflow.js +49 -0
- package/dist/serve.d.ts +12 -0
- package/dist/serve.js +149 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/worker.d.ts +43 -0
- package/dist/worker.js +364 -0
- package/package.json +49 -4
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { RetryPolicy, SDKInfo, WireError } from "./protocol/index.ts";
|
|
2
|
+
import type { FetchLike } from "./runtime/live.ts";
|
|
3
|
+
export type RunStatus = "running" | "cancelling" | "completed" | "failed" | "cancelled" | "timedOut" | (string & {});
|
|
4
|
+
export interface RunStep {
|
|
5
|
+
seq: number;
|
|
6
|
+
stepId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
kind: string;
|
|
9
|
+
status: string;
|
|
10
|
+
attempt: number;
|
|
11
|
+
hasOutput: boolean;
|
|
12
|
+
error: WireError | null;
|
|
13
|
+
}
|
|
14
|
+
export interface Run {
|
|
15
|
+
id: string;
|
|
16
|
+
runKey: string | null;
|
|
17
|
+
appId: string;
|
|
18
|
+
workflow: string;
|
|
19
|
+
status: RunStatus;
|
|
20
|
+
attempt: number;
|
|
21
|
+
error: WireError | null;
|
|
22
|
+
causeStepId: string | null;
|
|
23
|
+
steps: RunStep[];
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
endedAt: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface RunEvent {
|
|
29
|
+
id: string;
|
|
30
|
+
at: string;
|
|
31
|
+
type: string;
|
|
32
|
+
stepId: string | null;
|
|
33
|
+
invocationId: string | null;
|
|
34
|
+
deployment: string | null;
|
|
35
|
+
detail: unknown;
|
|
36
|
+
}
|
|
37
|
+
export interface AppWorkflow {
|
|
38
|
+
name: string;
|
|
39
|
+
retry?: RetryPolicy;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface App {
|
|
43
|
+
id: string;
|
|
44
|
+
slug: string;
|
|
45
|
+
resourceName: string;
|
|
46
|
+
organizationId: string;
|
|
47
|
+
projectId: string;
|
|
48
|
+
name: string;
|
|
49
|
+
kind: "push" | "pull" | (string & {});
|
|
50
|
+
endpoint: string | null;
|
|
51
|
+
syncStatus: "unsynced" | "synced" | "failed" | (string & {});
|
|
52
|
+
workflows: AppWorkflow[];
|
|
53
|
+
maxInvocationSeconds: number | null;
|
|
54
|
+
protocolVersion: string | null;
|
|
55
|
+
sdk: SDKInfo | null;
|
|
56
|
+
deployment: string | null;
|
|
57
|
+
health: string;
|
|
58
|
+
lastSyncAt: string | null;
|
|
59
|
+
createdAt: string;
|
|
60
|
+
updatedAt: string;
|
|
61
|
+
queue?: string | null;
|
|
62
|
+
partitions?: number | null;
|
|
63
|
+
}
|
|
64
|
+
export interface Schedule {
|
|
65
|
+
id: string;
|
|
66
|
+
resourceName: string;
|
|
67
|
+
appId: string;
|
|
68
|
+
workflow: string;
|
|
69
|
+
name: string;
|
|
70
|
+
cron: string;
|
|
71
|
+
timeZone: string;
|
|
72
|
+
overlap: "skip" | "allow" | "bufferOne" | (string & {});
|
|
73
|
+
jitterSeconds: number;
|
|
74
|
+
catchupWindowSeconds: number;
|
|
75
|
+
paused: boolean;
|
|
76
|
+
revision: number;
|
|
77
|
+
nextFireAt: string | null;
|
|
78
|
+
nextScheduledTime: string | null;
|
|
79
|
+
lastScheduledTime: string | null;
|
|
80
|
+
activeRunId: string | null;
|
|
81
|
+
missed: number;
|
|
82
|
+
skipped: number;
|
|
83
|
+
hasInput: boolean;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
updatedAt: string;
|
|
86
|
+
}
|
|
87
|
+
export interface ScheduleSummary {
|
|
88
|
+
id: string;
|
|
89
|
+
resourceName: string;
|
|
90
|
+
appId: string;
|
|
91
|
+
workflow: string;
|
|
92
|
+
name: string;
|
|
93
|
+
cron: string;
|
|
94
|
+
timeZone: string;
|
|
95
|
+
paused: boolean;
|
|
96
|
+
revision: number;
|
|
97
|
+
updatedAt: string;
|
|
98
|
+
}
|
|
99
|
+
export interface ScheduleInput {
|
|
100
|
+
appId: string;
|
|
101
|
+
workflow: string;
|
|
102
|
+
name: string;
|
|
103
|
+
cron: string;
|
|
104
|
+
timeZone?: string;
|
|
105
|
+
input?: unknown;
|
|
106
|
+
overlap?: "skip" | "allow" | "bufferOne";
|
|
107
|
+
jitterSeconds?: number;
|
|
108
|
+
catchupWindowSeconds?: number;
|
|
109
|
+
paused?: boolean;
|
|
110
|
+
}
|
|
111
|
+
export type ScheduleUpdate = Partial<Omit<ScheduleInput, "appId" | "paused">>;
|
|
112
|
+
export interface AppWithSecret {
|
|
113
|
+
app: App;
|
|
114
|
+
signingSecret: string | null;
|
|
115
|
+
}
|
|
116
|
+
export declare class WorkflowsApiError extends Error {
|
|
117
|
+
readonly status: number;
|
|
118
|
+
readonly code: string;
|
|
119
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
120
|
+
name: string;
|
|
121
|
+
constructor(status: number, code: string, message: string, retryAfterSeconds?: number | undefined);
|
|
122
|
+
}
|
|
123
|
+
export interface ClientOptions {
|
|
124
|
+
apiKey: string;
|
|
125
|
+
organizationId: string;
|
|
126
|
+
projectId: string;
|
|
127
|
+
baseUrl?: string;
|
|
128
|
+
fetch?: FetchLike;
|
|
129
|
+
retries?: number;
|
|
130
|
+
maxRetryDelayMs?: number;
|
|
131
|
+
timeoutMs?: number;
|
|
132
|
+
}
|
|
133
|
+
export declare function completeCallbackURL(url: string, value: unknown, opts?: {
|
|
134
|
+
fetch?: FetchLike;
|
|
135
|
+
timeoutMs?: number;
|
|
136
|
+
}): Promise<void>;
|
|
137
|
+
export declare class Client {
|
|
138
|
+
#private;
|
|
139
|
+
constructor(opts: ClientOptions);
|
|
140
|
+
private call;
|
|
141
|
+
private send;
|
|
142
|
+
private withSecret;
|
|
143
|
+
createApp(name: string, opts?: {
|
|
144
|
+
endpoint?: string;
|
|
145
|
+
kind?: "push" | "pull";
|
|
146
|
+
queue?: string;
|
|
147
|
+
partitions?: number;
|
|
148
|
+
idempotencyKey?: string;
|
|
149
|
+
}): Promise<AppWithSecret>;
|
|
150
|
+
getApp(appId: string): Promise<App>;
|
|
151
|
+
listApps(opts?: {
|
|
152
|
+
cursor?: string;
|
|
153
|
+
limit?: number;
|
|
154
|
+
}): Promise<{
|
|
155
|
+
apps: App[];
|
|
156
|
+
nextCursor: string | null;
|
|
157
|
+
}>;
|
|
158
|
+
devApp(opts?: {
|
|
159
|
+
user?: string;
|
|
160
|
+
name?: string;
|
|
161
|
+
partitions?: number;
|
|
162
|
+
}): Promise<App>;
|
|
163
|
+
sync(appId: string, opts?: {
|
|
164
|
+
endpoint?: string;
|
|
165
|
+
deployment?: string;
|
|
166
|
+
}): Promise<App>;
|
|
167
|
+
rotateSecret(appId: string): Promise<AppWithSecret>;
|
|
168
|
+
start(appId: string, workflow: string, input?: unknown, opts?: {
|
|
169
|
+
runKey?: string;
|
|
170
|
+
idempotencyKey?: string;
|
|
171
|
+
}): Promise<Run>;
|
|
172
|
+
get(runId: string): Promise<Run>;
|
|
173
|
+
cancel(runId: string, opts?: {
|
|
174
|
+
gracePeriodSeconds?: number;
|
|
175
|
+
}): Promise<Run>;
|
|
176
|
+
retry(runId: string): Promise<Run>;
|
|
177
|
+
output(runId: string): Promise<unknown>;
|
|
178
|
+
input(runId: string): Promise<unknown>;
|
|
179
|
+
stepOutput(runId: string, stepId: string): Promise<unknown>;
|
|
180
|
+
events(runId: string, opts?: {
|
|
181
|
+
cursor?: string;
|
|
182
|
+
limit?: number;
|
|
183
|
+
}): Promise<{
|
|
184
|
+
events: RunEvent[];
|
|
185
|
+
nextCursor: string | null;
|
|
186
|
+
}>;
|
|
187
|
+
createSchedule(input: ScheduleInput, opts?: {
|
|
188
|
+
idempotencyKey?: string;
|
|
189
|
+
}): Promise<Schedule>;
|
|
190
|
+
getSchedule(scheduleId: string): Promise<Schedule>;
|
|
191
|
+
listSchedules(opts?: {
|
|
192
|
+
cursor?: string;
|
|
193
|
+
limit?: number;
|
|
194
|
+
}): Promise<{
|
|
195
|
+
schedules: ScheduleSummary[];
|
|
196
|
+
nextCursor: string | null;
|
|
197
|
+
}>;
|
|
198
|
+
updateSchedule(scheduleId: string, patch: ScheduleUpdate, opts: {
|
|
199
|
+
revision: number;
|
|
200
|
+
}): Promise<Schedule>;
|
|
201
|
+
pauseSchedule(scheduleId: string): Promise<Schedule>;
|
|
202
|
+
resumeSchedule(scheduleId: string): Promise<Schedule>;
|
|
203
|
+
deleteSchedule(scheduleId: string): Promise<void>;
|
|
204
|
+
triggerSchedule(scheduleId: string, opts?: {
|
|
205
|
+
idempotencyKey?: string;
|
|
206
|
+
}): Promise<Run>;
|
|
207
|
+
scheduleInput(scheduleId: string): Promise<unknown>;
|
|
208
|
+
completeCallback(runId: string, stepId: string, value: unknown): Promise<Run>;
|
|
209
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { devQueue } from "./worker.js";
|
|
2
|
+
// An error answer from the Workflows API: the HTTP status and the platform error code. Status 0
|
|
3
|
+
// with code "outcome_unknown": the request may or may not have taken effect (a network error on
|
|
4
|
+
// createApp or rotateSecret, which aren't resent; see there).
|
|
5
|
+
export class WorkflowsApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
retryAfterSeconds;
|
|
9
|
+
name = "WorkflowsApiError";
|
|
10
|
+
constructor(status, code, message, retryAfterSeconds) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
const id = encodeURIComponent;
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
20
|
+
// What an answer to a request that returns a secret (createApp, rotateSecret) can't be resent on:
|
|
21
|
+
// the request may have taken effect, and its replay returns no secret.
|
|
22
|
+
const outcomeUnknown = (why) => new WorkflowsApiError(0, "outcome_unknown", `${why}, so the request may have taken effect: a resend would return no secret. Check with getApp, and rotate the app's secret if you didn't receive it.`);
|
|
23
|
+
function checkLimit(limit) {
|
|
24
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
|
|
25
|
+
throw new TypeError("limit is a whole number ≥ 1");
|
|
26
|
+
}
|
|
27
|
+
function apiError(res, data) {
|
|
28
|
+
const e = data?.error;
|
|
29
|
+
const ra = Number(res.headers.get("Retry-After"));
|
|
30
|
+
if (res.status >= 300 && res.status < 400)
|
|
31
|
+
return new WorkflowsApiError(res.status, "redirect", `The API answered a redirect (${res.status}); requests are never redirected, so check baseUrl`);
|
|
32
|
+
return new WorkflowsApiError(res.status, e?.code ?? "unknown", e?.message ?? `HTTP ${res.status}`, Number.isFinite(ra) && ra >= 0 && res.headers.get("Retry-After") !== null ? ra : undefined);
|
|
33
|
+
}
|
|
34
|
+
// completeCallbackURL completes a callback by its URL (step.createCallback's url): it POSTs value
|
|
35
|
+
// as JSON with no credential (the URL's token is the credential, so errors never include it). The
|
|
36
|
+
// first completion wins: the same value again succeeds, a different one is 409
|
|
37
|
+
// callback_completed, and after the timeout or the run's end it's 410 callback_expired.
|
|
38
|
+
export async function completeCallbackURL(url, value, opts = {}) {
|
|
39
|
+
const fetchImpl = opts.fetch ?? ((u, i) => fetch(u, i));
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
res = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(value === undefined ? null : value),
|
|
43
|
+
redirect: "manual", signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw new WorkflowsApiError(0, "network_error", `Completing the callback failed in transit: ${e instanceof Error ? e.name : "error"}`);
|
|
47
|
+
}
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
if (res.ok)
|
|
50
|
+
return;
|
|
51
|
+
let data;
|
|
52
|
+
try {
|
|
53
|
+
data = text ? JSON.parse(text) : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
data = undefined;
|
|
57
|
+
}
|
|
58
|
+
throw apiError(res, data);
|
|
59
|
+
}
|
|
60
|
+
// Client calls the Workflows API for one project: apps (create, sync, rotate the secret) and runs
|
|
61
|
+
// (start, inspect, cancel, retry, payloads, events).
|
|
62
|
+
export class Client {
|
|
63
|
+
// ES private fields: the API key never shows in console.log, util.inspect or JSON.stringify.
|
|
64
|
+
#apiKey;
|
|
65
|
+
#base;
|
|
66
|
+
#fetch;
|
|
67
|
+
#retries;
|
|
68
|
+
#maxDelay;
|
|
69
|
+
#timeout;
|
|
70
|
+
constructor(opts) {
|
|
71
|
+
this.#apiKey = opts.apiKey;
|
|
72
|
+
this.#base = `${(opts.baseUrl ?? "https://workflows-api.ezghcloud.com").replace(/\/+$/, "")}/v1/organizations/${id(opts.organizationId)}/projects/${id(opts.projectId)}`;
|
|
73
|
+
this.#fetch = opts.fetch ?? ((u, i) => fetch(u, i));
|
|
74
|
+
this.#retries = opts.retries ?? 3;
|
|
75
|
+
this.#maxDelay = opts.maxRetryDelayMs ?? 10_000;
|
|
76
|
+
if (!Number.isInteger(this.#retries) || this.#retries < 0)
|
|
77
|
+
throw new TypeError("retries is a whole number ≥ 0");
|
|
78
|
+
if (!Number.isFinite(this.#maxDelay) || this.#maxDelay < 0)
|
|
79
|
+
throw new TypeError("maxRetryDelayMs is a number ≥ 0");
|
|
80
|
+
this.#timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
81
|
+
if (!Number.isFinite(this.#timeout) || this.#timeout <= 0)
|
|
82
|
+
throw new TypeError("timeoutMs is a number > 0");
|
|
83
|
+
}
|
|
84
|
+
// call sends one request, retrying 429, 503, a 409 idempotency_in_progress and network errors
|
|
85
|
+
// (timeouts included) with the same Idempotency-Key, after Retry-After. Redirects are errors.
|
|
86
|
+
// returnsSecret (createApp, rotateSecret): only a 429 is retried; a network error, 503 or 409
|
|
87
|
+
// in progress is outcome_unknown, since the request may have taken effect and its replay would
|
|
88
|
+
// return no secret.
|
|
89
|
+
call(method, path, body, idempotencyKey, returnsSecret = false, extraHeaders = {}) {
|
|
90
|
+
if (idempotencyKey !== undefined && (typeof idempotencyKey !== "string" || idempotencyKey === ""))
|
|
91
|
+
throw new TypeError("An idempotencyKey is a non-empty string");
|
|
92
|
+
return this.send(method, path, body, idempotencyKey, returnsSecret, extraHeaders);
|
|
93
|
+
}
|
|
94
|
+
async send(method, path, body, idempotencyKey, returnsSecret, extraHeaders) {
|
|
95
|
+
const headers = { Authorization: `Bearer ${this.#apiKey}`, Accept: "application/json", ...extraHeaders };
|
|
96
|
+
if (body !== undefined)
|
|
97
|
+
headers["Content-Type"] = "application/json";
|
|
98
|
+
if (method !== "GET")
|
|
99
|
+
headers["Idempotency-Key"] = idempotencyKey ?? crypto.randomUUID();
|
|
100
|
+
const retries = this.#retries;
|
|
101
|
+
const maxDelay = this.#maxDelay;
|
|
102
|
+
for (let attempt = 0;; attempt++) {
|
|
103
|
+
let res;
|
|
104
|
+
try {
|
|
105
|
+
res = await this.#fetch(this.#base + path, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), redirect: "manual", signal: AbortSignal.timeout(this.#timeout) });
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (returnsSecret)
|
|
109
|
+
throw outcomeUnknown(`The request failed in transit (${e instanceof Error ? e.message : String(e)})`);
|
|
110
|
+
if (attempt >= retries)
|
|
111
|
+
throw e;
|
|
112
|
+
await sleep(Math.min(maxDelay, 500 * 2 ** attempt));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const text = await res.text();
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = text ? JSON.parse(text) : undefined;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
data = undefined;
|
|
122
|
+
}
|
|
123
|
+
if (res.ok)
|
|
124
|
+
return data;
|
|
125
|
+
const err = apiError(res, data);
|
|
126
|
+
const inProgress = res.status === 409 && err.code === "idempotency_in_progress";
|
|
127
|
+
if (returnsSecret && (res.status === 503 || inProgress))
|
|
128
|
+
throw outcomeUnknown(`The API answered ${res.status} ${err.code}`);
|
|
129
|
+
if ((res.status !== 429 && res.status !== 503 && !inProgress) || attempt >= retries)
|
|
130
|
+
throw err;
|
|
131
|
+
await sleep(Math.min(maxDelay, (err.retryAfterSeconds ?? 1) * 1000));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// withSecret is a secret-bearing answer: a push app's success without its secret is the replay of
|
|
135
|
+
// an earlier request (an Idempotency-Key reused), and the secret is lost: outcome_unknown.
|
|
136
|
+
async withSecret(p, pull = false) {
|
|
137
|
+
const r = await p;
|
|
138
|
+
if (!pull && r?.signingSecret == null)
|
|
139
|
+
throw outcomeUnknown("The API answered with no signing secret (the replay of an earlier request with this Idempotency-Key)");
|
|
140
|
+
return r;
|
|
141
|
+
}
|
|
142
|
+
// Apps
|
|
143
|
+
// createApp returns the app and its signing secret, once. An answer that leaves it unknown
|
|
144
|
+
// whether the app was created (a network error, a timeout, a 503, a 409 in progress, or a
|
|
145
|
+
// replay's null secret) throws outcome_unknown: a resend would return no secret. A 429 (not
|
|
146
|
+
// processed) is retried. A pull app has no secret.
|
|
147
|
+
createApp(name, opts = {}) {
|
|
148
|
+
const kind = opts.kind ?? "push";
|
|
149
|
+
return this.withSecret(this.call("POST", "/apps", { name, kind, ...(opts.endpoint ? { endpoint: opts.endpoint } : {}), ...(opts.queue ? { queue: opts.queue } : {}), ...(opts.partitions !== undefined ? { partitions: opts.partitions } : {}) }, opts.idempotencyKey, true), kind === "pull");
|
|
150
|
+
}
|
|
151
|
+
getApp(appId) { return this.call("GET", `/apps/${id(appId)}`); }
|
|
152
|
+
listApps(opts = {}) {
|
|
153
|
+
checkLimit(opts.limit);
|
|
154
|
+
const q = new URLSearchParams();
|
|
155
|
+
if (opts.cursor)
|
|
156
|
+
q.set("cursor", opts.cursor);
|
|
157
|
+
if (opts.limit !== undefined)
|
|
158
|
+
q.set("limit", String(opts.limit));
|
|
159
|
+
const qs = q.toString();
|
|
160
|
+
return this.call("GET", `/apps${qs ? `?${qs}` : ""}`);
|
|
161
|
+
}
|
|
162
|
+
// devApp creates, or finds, a developer's own pull app for dev mode: named and queued
|
|
163
|
+
// devQueue(user) (dev-<user>), so worker({ app: app.id, dev: true }) polls it. The live host
|
|
164
|
+
// serves only an app's own queue, so each developer needs an app of their own.
|
|
165
|
+
async devApp(opts = {}) {
|
|
166
|
+
const queue = devQueue(opts.user);
|
|
167
|
+
const name = opts.name ?? queue;
|
|
168
|
+
try {
|
|
169
|
+
return (await this.createApp(name, { kind: "pull", queue, ...(opts.partitions !== undefined ? { partitions: opts.partitions } : {}) })).app;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
// It exists (409 app_exists), or the create's outcome is unknown: find it by name.
|
|
173
|
+
if (!(e instanceof WorkflowsApiError && ((e.status === 409 && e.code === "app_exists") || e.code === "outcome_unknown")))
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
for (let cursor;;) {
|
|
177
|
+
const page = await this.listApps({ limit: 100, ...(cursor ? { cursor } : {}) });
|
|
178
|
+
const app = page.apps.find((a) => a.name === name);
|
|
179
|
+
if (app) {
|
|
180
|
+
if (app.kind !== "pull" || app.queue !== queue)
|
|
181
|
+
throw new WorkflowsApiError(409, "app_exists", `The app "${name}" exists but isn't a pull app with the queue ${queue}; pass devApp({ name }) another name`);
|
|
182
|
+
return app;
|
|
183
|
+
}
|
|
184
|
+
if (!page.nextCursor)
|
|
185
|
+
throw new WorkflowsApiError(404, "not_found", `The dev app "${name}" was neither created nor found`);
|
|
186
|
+
cursor = page.nextCursor;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// sync asks EZGH to call the app's endpoint and record its workflows; run it after each deploy.
|
|
190
|
+
// A failed sync is a 409 sync_failed.
|
|
191
|
+
sync(appId, opts = {}) {
|
|
192
|
+
return this.call("POST", `/apps/${id(appId)}/sync`, opts);
|
|
193
|
+
}
|
|
194
|
+
// rotateSecret returns a new signing secret; the old ones stay valid for 24 hours.
|
|
195
|
+
// A network error isn't resent, as for createApp (outcome_unknown).
|
|
196
|
+
rotateSecret(appId) { return this.withSecret(this.call("POST", `/apps/${id(appId)}/rotate-secret`, {}, undefined, true)); }
|
|
197
|
+
// Runs
|
|
198
|
+
// start a run: 201 for a new run. With runKey, starting again with the same workflow and input
|
|
199
|
+
// returns the same run, and different input is a 409 run_key_conflict.
|
|
200
|
+
start(appId, workflow, input, opts = {}) {
|
|
201
|
+
return this.call("POST", "/runs", { appId, workflow, ...(input !== undefined ? { input } : {}), ...(opts.runKey ? { runKey: opts.runKey } : {}) }, opts.idempotencyKey);
|
|
202
|
+
}
|
|
203
|
+
get(runId) { return this.call("GET", `/runs/${id(runId)}`); }
|
|
204
|
+
// cancel: the run is cancelling; its code gets gracePeriodSeconds (0–3600, default 300) to clean up.
|
|
205
|
+
cancel(runId, opts = {}) {
|
|
206
|
+
return this.call("POST", `/runs/${id(runId)}/cancel`, opts.gracePeriodSeconds === undefined ? {} : { gracePeriodSeconds: opts.gracePeriodSeconds });
|
|
207
|
+
}
|
|
208
|
+
// retry a failed run from its failed step; completed steps aren't run again.
|
|
209
|
+
retry(runId) { return this.call("POST", `/runs/${id(runId)}/retry`, {}); }
|
|
210
|
+
// The run's output (workflows.runs.getPayload); 409 run_not_completed until it completes.
|
|
211
|
+
output(runId) { return this.call("GET", `/runs/${id(runId)}/payloads/output`); }
|
|
212
|
+
input(runId) { return this.call("GET", `/runs/${id(runId)}/payloads/input`); }
|
|
213
|
+
stepOutput(runId, stepId) { return this.call("GET", `/runs/${id(runId)}/steps/${id(stepId)}/output`); }
|
|
214
|
+
events(runId, opts = {}) {
|
|
215
|
+
checkLimit(opts.limit);
|
|
216
|
+
const q = new URLSearchParams();
|
|
217
|
+
if (opts.cursor)
|
|
218
|
+
q.set("cursor", opts.cursor);
|
|
219
|
+
if (opts.limit !== undefined)
|
|
220
|
+
q.set("limit", String(opts.limit));
|
|
221
|
+
const qs = q.toString();
|
|
222
|
+
return this.call("GET", `/runs/${id(runId)}/events${qs ? `?${qs}` : ""}`);
|
|
223
|
+
}
|
|
224
|
+
// Schedules: cron schedules that start runs of one workflow (design-phase-2 "Schedules"), with
|
|
225
|
+
// the workflows.schedules.* actions.
|
|
226
|
+
createSchedule(input, opts = {}) { return this.call("POST", "/schedules", input, opts.idempotencyKey); }
|
|
227
|
+
getSchedule(scheduleId) { return this.call("GET", `/schedules/${id(scheduleId)}`); }
|
|
228
|
+
listSchedules(opts = {}) {
|
|
229
|
+
checkLimit(opts.limit);
|
|
230
|
+
const q = new URLSearchParams();
|
|
231
|
+
if (opts.cursor)
|
|
232
|
+
q.set("cursor", opts.cursor);
|
|
233
|
+
if (opts.limit !== undefined)
|
|
234
|
+
q.set("limit", String(opts.limit));
|
|
235
|
+
const qs = q.toString();
|
|
236
|
+
return this.call("GET", `/schedules${qs ? `?${qs}` : ""}`);
|
|
237
|
+
}
|
|
238
|
+
// updateSchedule changes the given fields. revision is the schedule's revision you read
|
|
239
|
+
// (If-Match): a change since then answers 412.
|
|
240
|
+
updateSchedule(scheduleId, patch, opts) {
|
|
241
|
+
return this.call("PATCH", `/schedules/${id(scheduleId)}`, patch, undefined, false, { "If-Match": `"${opts.revision}"` });
|
|
242
|
+
}
|
|
243
|
+
pauseSchedule(scheduleId) { return this.call("POST", `/schedules/${id(scheduleId)}/pause`, {}); }
|
|
244
|
+
// resumeSchedule fires again from now, with no catch-up.
|
|
245
|
+
resumeSchedule(scheduleId) { return this.call("POST", `/schedules/${id(scheduleId)}/resume`, {}); }
|
|
246
|
+
async deleteSchedule(scheduleId) { await this.call("DELETE", `/schedules/${id(scheduleId)}`); }
|
|
247
|
+
// triggerSchedule starts a run now. The Idempotency-Key names the run, so the same key always
|
|
248
|
+
// answers the same run (without one, each call starts a new run).
|
|
249
|
+
triggerSchedule(scheduleId, opts = {}) { return this.call("POST", `/schedules/${id(scheduleId)}/trigger`, {}, opts.idempotencyKey); }
|
|
250
|
+
// The input every run of the schedule gets (workflows.runs.getPayload); null when none.
|
|
251
|
+
scheduleInput(scheduleId) { return this.call("GET", `/schedules/${id(scheduleId)}/payloads/input`); }
|
|
252
|
+
// Callbacks
|
|
253
|
+
// completeCallback completes a waiting callback as an authenticated caller
|
|
254
|
+
// (workflows.callbacks.complete, in the workflows:runs scope) and answers the run. The first
|
|
255
|
+
// completion wins: the same value again succeeds, a different one is 409 callback_completed,
|
|
256
|
+
// and after the timeout or the run's end it's 410 callback_expired. The public alternative is
|
|
257
|
+
// POSTing the value to the callback's URL.
|
|
258
|
+
completeCallback(runId, stepId, value) { return this.call("POST", `/runs/${id(runId)}/callbacks/${id(stepId)}`, value === undefined ? null : value); }
|
|
259
|
+
}
|
package/dist/env.d.ts
ADDED
package/dist/env.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { truncateUtf8 } from "./protocol/bytes.js";
|
|
2
|
+
// The longest deployment label the engine stores (internal/protocol MaxDeploymentLen), in bytes.
|
|
3
|
+
const MAX_DEPLOYMENT_BYTES = 256;
|
|
4
|
+
// cleanDeployment: a deployment label as the engine takes it: NUL characters removed, cut to
|
|
5
|
+
// 256 bytes of UTF-8. Empty is none.
|
|
6
|
+
export function cleanDeployment(s) {
|
|
7
|
+
if (typeof s !== "string")
|
|
8
|
+
return undefined;
|
|
9
|
+
return truncateUtf8(s.replaceAll("\u0000", ""), MAX_DEPLOYMENT_BYTES) || undefined;
|
|
10
|
+
}
|
|
11
|
+
// Deployment IDs from the platform's environment (design, "Runs continue on whatever code is
|
|
12
|
+
// deployed"): Vercel, AWS Lambda (function:version, but not $LATEST), Cloud Run, Fly.io and Render.
|
|
13
|
+
export function detectDeployment() {
|
|
14
|
+
try {
|
|
15
|
+
// Deno throws on process.env without --allow-env.
|
|
16
|
+
const env = globalThis.process?.env;
|
|
17
|
+
if (!env)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (env.VERCEL_DEPLOYMENT_ID)
|
|
20
|
+
return cleanDeployment(env.VERCEL_DEPLOYMENT_ID);
|
|
21
|
+
const fn = env.AWS_LAMBDA_FUNCTION_NAME, version = env.AWS_LAMBDA_FUNCTION_VERSION;
|
|
22
|
+
if (fn && version && version !== "$LATEST")
|
|
23
|
+
return cleanDeployment(`${fn}:${version}`);
|
|
24
|
+
for (const k of ["K_REVISION", "FLY_MACHINE_VERSION", "RENDER_GIT_COMMIT"])
|
|
25
|
+
if (env[k])
|
|
26
|
+
return cleanDeployment(env[k]);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { SDK_NAME, SDK_VERSION } from "./version.ts";
|
|
2
|
+
export { workflow } from "./runtime/workflow.ts";
|
|
3
|
+
export type { Workflow, WorkflowOptions } from "./runtime/workflow.ts";
|
|
4
|
+
export type { WorkflowContext, Step, StepRunContext, StepRunOptions, Jsonify, Callback, CreateCallbackOptions, InvokeOptions, ReceivedMessage, WaitForMessageOptions } from "./runtime/context.ts";
|
|
5
|
+
export { NonRetriableError, RetryAfterError, CancelledError, WorkflowError, CallbackTimeout, ChildFailed } from "./runtime/errors.ts";
|
|
6
|
+
export { serve } from "./serve.ts";
|
|
7
|
+
export type { ServeOptions } from "./serve.ts";
|
|
8
|
+
export { Client, WorkflowsApiError, completeCallbackURL } from "./client.ts";
|
|
9
|
+
export type { App, AppWithSecret, AppWorkflow, ClientOptions, Run, RunEvent, RunStatus, RunStep, Schedule, ScheduleInput, ScheduleSummary, ScheduleUpdate } from "./client.ts";
|
|
10
|
+
export type { FetchLike } from "./runtime/live.ts";
|
|
11
|
+
export type { RetryPolicy, ScheduleInfo } from "./protocol/types.ts";
|
|
12
|
+
export { worker, devQueue, WorkerAuthError, WorkerConfigError } from "./worker.ts";
|
|
13
|
+
export type { Worker, WorkerOptions } from "./worker.ts";
|
|
14
|
+
export { QueuesClient, QueuesApiError } from "./queues.ts";
|
|
15
|
+
export type { QueuesClientOptions, QueueMessageInput, SentMessage, SentMessages } from "./queues.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
2
|
+
export { workflow } from "./runtime/workflow.js";
|
|
3
|
+
export { NonRetriableError, RetryAfterError, CancelledError, WorkflowError, CallbackTimeout, ChildFailed } from "./runtime/errors.js";
|
|
4
|
+
export { serve } from "./serve.js";
|
|
5
|
+
export { Client, WorkflowsApiError, completeCallbackURL } from "./client.js";
|
|
6
|
+
export { worker, devQueue, WorkerAuthError, WorkerConfigError } from "./worker.js";
|
|
7
|
+
export { QueuesClient, QueuesApiError } from "./queues.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const utf8: (s: string) => Uint8Array<ArrayBuffer>;
|
|
2
|
+
export declare function toHex(b: Uint8Array): string;
|
|
3
|
+
export declare function fromHex(h: string): Uint8Array<ArrayBuffer>;
|
|
4
|
+
export declare function toBase64url(b: Uint8Array): string;
|
|
5
|
+
export declare function fromBase64url(s: string): Uint8Array<ArrayBuffer>;
|
|
6
|
+
export declare function concat(a: Uint8Array, b: Uint8Array): Uint8Array<ArrayBuffer>;
|
|
7
|
+
export declare const utf8Length: (s: string) => number;
|
|
8
|
+
export declare function truncateUtf8(s: string, maxBytes: number): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const utf8 = (s) => new TextEncoder().encode(s);
|
|
2
|
+
export function toHex(b) {
|
|
3
|
+
let s = "";
|
|
4
|
+
for (const x of b)
|
|
5
|
+
s += x.toString(16).padStart(2, "0");
|
|
6
|
+
return s;
|
|
7
|
+
}
|
|
8
|
+
export function fromHex(h) {
|
|
9
|
+
const out = new Uint8Array(h.length / 2);
|
|
10
|
+
for (let i = 0; i < out.length; i++)
|
|
11
|
+
out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
export function toBase64url(b) {
|
|
15
|
+
let bin = "";
|
|
16
|
+
for (const x of b)
|
|
17
|
+
bin += String.fromCharCode(x);
|
|
18
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
// fromBase64url decodes unpadded base64url strictly: only the canonical encoding of the bytes is
|
|
21
|
+
// accepted (Go's base64.RawURLEncoding.Strict()), so one secret has exactly one text form.
|
|
22
|
+
export function fromBase64url(s) {
|
|
23
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s) || s.length % 4 === 1)
|
|
24
|
+
throw new TypeError("not base64url");
|
|
25
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4));
|
|
26
|
+
const out = new Uint8Array(bin.length);
|
|
27
|
+
for (let i = 0; i < bin.length; i++)
|
|
28
|
+
out[i] = bin.charCodeAt(i);
|
|
29
|
+
if (toBase64url(out) !== s)
|
|
30
|
+
throw new TypeError("not canonical base64url");
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export function concat(a, b) {
|
|
34
|
+
const out = new Uint8Array(a.length + b.length);
|
|
35
|
+
out.set(a, 0);
|
|
36
|
+
out.set(b, a.length);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export const utf8Length = (s) => utf8(s).length;
|
|
40
|
+
// truncateUtf8 cuts s to at most maxBytes of UTF-8, never inside a code point.
|
|
41
|
+
export function truncateUtf8(s, maxBytes) {
|
|
42
|
+
const b = utf8(s);
|
|
43
|
+
if (b.length <= maxBytes)
|
|
44
|
+
return s;
|
|
45
|
+
let cut = maxBytes;
|
|
46
|
+
while (cut > 0 && (b[cut] & 0xc0) === 0x80)
|
|
47
|
+
cut--;
|
|
48
|
+
return new TextDecoder().decode(b.subarray(0, cut));
|
|
49
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function mintCallbackToken(shard: number, random?: (n: number) => Uint8Array): string;
|
|
2
|
+
export declare function parseCallbackToken(token: string): {
|
|
3
|
+
shard: number;
|
|
4
|
+
secret: Uint8Array;
|
|
5
|
+
};
|
|
6
|
+
export declare function callbackTokenHash(token: string): Promise<string>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { fromBase64url, toBase64url, toHex } from "./bytes.js";
|
|
2
|
+
// Callback tokens (design-phase-2, "Callbacks"): "<shard 3 lowercase hex>.<43 base64url>", 256
|
|
3
|
+
// random bits. The shard is the run's, so workflows-api routes a completion without a lookup; the
|
|
4
|
+
// engine stores SHA-256 of the secret (internal/protocol CallbackToken).
|
|
5
|
+
const TOKEN = /^([0-9a-f]{3})\.([A-Za-z0-9_-]{43})$/;
|
|
6
|
+
export function mintCallbackToken(shard, random = (n) => crypto.getRandomValues(new Uint8Array(n))) {
|
|
7
|
+
if (!Number.isInteger(shard) || shard < 0 || shard > 1023)
|
|
8
|
+
throw new TypeError("A callback token's shard is 0–1023");
|
|
9
|
+
const secret = random(32);
|
|
10
|
+
if (secret.length !== 32)
|
|
11
|
+
throw new TypeError("A callback token's secret is 32 bytes");
|
|
12
|
+
return `${shard.toString(16).padStart(3, "0")}.${toBase64url(secret)}`;
|
|
13
|
+
}
|
|
14
|
+
// parseCallbackToken parses the wire form strictly (lowercase hex, canonical base64url).
|
|
15
|
+
export function parseCallbackToken(token) {
|
|
16
|
+
const m = typeof token === "string" ? TOKEN.exec(token) : null;
|
|
17
|
+
if (!m)
|
|
18
|
+
throw new TypeError("Not a callback token");
|
|
19
|
+
const shard = parseInt(m[1], 16);
|
|
20
|
+
if (shard > 1023)
|
|
21
|
+
throw new TypeError("Not a callback token: the shard is out of range");
|
|
22
|
+
const secret = fromBase64url(m[2]);
|
|
23
|
+
if (secret.length !== 32)
|
|
24
|
+
throw new TypeError("Not a callback token: the secret isn't 32 bytes");
|
|
25
|
+
return { shard, secret };
|
|
26
|
+
}
|
|
27
|
+
// callbackTokenHash: lowercase hex SHA-256 of the token's secret (what the engine stores).
|
|
28
|
+
export async function callbackTokenHash(token) {
|
|
29
|
+
const { secret } = parseCallbackToken(token);
|
|
30
|
+
return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", secret)));
|
|
31
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RetryPolicy } from "./types.ts";
|
|
2
|
+
export declare const MAX_OUTPUT_BYTES = 1048576;
|
|
3
|
+
export declare function encodeValue(v: unknown): unknown;
|
|
4
|
+
export declare function jsonByteLength(v: unknown): number;
|
|
5
|
+
export declare function parseDuration(d: string | number): number;
|
|
6
|
+
export declare function backoff(p: RetryPolicy, attempt: number): number;
|