@aikirun/task 0.5.3

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/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # @aikirun/task
2
+
3
+ Task SDK for Aiki durable execution platform - define reliable tasks with automatic retries, idempotency, and error
4
+ handling.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm install @aikirun/task
10
+ ```
11
+
12
+ ## Quick Start
13
+
14
+ ### Define a Simple Task
15
+
16
+ ```typescript
17
+ import { task } from "@aikirun/task";
18
+
19
+ export const sendVerificationEmail = task({
20
+ id: "send-verification",
21
+ async handler(input: { email: string }) {
22
+ return emailService.sendVerification(input.email);
23
+ },
24
+ });
25
+ ```
26
+
27
+ ### Task with Retry Configuration
28
+
29
+ ```typescript
30
+ export const ringAlarm = task({
31
+ id: "ring-alarm",
32
+ handler(input: { song: string }) {
33
+ return Promise.resolve(audioService.play(input.song));
34
+ },
35
+ opts: {
36
+ retry: {
37
+ type: "fixed",
38
+ maxAttempts: 3,
39
+ delayMs: 1000,
40
+ },
41
+ },
42
+ });
43
+ ```
44
+
45
+ ### Execute Task in a Workflow
46
+
47
+ ```typescript
48
+ import { workflow } from "@aikirun/workflow";
49
+
50
+ export const morningWorkflow = workflow({ id: "morning-routine" });
51
+
52
+ export const morningWorkflowV1 = morningWorkflow.v("1.0", {
53
+ async handler(input, run) {
54
+ const result = await ringAlarm.start(run, { song: "alarm.mp3" });
55
+ console.log("Task completed:", result);
56
+ },
57
+ });
58
+ ```
59
+
60
+ ## Features
61
+
62
+ - **Idempotent Execution** - Tasks can be safely retried without unintended side effects
63
+ - **Automatic Retries** - Multiple retry strategies (fixed, exponential, jittered)
64
+ - **Idempotency Keys** - Deduplicate task executions with custom keys
65
+ - **Error Handling** - Structured error information with recovery strategies
66
+ - **State Tracking** - Task execution state persists across failures
67
+ - **Type Safety** - Full TypeScript support with input/output types
68
+
69
+ ## Task Configuration
70
+
71
+ ```typescript
72
+ interface TaskOptions {
73
+ retry?: RetryStrategy; // Retry strategy
74
+ idempotencyKey?: string; // For deduplication
75
+ }
76
+ ```
77
+
78
+ ### Retry Strategies
79
+
80
+ #### Never Retry
81
+
82
+ ```typescript
83
+ opts: {
84
+ retry: { type: "never" },
85
+ }
86
+ ```
87
+
88
+ #### Fixed Delay
89
+
90
+ ```typescript
91
+ opts: {
92
+ retry: {
93
+ type: "fixed",
94
+ maxAttempts: 3,
95
+ delayMs: 1000,
96
+ },
97
+ }
98
+ ```
99
+
100
+ #### Exponential Backoff
101
+
102
+ ```typescript
103
+ opts: {
104
+ retry: {
105
+ type: "exponential",
106
+ maxAttempts: 5,
107
+ baseDelayMs: 1000,
108
+ factor: 2,
109
+ maxDelayMs: 30000,
110
+ },
111
+ }
112
+ ```
113
+
114
+ #### Jittered Exponential
115
+
116
+ ```typescript
117
+ opts: {
118
+ retry: {
119
+ type: "jittered",
120
+ maxAttempts: 5,
121
+ baseDelayMs: 1000,
122
+ jitterFactor: 0.1,
123
+ maxDelayMs: 30000,
124
+ },
125
+ }
126
+ ```
127
+
128
+ ## Execution Context
129
+
130
+ Tasks are executed within a workflow's execution context. Logging happens in the workflow:
131
+
132
+ ```typescript
133
+ export const processPayment = task({
134
+ id: "process-payment",
135
+ async handler(input: { amount: number }) {
136
+ return { success: true, transactionId: "tx_123" };
137
+ },
138
+ });
139
+
140
+ export const paymentWorkflowV1 = paymentWorkflow.v("1.0", {
141
+ async handler(input, run) {
142
+ run.logger.info("Processing payment", { amount: input.amount });
143
+ const result = await processPayment.start(run, { amount: input.amount });
144
+ run.logger.info("Payment complete", result);
145
+ },
146
+ });
147
+ ```
148
+
149
+ ## Best Practices
150
+
151
+ 1. **Make Tasks Idempotent** - Tasks may be retried, so re-running should not cause unintended side effects
152
+ 2. **Use Idempotency Keys** - Use custom keys to prevent duplicate processing
153
+ 3. **Use Meaningful Errors** - Help diagnose failures
154
+ 4. **Log Information** - Use `run.logger` for debugging
155
+ 5. **Keep Tasks Focused** - One responsibility per task
156
+
157
+ ## Related Packages
158
+
159
+ - [@aikirun/workflow](https://www.npmjs.com/package/@aikirun/workflow) - Use tasks in workflows
160
+ - [@aikirun/worker](https://www.npmjs.com/package/@aikirun/worker) - Execute tasks in workers
161
+ - [@aikirun/client](https://www.npmjs.com/package/@aikirun/client) - Manage task execution
162
+ - [@aikirun/types](https://www.npmjs.com/package/@aikirun/types) - Type definitions
163
+
164
+ ## Changelog
165
+
166
+ See the [CHANGELOG](https://github.com/aikirun/aiki/blob/main/CHANGELOG.md) for version history.
167
+
168
+ ## License
169
+
170
+ Apache-2.0
@@ -0,0 +1,88 @@
1
+ import { SerializableInput } from '@aikirun/types/error';
2
+ import { RetryStrategy } from '@aikirun/types/retry';
3
+ import { TaskId } from '@aikirun/types/task';
4
+ import { WorkflowRunContext } from '@aikirun/workflow';
5
+
6
+ type NonEmptyArray<T> = [T, ...T[]];
7
+
8
+ type NonArrayObject<T> = T extends object ? (T extends ReadonlyArray<unknown> ? never : T) : never;
9
+ type IsSubtype<SubT, SuperT> = SubT extends SuperT ? true : false;
10
+ type And<T extends NonEmptyArray<boolean>> = T extends [infer First, ...infer Rest] ? false extends First ? false : Rest extends NonEmptyArray<boolean> ? And<Rest> : true : never;
11
+ type Or<T extends NonEmptyArray<boolean>> = T extends [infer First, ...infer Rest] ? true extends First ? true : Rest extends NonEmptyArray<boolean> ? Or<Rest> : false : never;
12
+ type PathFromObject<T, IncludeArrayKeys extends boolean = false> = T extends T ? PathFromObjectInternal<T, IncludeArrayKeys> : never;
13
+ type PathFromObjectInternal<T, IncludeArrayKeys extends boolean> = And<[
14
+ IsSubtype<T, object>,
15
+ Or<[IncludeArrayKeys, NonArrayObject<T> extends never ? false : true]>
16
+ ]> extends true ? {
17
+ [K in Exclude<keyof T, symbol>]-?: And<[
18
+ IsSubtype<NonNullable<T[K]>, object>,
19
+ Or<[IncludeArrayKeys, NonArrayObject<NonNullable<T[K]>> extends never ? false : true]>
20
+ ]> extends true ? K | `${K}.${PathFromObjectInternal<NonNullable<T[K]>, IncludeArrayKeys>}` : K;
21
+ }[Exclude<keyof T, symbol>] : "";
22
+ type ExtractObjectType<T> = T extends object ? T : never;
23
+ type TypeOfValueAtPath<T extends object, Path extends PathFromObject<T>> = Path extends keyof T ? T[Path] : Path extends `${infer First}.${infer Rest}` ? First extends keyof T ? undefined extends T[First] ? Rest extends PathFromObject<ExtractObjectType<T[First]>> ? TypeOfValueAtPath<ExtractObjectType<T[First]>, Rest> | undefined : never : Rest extends PathFromObject<ExtractObjectType<T[First]>> ? TypeOfValueAtPath<ExtractObjectType<T[First]>, Rest> : never : never : never;
24
+
25
+ /**
26
+ * Defines a durable task with deterministic execution and automatic retries.
27
+ *
28
+ * Tasks must be deterministic - the same input should always produce the same output.
29
+ * Tasks can be retried multiple times, so they should be idempotent when possible.
30
+ * Tasks execute within a workflow context and can access logging.
31
+ *
32
+ * @template Input - Type of task input (must be JSON serializable)
33
+ * @template Output - Type of task output (must be JSON serializable)
34
+ * @param params - Task configuration
35
+ * @param params.id - Unique task id used for execution tracking
36
+ * @param params.handler - Async function that executes the task logic
37
+ * @returns Task instance with retry and option configuration methods
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * // Simple task without retry
42
+ * export const sendEmail = task({
43
+ * id: "send-email",
44
+ * handler(input: { email: string; message: string }) {
45
+ * return emailService.send(input.email, input.message);
46
+ * },
47
+ * });
48
+ *
49
+ * // Task with retry configuration
50
+ * export const chargeCard = task({
51
+ * id: "charge-card",
52
+ * handler(input: { cardId: string; amount: number }) {
53
+ * return paymentService.charge(input.cardId, input.amount);
54
+ * },
55
+ * opts: {
56
+ * retry: {
57
+ * type: "fixed",
58
+ * maxAttempts: 3,
59
+ * delayMs: 1000,
60
+ * },
61
+ * },
62
+ * });
63
+ *
64
+ * // Execute task in workflow
65
+ * const result = await chargeCard.start(run, { cardId: "123", amount: 9999 });
66
+ * ```
67
+ */
68
+ declare function task<Input extends SerializableInput = null, Output = void>(params: TaskParams<Input, Output>): Task<Input, Output>;
69
+ interface TaskParams<Input, Output> {
70
+ id: string;
71
+ handler: (input: Input) => Promise<Output>;
72
+ opts?: TaskOptions;
73
+ }
74
+ interface TaskOptions {
75
+ retry?: RetryStrategy;
76
+ idempotencyKey?: string;
77
+ }
78
+ interface TaskBuilder<Input, Output> {
79
+ opt<Path extends PathFromObject<TaskOptions>>(path: Path, value: TypeOfValueAtPath<TaskOptions, Path>): TaskBuilder<Input, Output>;
80
+ start: Task<Input, Output>["start"];
81
+ }
82
+ interface Task<Input, Output> {
83
+ id: TaskId;
84
+ with(): TaskBuilder<Input, Output>;
85
+ start: <WorkflowInput, WorkflowOutput>(run: WorkflowRunContext<WorkflowInput, WorkflowOutput>, ...args: Input extends null ? [] : [Input]) => Promise<Output>;
86
+ }
87
+
88
+ export { type Task, type TaskParams, task };
package/dist/index.js ADDED
@@ -0,0 +1,262 @@
1
+ // ../../lib/array/utils.ts
2
+ function isNonEmptyArray(value) {
3
+ return value.length > 0;
4
+ }
5
+
6
+ // ../../lib/async/delay.ts
7
+ function delay(ms, options) {
8
+ const abortSignal = options?.abortSignal;
9
+ if (abortSignal?.aborted) {
10
+ return Promise.reject(abortSignal.reason);
11
+ }
12
+ return new Promise((resolve, reject) => {
13
+ const abort = () => {
14
+ clearTimeout(timeout);
15
+ reject(abortSignal?.reason);
16
+ };
17
+ const timeout = setTimeout(() => {
18
+ abortSignal?.removeEventListener("abort", abort);
19
+ resolve();
20
+ }, ms);
21
+ abortSignal?.addEventListener("abort", abort, { once: true });
22
+ });
23
+ }
24
+
25
+ // ../../lib/crypto/hash.ts
26
+ async function sha256(input) {
27
+ const data = new TextEncoder().encode(input);
28
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
29
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
30
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
31
+ }
32
+
33
+ // ../../lib/error/serializable.ts
34
+ function createSerializableError(error) {
35
+ return error instanceof Error ? {
36
+ message: error.message,
37
+ name: error.name,
38
+ stack: error.stack,
39
+ cause: error.cause ? createSerializableError(error.cause) : void 0
40
+ } : {
41
+ message: String(error),
42
+ name: "UnknownError"
43
+ };
44
+ }
45
+
46
+ // ../../lib/json/stable-stringify.ts
47
+ function stableStringify(value) {
48
+ if (value === null || value === void 0) {
49
+ return JSON.stringify(value);
50
+ }
51
+ if (typeof value !== "object") {
52
+ return JSON.stringify(value);
53
+ }
54
+ if (Array.isArray(value)) {
55
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
56
+ }
57
+ const keys = Object.keys(value).sort();
58
+ const pairs = keys.map((key) => {
59
+ const val = value[key];
60
+ return `${JSON.stringify(key)}:${stableStringify(val)}`;
61
+ });
62
+ return `{${pairs.join(",")}}`;
63
+ }
64
+
65
+ // ../../lib/object/overrider.ts
66
+ function set(obj, path, value) {
67
+ const keys = path.split(".");
68
+ let currentValue = obj;
69
+ for (let i = 0; i < keys.length - 1; i++) {
70
+ const key = keys[i];
71
+ currentValue = currentValue[key];
72
+ if (currentValue === void 0 || currentValue === null) {
73
+ currentValue = {};
74
+ currentValue[key] = currentValue;
75
+ }
76
+ }
77
+ const lastKey = keys[keys.length - 1];
78
+ currentValue[lastKey] = value;
79
+ }
80
+ var objectOverrider = (defaultObj) => (obj) => {
81
+ const createBuilder = (overrides) => ({
82
+ with: (path, value) => createBuilder([...overrides, { path: `${path}`, value }]),
83
+ build: () => {
84
+ const clonedObject = structuredClone(obj ?? defaultObj);
85
+ for (const { path, value } of overrides) {
86
+ set(clonedObject, path, value);
87
+ }
88
+ return clonedObject;
89
+ }
90
+ });
91
+ return createBuilder([]);
92
+ };
93
+
94
+ // ../../lib/retry/strategy.ts
95
+ function getRetryParams(attempts, strategy) {
96
+ const strategyType = strategy.type;
97
+ switch (strategyType) {
98
+ case "never":
99
+ return {
100
+ retriesLeft: false
101
+ };
102
+ case "fixed":
103
+ if (attempts >= strategy.maxAttempts) {
104
+ return {
105
+ retriesLeft: false
106
+ };
107
+ }
108
+ return {
109
+ retriesLeft: true,
110
+ delayMs: strategy.delayMs
111
+ };
112
+ case "exponential": {
113
+ if (attempts >= strategy.maxAttempts) {
114
+ return {
115
+ retriesLeft: false
116
+ };
117
+ }
118
+ const delayMs = strategy.baseDelayMs * (strategy.factor ?? 2) ** (attempts - 1);
119
+ return {
120
+ retriesLeft: true,
121
+ delayMs: Math.min(delayMs, strategy.maxDelayMs ?? Number.POSITIVE_INFINITY)
122
+ };
123
+ }
124
+ case "jittered": {
125
+ if (attempts >= strategy.maxAttempts) {
126
+ return {
127
+ retriesLeft: false
128
+ };
129
+ }
130
+ const base = strategy.baseDelayMs * (strategy.jitterFactor ?? 2) ** (attempts - 1);
131
+ const delayMs = Math.random() * base;
132
+ return {
133
+ retriesLeft: true,
134
+ delayMs: Math.min(delayMs, strategy.maxDelayMs ?? Number.POSITIVE_INFINITY)
135
+ };
136
+ }
137
+ default:
138
+ return strategyType;
139
+ }
140
+ }
141
+
142
+ // task.ts
143
+ import { INTERNAL } from "@aikirun/types/symbols";
144
+ import { TaskFailedError } from "@aikirun/types/task";
145
+ function task(params) {
146
+ return new TaskImpl(params);
147
+ }
148
+ var TaskImpl = class _TaskImpl {
149
+ constructor(params) {
150
+ this.params = params;
151
+ this.id = params.id;
152
+ }
153
+ id;
154
+ with() {
155
+ const optsOverrider = objectOverrider(this.params.opts ?? {});
156
+ const createBuilder = (optsBuilder) => ({
157
+ opt: (path, value) => createBuilder(optsBuilder.with(path, value)),
158
+ start: (run, ...args) => new _TaskImpl({ ...this.params, opts: optsBuilder.build() }).start(run, ...args)
159
+ });
160
+ return createBuilder(optsOverrider());
161
+ }
162
+ async start(run, ...args) {
163
+ const handle = run[INTERNAL].handle;
164
+ handle[INTERNAL].assertExecutionAllowed();
165
+ const input = isNonEmptyArray(args) ? args[0] : (
166
+ // this cast is okay cos if args is empty, Input must be type null
167
+ null
168
+ );
169
+ const path = await this.getPath(input);
170
+ const taskState = handle.run.tasksState[path] ?? { status: "none" };
171
+ if (taskState.status === "completed") {
172
+ return taskState.output;
173
+ }
174
+ const logger = run.logger.child({
175
+ "aiki.component": "task-execution",
176
+ "aiki.taskPath": path
177
+ });
178
+ let attempts = 0;
179
+ const retryStrategy = this.params.opts?.retry ?? { type: "never" };
180
+ if ("attempts" in taskState) {
181
+ this.assertRetryAllowed(path, taskState.attempts, retryStrategy, logger);
182
+ logger.warn("Retrying task", {
183
+ "aiki.attempts": taskState.attempts,
184
+ "aiki.taskStatus": taskState.status
185
+ });
186
+ attempts = taskState.attempts;
187
+ }
188
+ if (taskState.status === "failed") {
189
+ await this.delayIfNecessary(taskState);
190
+ }
191
+ attempts++;
192
+ logger.info("Starting task", { "aiki.attempts": attempts });
193
+ await handle[INTERNAL].transitionTaskState(path, { status: "running", attempts });
194
+ const { output, lastAttempt } = await this.tryExecuteTask(run, input, path, retryStrategy, attempts, logger);
195
+ await handle[INTERNAL].transitionTaskState(path, { status: "completed", output });
196
+ logger.info("Task complete", { "aiki.attempts": lastAttempt });
197
+ return output;
198
+ }
199
+ async tryExecuteTask(run, input, path, retryStrategy, currentAttempt, logger) {
200
+ let attempts = currentAttempt;
201
+ while (true) {
202
+ const attemptedAt = Date.now();
203
+ try {
204
+ const output = await this.params.handler(input);
205
+ return { output, lastAttempt: attempts };
206
+ } catch (error) {
207
+ const serializableError = createSerializableError(error);
208
+ const taskFailedState = {
209
+ status: "failed",
210
+ reason: serializableError.message,
211
+ attempts,
212
+ attemptedAt,
213
+ error: serializableError
214
+ };
215
+ const retryParams = getRetryParams(attempts, retryStrategy);
216
+ if (!retryParams.retriesLeft) {
217
+ await run[INTERNAL].handle[INTERNAL].transitionTaskState(path, taskFailedState);
218
+ logger.error("Task failed", {
219
+ "aiki.attempts": attempts,
220
+ "aiki.reason": taskFailedState.reason
221
+ });
222
+ throw new TaskFailedError(path, attempts, taskFailedState.reason);
223
+ }
224
+ const nextAttemptAt = Date.now() + retryParams.delayMs;
225
+ await run[INTERNAL].handle[INTERNAL].transitionTaskState(path, { ...taskFailedState, nextAttemptAt });
226
+ logger.debug("Task failed. Retrying", {
227
+ "aiki.attempts": attempts,
228
+ "aiki.nextAttemptAt": nextAttemptAt,
229
+ "aiki.reason": taskFailedState.reason
230
+ });
231
+ await delay(retryParams.delayMs);
232
+ attempts++;
233
+ }
234
+ }
235
+ }
236
+ assertRetryAllowed(path, attempts, retryStrategy, logger) {
237
+ const retryParams = getRetryParams(attempts, retryStrategy);
238
+ if (!retryParams.retriesLeft) {
239
+ logger.error("Task retry not allowed", {
240
+ "aiki.attempts": attempts
241
+ });
242
+ throw new TaskFailedError(path, attempts, "Task retry not allowed");
243
+ }
244
+ }
245
+ async delayIfNecessary(taskState) {
246
+ if (taskState.nextAttemptAt !== void 0) {
247
+ const now = Date.now();
248
+ const remainingDelay = Math.max(0, taskState.nextAttemptAt - now);
249
+ if (remainingDelay > 0) {
250
+ await delay(remainingDelay);
251
+ }
252
+ }
253
+ }
254
+ async getPath(input) {
255
+ const inputHash = await sha256(stableStringify(input));
256
+ const taskPath = this.params.opts?.idempotencyKey ? `${this.id}/${inputHash}/${this.params.opts.idempotencyKey}` : `${this.id}/${inputHash}`;
257
+ return taskPath;
258
+ }
259
+ };
260
+ export {
261
+ task
262
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@aikirun/task",
3
+ "version": "0.5.3",
4
+ "description": "Task SDK for Aiki - define reliable tasks with automatic retries, idempotency, and error handling",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsup"
19
+ },
20
+ "dependencies": {
21
+ "@aikirun/types": "0.5.3",
22
+ "@aikirun/workflow": "0.5.3"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "license": "Apache-2.0"
28
+ }