resumable-workflow 1.4.0 → 1.4.2

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/dist/index.d.mts CHANGED
@@ -68,7 +68,11 @@ declare class FileStorage implements StorageProvider {
68
68
  private readonly baseDir;
69
69
  constructor(baseDir?: string);
70
70
  private ensureDir;
71
+ private validateRunId;
71
72
  private getFilePath;
73
+ private isValidRunFileName;
74
+ private isValidWorkflowStatus;
75
+ private isValidRunStateShape;
72
76
  saveRun(state: WorkflowRunState<unknown, Record<string, unknown>>): Promise<void>;
73
77
  getRun(runId: string): Promise<WorkflowRunState<unknown, Record<string, unknown>> | null>;
74
78
  listIncompleteRuns(workflowId: string): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]>;
package/dist/index.d.ts CHANGED
@@ -68,7 +68,11 @@ declare class FileStorage implements StorageProvider {
68
68
  private readonly baseDir;
69
69
  constructor(baseDir?: string);
70
70
  private ensureDir;
71
+ private validateRunId;
71
72
  private getFilePath;
73
+ private isValidRunFileName;
74
+ private isValidWorkflowStatus;
75
+ private isValidRunStateShape;
72
76
  saveRun(state: WorkflowRunState<unknown, Record<string, unknown>>): Promise<void>;
73
77
  getRun(runId: string): Promise<WorkflowRunState<unknown, Record<string, unknown>> | null>;
74
78
  listIncompleteRuns(workflowId: string): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]>;
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ var import_node_crypto = require("crypto");
42
42
  // src/storage/file-storage.ts
43
43
  var import_promises = __toESM(require("fs/promises"));
44
44
  var import_node_path = __toESM(require("path"));
45
+ var RUN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
45
46
  var FileStorage = class {
46
47
  constructor(baseDir = ".resumable-workflow") {
47
48
  this.baseDir = import_node_path.default.resolve(baseDir);
@@ -49,8 +50,45 @@ var FileStorage = class {
49
50
  async ensureDir() {
50
51
  await import_promises.default.mkdir(this.baseDir, { recursive: true });
51
52
  }
53
+ validateRunId(runId) {
54
+ if (!runId || typeof runId !== "string") {
55
+ throw new Error(`Invalid runId: ${runId}`);
56
+ }
57
+ if (runId.includes("../") || runId.includes("..\\") || runId.startsWith("..")) {
58
+ throw new Error(`Invalid runId: Path traversal detected in ${runId}`);
59
+ }
60
+ if (!RUN_ID_PATTERN.test(runId)) {
61
+ throw new Error(
62
+ `Invalid runId: Only alphanumeric characters, hyphens, and underscores are allowed in ${runId}`
63
+ );
64
+ }
65
+ }
52
66
  getFilePath(runId) {
53
- return import_node_path.default.join(this.baseDir, `${runId}.json`);
67
+ this.validateRunId(runId);
68
+ const filePath = import_node_path.default.join(this.baseDir, `${runId}.json`);
69
+ const resolvedPath = import_node_path.default.resolve(filePath);
70
+ const resolvedBaseDir = import_node_path.default.resolve(this.baseDir);
71
+ if (!resolvedPath.startsWith(resolvedBaseDir)) {
72
+ throw new Error("Invalid runId: Path traversal detected");
73
+ }
74
+ return resolvedPath;
75
+ }
76
+ isValidRunFileName(fileName) {
77
+ if (!fileName.endsWith(".json")) {
78
+ return false;
79
+ }
80
+ const runId = fileName.slice(0, -".json".length);
81
+ return RUN_ID_PATTERN.test(runId);
82
+ }
83
+ isValidWorkflowStatus(value) {
84
+ return value === "pending" || value === "completed" || value === "failed";
85
+ }
86
+ isValidRunStateShape(run) {
87
+ if (typeof run !== "object" || run === null || Array.isArray(run)) {
88
+ return false;
89
+ }
90
+ const candidate = run;
91
+ return typeof candidate.workflowId === "string" && typeof candidate.runId === "string" && RUN_ID_PATTERN.test(candidate.runId) && this.isValidWorkflowStatus(candidate.status) && typeof candidate.currentStepIndex === "number" && Number.isInteger(candidate.currentStepIndex) && candidate.currentStepIndex >= 0 && "input" in candidate && typeof candidate.state === "object" && candidate.state !== null && !Array.isArray(candidate.state);
54
92
  }
55
93
  async saveRun(state) {
56
94
  await this.ensureDir();
@@ -60,10 +98,14 @@ var FileStorage = class {
60
98
  );
61
99
  }
62
100
  async getRun(runId) {
101
+ this.validateRunId(runId);
63
102
  try {
64
103
  const data = await import_promises.default.readFile(this.getFilePath(runId), "utf-8");
65
104
  return JSON.parse(data);
66
- } catch (_e) {
105
+ } catch (e) {
106
+ if (e.message.includes("Invalid runId") || e.message.includes("Path traversal")) {
107
+ throw e;
108
+ }
67
109
  return null;
68
110
  }
69
111
  }
@@ -79,18 +121,22 @@ var FileStorage = class {
79
121
  const files = await import_promises.default.readdir(this.baseDir);
80
122
  const runs = [];
81
123
  for (const file of files) {
82
- if (file.endsWith(".json")) {
83
- try {
84
- const data = await import_promises.default.readFile(
85
- import_node_path.default.join(this.baseDir, file),
86
- "utf-8"
87
- );
88
- const run = JSON.parse(data);
89
- if (run.workflowId === workflowId && run.status === status) {
90
- runs.push(run);
91
- }
92
- } catch (_e) {
124
+ if (!this.isValidRunFileName(file)) {
125
+ continue;
126
+ }
127
+ try {
128
+ const data = await import_promises.default.readFile(import_node_path.default.join(this.baseDir, file), "utf-8");
129
+ const run = JSON.parse(data);
130
+ if (!this.isValidRunStateShape(run)) {
131
+ continue;
132
+ }
133
+ if (run.runId !== file.slice(0, -".json".length)) {
134
+ continue;
93
135
  }
136
+ if (run.workflowId === workflowId && run.status === status) {
137
+ runs.push(run);
138
+ }
139
+ } catch (_e) {
94
140
  }
95
141
  }
96
142
  return runs;
@@ -99,9 +145,13 @@ var FileStorage = class {
99
145
  }
100
146
  }
101
147
  async deleteRun(runId) {
148
+ this.validateRunId(runId);
102
149
  try {
103
150
  await import_promises.default.unlink(this.getFilePath(runId));
104
- } catch (_e) {
151
+ } catch (e) {
152
+ if (e.message.includes("Invalid runId") || e.message.includes("Path traversal")) {
153
+ throw e;
154
+ }
105
155
  }
106
156
  }
107
157
  };
@@ -114,10 +164,7 @@ var Workflow = class {
114
164
  this.workflowId = config.id;
115
165
  if (config.autoResume) {
116
166
  this.resumeAllIncomplete().catch((err) => {
117
- console.error(
118
- `[ResumableWorkflow: ${this.workflowId}] Auto-resume failed:`,
119
- err
120
- );
167
+ console.error("[ResumableWorkflow] Auto-resume failed:", err);
121
168
  });
122
169
  }
123
170
  }
@@ -229,7 +276,9 @@ var Workflow = class {
229
276
  */
230
277
  async clearCompleted() {
231
278
  const completed = await this.storage.listCompletedRuns(this.workflowId);
232
- await Promise.all(completed.map((run) => this.storage.deleteRun(run.runId)));
279
+ await Promise.all(
280
+ completed.map((run) => this.storage.deleteRun(run.runId))
281
+ );
233
282
  }
234
283
  };
235
284
  function createWorkflow(config) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/core/engine.ts","../src/storage/file-storage.ts"],"sourcesContent":["export { createWorkflow, Workflow } from './core/engine';\nexport { FileStorage } from './storage/file-storage';\nexport type {\n Step,\n StorageProvider,\n WorkflowConfig,\n WorkflowResult,\n WorkflowRunState,\n WorkflowStatus,\n} from './types';\n","import { randomUUID } from 'node:crypto';\nimport { FileStorage } from '../storage/file-storage';\nimport type {\n StorageProvider,\n WorkflowConfig,\n WorkflowResult,\n WorkflowRunState,\n WorkflowStatus,\n} from '../types';\n\ntype Mutable<T> = {\n -readonly [P in keyof T]: T[P];\n};\n\nexport class Workflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n> {\n private readonly storage: StorageProvider;\n private readonly workflowId: string;\n private readonly config: WorkflowConfig<TInput, TState>;\n\n constructor(config: WorkflowConfig<TInput, TState>) {\n this.config = config;\n this.storage = config.storage || new FileStorage();\n this.workflowId = config.id;\n\n if (config.autoResume) {\n this.resumeAllIncomplete().catch((err) => {\n console.error(\n `[ResumableWorkflow: ${this.workflowId}] Auto-resume failed:`,\n err\n );\n });\n }\n }\n\n private async executeStep(\n currentState: Mutable<WorkflowRunState<TInput, TState>>,\n stepIndex: number\n ): Promise<void> {\n const step = this.config.steps[stepIndex];\n if (!step) {\n return;\n }\n\n try {\n const result = await step.run({\n input: currentState.input,\n state: currentState.state,\n });\n\n if (\n result !== null &&\n typeof result === 'object' &&\n !Array.isArray(result)\n ) {\n currentState.state = {\n ...currentState.state,\n ...(result as Partial<TState>),\n };\n } else if (result !== undefined) {\n (currentState.state as Record<string, unknown>)[\n step.name || `step_${stepIndex}`\n ] = result;\n }\n\n currentState.currentStepIndex = stepIndex + 1;\n\n if (currentState.currentStepIndex === this.config.steps.length) {\n currentState.status = 'completed';\n }\n\n await this.storage.saveRun(currentState);\n } catch (error) {\n currentState.status = 'failed';\n currentState.error =\n error instanceof Error ? error.message : String(error);\n await this.storage.saveRun(currentState);\n throw error;\n }\n }\n\n private async run(\n input: TInput,\n existingRun?: WorkflowRunState<TInput, TState>\n ): Promise<WorkflowResult<TState>> {\n const runId = existingRun?.runId || randomUUID();\n const currentState: Mutable<WorkflowRunState<TInput, TState>> =\n (existingRun as Mutable<WorkflowRunState<TInput, TState>>) || {\n workflowId: this.workflowId,\n runId,\n status: 'pending' as WorkflowStatus,\n currentStepIndex: 0,\n input,\n state: {} as TState,\n };\n\n if (!existingRun) {\n await this.storage.saveRun(currentState);\n }\n\n try {\n for (\n let i = currentState.currentStepIndex;\n i < this.config.steps.length;\n i++\n ) {\n await this.executeStep(currentState, i);\n }\n } catch (_error) {\n return {\n success: false,\n error: currentState.error || 'Unknown error',\n runId: currentState.runId,\n stepName:\n this.config.steps[currentState.currentStepIndex]?.name ||\n `step_${currentState.currentStepIndex}`,\n };\n }\n\n // Auto-cleanup if enabled and successful\n if (this.config.autoCleanup) {\n await this.storage.deleteRun(currentState.runId);\n }\n\n return {\n success: true,\n result: currentState.state,\n runId: currentState.runId,\n };\n }\n\n start(input: TInput): Promise<WorkflowResult<TState>> {\n return this.run(input);\n }\n\n async resume(runId: string): Promise<WorkflowResult<TState>> {\n const runState = await this.storage.getRun(runId);\n if (!runState) {\n return {\n success: false,\n error: `Run ${runId} not found`,\n runId,\n stepName: 'unknown',\n };\n }\n if (runState.status === 'completed') {\n return {\n success: true,\n result: runState.state as TState,\n runId: runState.runId,\n };\n }\n return this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n );\n }\n\n listIncomplete(): Promise<\n WorkflowRunState<unknown, Record<string, unknown>>[]\n > {\n return this.storage.listIncompleteRuns(this.workflowId);\n }\n\n async resumeAllIncomplete(): Promise<WorkflowResult<TState>[]> {\n const incomplete = await this.storage.listIncompleteRuns(this.workflowId);\n return Promise.all(\n incomplete.map((runState) =>\n this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n )\n )\n );\n }\n\n /**\n * Manually clears all completed workflow runs for this workflow ID from storage.\n */\n async clearCompleted(): Promise<void> {\n const completed = await this.storage.listCompletedRuns(this.workflowId);\n await Promise.all(completed.map((run) => this.storage.deleteRun(run.runId)));\n }\n}\n\n/**\n * Factory function for backward compatibility and ease of use\n */\nexport function createWorkflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n>(config: WorkflowConfig<TInput, TState>) {\n return new Workflow(config);\n}","import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { StorageProvider, WorkflowRunState } from '../types';\n\nexport class FileStorage implements StorageProvider {\n private readonly baseDir: string;\n\n constructor(baseDir = '.resumable-workflow') {\n this.baseDir = path.resolve(baseDir);\n }\n\n private async ensureDir(): Promise<void> {\n await fs.mkdir(this.baseDir, { recursive: true });\n }\n\n private getFilePath(runId: string): string {\n return path.join(this.baseDir, `${runId}.json`);\n }\n\n async saveRun(\n state: WorkflowRunState<unknown, Record<string, unknown>>\n ): Promise<void> {\n await this.ensureDir();\n await fs.writeFile(\n this.getFilePath(state.runId),\n JSON.stringify(state, null, 2)\n );\n }\n\n async getRun(\n runId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>> | null> {\n try {\n const data = await fs.readFile(this.getFilePath(runId), 'utf-8');\n return JSON.parse(data) as WorkflowRunState<\n unknown,\n Record<string, unknown>\n >;\n } catch (_e) {\n // Return null if file doesn't exist or is invalid\n return null;\n }\n }\n\n listIncompleteRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'pending');\n }\n\n listCompletedRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'completed');\n }\n\n private async listRuns(\n workflowId: string,\n status: 'pending' | 'completed'\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n try {\n await this.ensureDir();\n const files = await fs.readdir(this.baseDir);\n const runs: WorkflowRunState<unknown, Record<string, unknown>>[] = [];\n\n for (const file of files) {\n if (file.endsWith('.json')) {\n try {\n const data = await fs.readFile(\n path.join(this.baseDir, file),\n 'utf-8'\n );\n const run = JSON.parse(data) as WorkflowRunState<\n unknown,\n Record<string, unknown>\n >;\n if (run.workflowId === workflowId && run.status === status) {\n runs.push(run);\n }\n } catch (_e) {\n // Ignore corrupted files\n }\n }\n }\n return runs;\n } catch (_e) {\n return [];\n }\n }\n\n async deleteRun(runId: string): Promise<void> {\n try {\n await fs.unlink(this.getFilePath(runId));\n } catch (_e) {\n // Ignore if file doesn't exist\n }\n }\n}\n"],"mappings":"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","names":["path","fs"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/core/engine.ts","../src/storage/file-storage.ts"],"sourcesContent":["export { createWorkflow, Workflow } from './core/engine';\nexport { FileStorage } from './storage/file-storage';\nexport type {\n Step,\n StorageProvider,\n WorkflowConfig,\n WorkflowResult,\n WorkflowRunState,\n WorkflowStatus,\n} from './types';\n","import { randomUUID } from 'node:crypto';\nimport { FileStorage } from '../storage/file-storage';\nimport type {\n StorageProvider,\n WorkflowConfig,\n WorkflowResult,\n WorkflowRunState,\n WorkflowStatus,\n} from '../types';\n\ntype Mutable<T> = {\n -readonly [P in keyof T]: T[P];\n};\n\nexport class Workflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n> {\n private readonly storage: StorageProvider;\n private readonly workflowId: string;\n private readonly config: WorkflowConfig<TInput, TState>;\n\n constructor(config: WorkflowConfig<TInput, TState>) {\n this.config = config;\n this.storage = config.storage || new FileStorage();\n this.workflowId = config.id;\n\n if (config.autoResume) {\n this.resumeAllIncomplete().catch((err) => {\n console.error('[ResumableWorkflow] Auto-resume failed:', err);\n });\n }\n }\n\n private async executeStep(\n currentState: Mutable<WorkflowRunState<TInput, TState>>,\n stepIndex: number\n ): Promise<void> {\n const step = this.config.steps[stepIndex];\n if (!step) {\n return;\n }\n\n try {\n const result = await step.run({\n input: currentState.input,\n state: currentState.state,\n });\n\n if (\n result !== null &&\n typeof result === 'object' &&\n !Array.isArray(result)\n ) {\n currentState.state = {\n ...currentState.state,\n ...(result as Partial<TState>),\n };\n } else if (result !== undefined) {\n (currentState.state as Record<string, unknown>)[\n step.name || `step_${stepIndex}`\n ] = result;\n }\n\n currentState.currentStepIndex = stepIndex + 1;\n\n if (currentState.currentStepIndex === this.config.steps.length) {\n currentState.status = 'completed';\n }\n\n await this.storage.saveRun(currentState);\n } catch (error) {\n currentState.status = 'failed';\n currentState.error =\n error instanceof Error ? error.message : String(error);\n await this.storage.saveRun(currentState);\n throw error;\n }\n }\n\n private async run(\n input: TInput,\n existingRun?: WorkflowRunState<TInput, TState>\n ): Promise<WorkflowResult<TState>> {\n const runId = existingRun?.runId || randomUUID();\n const currentState: Mutable<WorkflowRunState<TInput, TState>> =\n (existingRun as Mutable<WorkflowRunState<TInput, TState>>) || {\n workflowId: this.workflowId,\n runId,\n status: 'pending' as WorkflowStatus,\n currentStepIndex: 0,\n input,\n state: {} as TState,\n };\n\n if (!existingRun) {\n await this.storage.saveRun(currentState);\n }\n\n try {\n for (\n let i = currentState.currentStepIndex;\n i < this.config.steps.length;\n i++\n ) {\n await this.executeStep(currentState, i);\n }\n } catch (_error) {\n return {\n success: false,\n error: currentState.error || 'Unknown error',\n runId: currentState.runId,\n stepName:\n this.config.steps[currentState.currentStepIndex]?.name ||\n `step_${currentState.currentStepIndex}`,\n };\n }\n\n // Auto-cleanup if enabled and successful\n if (this.config.autoCleanup) {\n await this.storage.deleteRun(currentState.runId);\n }\n\n return {\n success: true,\n result: currentState.state,\n runId: currentState.runId,\n };\n }\n\n start(input: TInput): Promise<WorkflowResult<TState>> {\n return this.run(input);\n }\n\n async resume(runId: string): Promise<WorkflowResult<TState>> {\n const runState = await this.storage.getRun(runId);\n if (!runState) {\n return {\n success: false,\n error: `Run ${runId} not found`,\n runId,\n stepName: 'unknown',\n };\n }\n if (runState.status === 'completed') {\n return {\n success: true,\n result: runState.state as TState,\n runId: runState.runId,\n };\n }\n return this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n );\n }\n\n listIncomplete(): Promise<\n WorkflowRunState<unknown, Record<string, unknown>>[]\n > {\n return this.storage.listIncompleteRuns(this.workflowId);\n }\n\n async resumeAllIncomplete(): Promise<WorkflowResult<TState>[]> {\n const incomplete = await this.storage.listIncompleteRuns(this.workflowId);\n return Promise.all(\n incomplete.map((runState) =>\n this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n )\n )\n );\n }\n\n /**\n * Manually clears all completed workflow runs for this workflow ID from storage.\n */\n async clearCompleted(): Promise<void> {\n const completed = await this.storage.listCompletedRuns(this.workflowId);\n await Promise.all(\n completed.map((run) => this.storage.deleteRun(run.runId))\n );\n }\n}\n\n/**\n * Factory function for backward compatibility and ease of use\n */\nexport function createWorkflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n>(config: WorkflowConfig<TInput, TState>) {\n return new Workflow(config);\n}\n","import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { StorageProvider, WorkflowRunState } from '../types';\n\nconst RUN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;\n\nexport class FileStorage implements StorageProvider {\n private readonly baseDir: string;\n\n constructor(baseDir = '.resumable-workflow') {\n this.baseDir = path.resolve(baseDir);\n }\n\n private async ensureDir(): Promise<void> {\n await fs.mkdir(this.baseDir, { recursive: true });\n }\n\n private validateRunId(runId: string): void {\n if (!runId || typeof runId !== 'string') {\n throw new Error(`Invalid runId: ${runId}`);\n }\n\n // Check for path traversal attempts\n if (\n runId.includes('../') ||\n runId.includes('..\\\\') ||\n runId.startsWith('..')\n ) {\n throw new Error(`Invalid runId: Path traversal detected in ${runId}`);\n }\n\n // Additional validation: only allow alphanumeric, hyphens, and underscores\n if (!RUN_ID_PATTERN.test(runId)) {\n throw new Error(\n `Invalid runId: Only alphanumeric characters, hyphens, and underscores are allowed in ${runId}`\n );\n }\n }\n\n private getFilePath(runId: string): string {\n this.validateRunId(runId);\n const filePath = path.join(this.baseDir, `${runId}.json`);\n\n // Additional security check: ensure the resolved path is within the base directory\n const resolvedPath = path.resolve(filePath);\n const resolvedBaseDir = path.resolve(this.baseDir);\n\n if (!resolvedPath.startsWith(resolvedBaseDir)) {\n throw new Error('Invalid runId: Path traversal detected');\n }\n\n return resolvedPath;\n }\n\n private isValidRunFileName(fileName: string): boolean {\n if (!fileName.endsWith('.json')) {\n return false;\n }\n\n const runId = fileName.slice(0, -'.json'.length);\n return RUN_ID_PATTERN.test(runId);\n }\n\n private isValidWorkflowStatus(\n value: unknown\n ): value is 'pending' | 'completed' | 'failed' {\n return value === 'pending' || value === 'completed' || value === 'failed';\n }\n\n private isValidRunStateShape(\n run: unknown\n ): run is WorkflowRunState<unknown, Record<string, unknown>> {\n if (typeof run !== 'object' || run === null || Array.isArray(run)) {\n return false;\n }\n\n const candidate = run as Record<string, unknown>;\n return (\n typeof candidate.workflowId === 'string' &&\n typeof candidate.runId === 'string' &&\n RUN_ID_PATTERN.test(candidate.runId) &&\n this.isValidWorkflowStatus(candidate.status) &&\n typeof candidate.currentStepIndex === 'number' &&\n Number.isInteger(candidate.currentStepIndex) &&\n candidate.currentStepIndex >= 0 &&\n 'input' in candidate &&\n typeof candidate.state === 'object' &&\n candidate.state !== null &&\n !Array.isArray(candidate.state)\n );\n }\n\n async saveRun(\n state: WorkflowRunState<unknown, Record<string, unknown>>\n ): Promise<void> {\n await this.ensureDir();\n await fs.writeFile(\n this.getFilePath(state.runId),\n JSON.stringify(state, null, 2)\n );\n }\n\n async getRun(\n runId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>> | null> {\n // Validate runId first before attempting file operations\n this.validateRunId(runId);\n\n try {\n const data = await fs.readFile(this.getFilePath(runId), 'utf-8');\n return JSON.parse(data) as WorkflowRunState<\n unknown,\n Record<string, unknown>\n >;\n } catch (e) {\n // Only return null for file system errors, not validation errors\n // If it's a validation error, it should have been caught by validateRunId\n if (\n (e as Error).message.includes('Invalid runId') ||\n (e as Error).message.includes('Path traversal')\n ) {\n throw e; // Re-throw validation errors\n }\n // Return null if file doesn't exist or is invalid\n return null;\n }\n }\n\n listIncompleteRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'pending');\n }\n\n listCompletedRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'completed');\n }\n\n private async listRuns(\n workflowId: string,\n status: 'pending' | 'completed'\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n try {\n await this.ensureDir();\n const files = await fs.readdir(this.baseDir);\n const runs: WorkflowRunState<unknown, Record<string, unknown>>[] = [];\n\n for (const file of files) {\n if (!this.isValidRunFileName(file)) {\n continue;\n }\n\n try {\n const data = await fs.readFile(path.join(this.baseDir, file), 'utf-8');\n const run = JSON.parse(data) as unknown;\n\n if (!this.isValidRunStateShape(run)) {\n continue;\n }\n\n if (run.runId !== file.slice(0, -'.json'.length)) {\n continue;\n }\n\n if (run.workflowId === workflowId && run.status === status) {\n runs.push(run);\n }\n } catch (_e) {\n // Ignore corrupted files\n }\n }\n return runs;\n } catch (_e) {\n return [];\n }\n }\n\n async deleteRun(runId: string): Promise<void> {\n // Validate runId first before attempting file operations\n this.validateRunId(runId);\n\n try {\n await fs.unlink(this.getFilePath(runId));\n } catch (e) {\n // Only ignore file system errors, not validation errors\n if (\n (e as Error).message.includes('Invalid runId') ||\n (e as Error).message.includes('Path traversal')\n ) {\n throw e; // Re-throw validation errors\n }\n // Ignore if file doesn't exist\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAA2B;;;ACA3B,sBAAe;AACf,uBAAiB;AAGjB,IAAM,iBAAiB;AAEhB,IAAM,cAAN,MAA6C;AAAA,EAGlD,YAAY,UAAU,uBAAuB;AAC3C,SAAK,UAAU,iBAAAA,QAAK,QAAQ,OAAO;AAAA,EACrC;AAAA,EAEA,MAAc,YAA2B;AACvC,UAAM,gBAAAC,QAAG,MAAM,KAAK,SAAS,EAAE,WAAW,KAAK,CAAC;AAAA,EAClD;AAAA,EAEQ,cAAc,OAAqB;AACzC,QAAI,CAAC,SAAS,OAAO,UAAU,UAAU;AACvC,YAAM,IAAI,MAAM,kBAAkB,KAAK,EAAE;AAAA,IAC3C;AAGA,QACE,MAAM,SAAS,KAAK,KACpB,MAAM,SAAS,MAAM,KACrB,MAAM,WAAW,IAAI,GACrB;AACA,YAAM,IAAI,MAAM,6CAA6C,KAAK,EAAE;AAAA,IACtE;AAGA,QAAI,CAAC,eAAe,KAAK,KAAK,GAAG;AAC/B,YAAM,IAAI;AAAA,QACR,wFAAwF,KAAK;AAAA,MAC/F;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,YAAY,OAAuB;AACzC,SAAK,cAAc,KAAK;AACxB,UAAM,WAAW,iBAAAD,QAAK,KAAK,KAAK,SAAS,GAAG,KAAK,OAAO;AAGxD,UAAM,eAAe,iBAAAA,QAAK,QAAQ,QAAQ;AAC1C,UAAM,kBAAkB,iBAAAA,QAAK,QAAQ,KAAK,OAAO;AAEjD,QAAI,CAAC,aAAa,WAAW,eAAe,GAAG;AAC7C,YAAM,IAAI,MAAM,wCAAwC;AAAA,IAC1D;AAEA,WAAO;AAAA,EACT;AAAA,EAEQ,mBAAmB,UAA2B;AACpD,QAAI,CAAC,SAAS,SAAS,OAAO,GAAG;AAC/B,aAAO;AAAA,IACT;AAEA,UAAM,QAAQ,SAAS,MAAM,GAAG,CAAC,QAAQ,MAAM;AAC/C,WAAO,eAAe,KAAK,KAAK;AAAA,EAClC;AAAA,EAEQ,sBACN,OAC6C;AAC7C,WAAO,UAAU,aAAa,UAAU,eAAe,UAAU;AAAA,EACnE;AAAA,EAEQ,qBACN,KAC2D;AAC3D,QAAI,OAAO,QAAQ,YAAY,QAAQ,QAAQ,MAAM,QAAQ,GAAG,GAAG;AACjE,aAAO;AAAA,IACT;AAEA,UAAM,YAAY;AAClB,WACE,OAAO,UAAU,eAAe,YAChC,OAAO,UAAU,UAAU,YAC3B,eAAe,KAAK,UAAU,KAAK,KACnC,KAAK,sBAAsB,UAAU,MAAM,KAC3C,OAAO,UAAU,qBAAqB,YACtC,OAAO,UAAU,UAAU,gBAAgB,KAC3C,UAAU,oBAAoB,KAC9B,WAAW,aACX,OAAO,UAAU,UAAU,YAC3B,UAAU,UAAU,QACpB,CAAC,MAAM,QAAQ,UAAU,KAAK;AAAA,EAElC;AAAA,EAEA,MAAM,QACJ,OACe;AACf,UAAM,KAAK,UAAU;AACrB,UAAM,gBAAAC,QAAG;AAAA,MACP,KAAK,YAAY,MAAM,KAAK;AAAA,MAC5B,KAAK,UAAU,OAAO,MAAM,CAAC;AAAA,IAC/B;AAAA,EACF;AAAA,EAEA,MAAM,OACJ,OACoE;AAEpE,SAAK,cAAc,KAAK;AAExB,QAAI;AACF,YAAM,OAAO,MAAM,gBAAAA,QAAG,SAAS,KAAK,YAAY,KAAK,GAAG,OAAO;AAC/D,aAAO,KAAK,MAAM,IAAI;AAAA,IAIxB,SAAS,GAAG;AAGV,UACG,EAAY,QAAQ,SAAS,eAAe,KAC5C,EAAY,QAAQ,SAAS,gBAAgB,GAC9C;AACA,cAAM;AAAA,MACR;AAEA,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,mBACE,YAC+D;AAC/D,WAAO,KAAK,SAAS,YAAY,SAAS;AAAA,EAC5C;AAAA,EAEA,kBACE,YAC+D;AAC/D,WAAO,KAAK,SAAS,YAAY,WAAW;AAAA,EAC9C;AAAA,EAEA,MAAc,SACZ,YACA,QAC+D;AAC/D,QAAI;AACF,YAAM,KAAK,UAAU;AACrB,YAAM,QAAQ,MAAM,gBAAAA,QAAG,QAAQ,KAAK,OAAO;AAC3C,YAAM,OAA6D,CAAC;AAEpE,iBAAW,QAAQ,OAAO;AACxB,YAAI,CAAC,KAAK,mBAAmB,IAAI,GAAG;AAClC;AAAA,QACF;AAEA,YAAI;AACF,gBAAM,OAAO,MAAM,gBAAAA,QAAG,SAAS,iBAAAD,QAAK,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;AACrE,gBAAM,MAAM,KAAK,MAAM,IAAI;AAE3B,cAAI,CAAC,KAAK,qBAAqB,GAAG,GAAG;AACnC;AAAA,UACF;AAEA,cAAI,IAAI,UAAU,KAAK,MAAM,GAAG,CAAC,QAAQ,MAAM,GAAG;AAChD;AAAA,UACF;AAEA,cAAI,IAAI,eAAe,cAAc,IAAI,WAAW,QAAQ;AAC1D,iBAAK,KAAK,GAAG;AAAA,UACf;AAAA,QACF,SAAS,IAAI;AAAA,QAEb;AAAA,MACF;AACA,aAAO;AAAA,IACT,SAAS,IAAI;AACX,aAAO,CAAC;AAAA,IACV;AAAA,EACF;AAAA,EAEA,MAAM,UAAU,OAA8B;AAE5C,SAAK,cAAc,KAAK;AAExB,QAAI;AACF,YAAM,gBAAAC,QAAG,OAAO,KAAK,YAAY,KAAK,CAAC;AAAA,IACzC,SAAS,GAAG;AAEV,UACG,EAAY,QAAQ,SAAS,eAAe,KAC5C,EAAY,QAAQ,SAAS,gBAAgB,GAC9C;AACA,cAAM;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACF;;;ADtLO,IAAM,WAAN,MAGL;AAAA,EAKA,YAAY,QAAwC;AAClD,SAAK,SAAS;AACd,SAAK,UAAU,OAAO,WAAW,IAAI,YAAY;AACjD,SAAK,aAAa,OAAO;AAEzB,QAAI,OAAO,YAAY;AACrB,WAAK,oBAAoB,EAAE,MAAM,CAAC,QAAQ;AACxC,gBAAQ,MAAM,2CAA2C,GAAG;AAAA,MAC9D,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEA,MAAc,YACZ,cACA,WACe;AACf,UAAM,OAAO,KAAK,OAAO,MAAM,SAAS;AACxC,QAAI,CAAC,MAAM;AACT;AAAA,IACF;AAEA,QAAI;AACF,YAAM,SAAS,MAAM,KAAK,IAAI;AAAA,QAC5B,OAAO,aAAa;AAAA,QACpB,OAAO,aAAa;AAAA,MACtB,CAAC;AAED,UACE,WAAW,QACX,OAAO,WAAW,YAClB,CAAC,MAAM,QAAQ,MAAM,GACrB;AACA,qBAAa,QAAQ;AAAA,UACnB,GAAG,aAAa;AAAA,UAChB,GAAI;AAAA,QACN;AAAA,MACF,WAAW,WAAW,QAAW;AAC/B,QAAC,aAAa,MACZ,KAAK,QAAQ,QAAQ,SAAS,EAChC,IAAI;AAAA,MACN;AAEA,mBAAa,mBAAmB,YAAY;AAE5C,UAAI,aAAa,qBAAqB,KAAK,OAAO,MAAM,QAAQ;AAC9D,qBAAa,SAAS;AAAA,MACxB;AAEA,YAAM,KAAK,QAAQ,QAAQ,YAAY;AAAA,IACzC,SAAS,OAAO;AACd,mBAAa,SAAS;AACtB,mBAAa,QACX,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACvD,YAAM,KAAK,QAAQ,QAAQ,YAAY;AACvC,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAc,IACZ,OACA,aACiC;AACjC,UAAM,QAAQ,aAAa,aAAS,+BAAW;AAC/C,UAAM,eACH,eAA6D;AAAA,MAC5D,YAAY,KAAK;AAAA,MACjB;AAAA,MACA,QAAQ;AAAA,MACR,kBAAkB;AAAA,MAClB;AAAA,MACA,OAAO,CAAC;AAAA,IACV;AAEF,QAAI,CAAC,aAAa;AAChB,YAAM,KAAK,QAAQ,QAAQ,YAAY;AAAA,IACzC;AAEA,QAAI;AACF,eACM,IAAI,aAAa,kBACrB,IAAI,KAAK,OAAO,MAAM,QACtB,KACA;AACA,cAAM,KAAK,YAAY,cAAc,CAAC;AAAA,MACxC;AAAA,IACF,SAAS,QAAQ;AACf,aAAO;AAAA,QACL,SAAS;AAAA,QACT,OAAO,aAAa,SAAS;AAAA,QAC7B,OAAO,aAAa;AAAA,QACpB,UACE,KAAK,OAAO,MAAM,aAAa,gBAAgB,GAAG,QAClD,QAAQ,aAAa,gBAAgB;AAAA,MACzC;AAAA,IACF;AAGA,QAAI,KAAK,OAAO,aAAa;AAC3B,YAAM,KAAK,QAAQ,UAAU,aAAa,KAAK;AAAA,IACjD;AAEA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,QAAQ,aAAa;AAAA,MACrB,OAAO,aAAa;AAAA,IACtB;AAAA,EACF;AAAA,EAEA,MAAM,OAAgD;AACpD,WAAO,KAAK,IAAI,KAAK;AAAA,EACvB;AAAA,EAEA,MAAM,OAAO,OAAgD;AAC3D,UAAM,WAAW,MAAM,KAAK,QAAQ,OAAO,KAAK;AAChD,QAAI,CAAC,UAAU;AACb,aAAO;AAAA,QACL,SAAS;AAAA,QACT,OAAO,OAAO,KAAK;AAAA,QACnB;AAAA,QACA,UAAU;AAAA,MACZ;AAAA,IACF;AACA,QAAI,SAAS,WAAW,aAAa;AACnC,aAAO;AAAA,QACL,SAAS;AAAA,QACT,QAAQ,SAAS;AAAA,QACjB,OAAO,SAAS;AAAA,MAClB;AAAA,IACF;AACA,WAAO,KAAK;AAAA,MACV,SAAS;AAAA,MACT;AAAA,IACF;AAAA,EACF;AAAA,EAEA,iBAEE;AACA,WAAO,KAAK,QAAQ,mBAAmB,KAAK,UAAU;AAAA,EACxD;AAAA,EAEA,MAAM,sBAAyD;AAC7D,UAAM,aAAa,MAAM,KAAK,QAAQ,mBAAmB,KAAK,UAAU;AACxE,WAAO,QAAQ;AAAA,MACb,WAAW;AAAA,QAAI,CAAC,aACd,KAAK;AAAA,UACH,SAAS;AAAA,UACT;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,iBAAgC;AACpC,UAAM,YAAY,MAAM,KAAK,QAAQ,kBAAkB,KAAK,UAAU;AACtE,UAAM,QAAQ;AAAA,MACZ,UAAU,IAAI,CAAC,QAAQ,KAAK,QAAQ,UAAU,IAAI,KAAK,CAAC;AAAA,IAC1D;AAAA,EACF;AACF;AAKO,SAAS,eAGd,QAAwC;AACxC,SAAO,IAAI,SAAS,MAAM;AAC5B;","names":["path","fs"]}
package/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
4
4
  // src/storage/file-storage.ts
5
5
  import fs from "fs/promises";
6
6
  import path from "path";
7
+ var RUN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
7
8
  var FileStorage = class {
8
9
  constructor(baseDir = ".resumable-workflow") {
9
10
  this.baseDir = path.resolve(baseDir);
@@ -11,8 +12,45 @@ var FileStorage = class {
11
12
  async ensureDir() {
12
13
  await fs.mkdir(this.baseDir, { recursive: true });
13
14
  }
15
+ validateRunId(runId) {
16
+ if (!runId || typeof runId !== "string") {
17
+ throw new Error(`Invalid runId: ${runId}`);
18
+ }
19
+ if (runId.includes("../") || runId.includes("..\\") || runId.startsWith("..")) {
20
+ throw new Error(`Invalid runId: Path traversal detected in ${runId}`);
21
+ }
22
+ if (!RUN_ID_PATTERN.test(runId)) {
23
+ throw new Error(
24
+ `Invalid runId: Only alphanumeric characters, hyphens, and underscores are allowed in ${runId}`
25
+ );
26
+ }
27
+ }
14
28
  getFilePath(runId) {
15
- return path.join(this.baseDir, `${runId}.json`);
29
+ this.validateRunId(runId);
30
+ const filePath = path.join(this.baseDir, `${runId}.json`);
31
+ const resolvedPath = path.resolve(filePath);
32
+ const resolvedBaseDir = path.resolve(this.baseDir);
33
+ if (!resolvedPath.startsWith(resolvedBaseDir)) {
34
+ throw new Error("Invalid runId: Path traversal detected");
35
+ }
36
+ return resolvedPath;
37
+ }
38
+ isValidRunFileName(fileName) {
39
+ if (!fileName.endsWith(".json")) {
40
+ return false;
41
+ }
42
+ const runId = fileName.slice(0, -".json".length);
43
+ return RUN_ID_PATTERN.test(runId);
44
+ }
45
+ isValidWorkflowStatus(value) {
46
+ return value === "pending" || value === "completed" || value === "failed";
47
+ }
48
+ isValidRunStateShape(run) {
49
+ if (typeof run !== "object" || run === null || Array.isArray(run)) {
50
+ return false;
51
+ }
52
+ const candidate = run;
53
+ return typeof candidate.workflowId === "string" && typeof candidate.runId === "string" && RUN_ID_PATTERN.test(candidate.runId) && this.isValidWorkflowStatus(candidate.status) && typeof candidate.currentStepIndex === "number" && Number.isInteger(candidate.currentStepIndex) && candidate.currentStepIndex >= 0 && "input" in candidate && typeof candidate.state === "object" && candidate.state !== null && !Array.isArray(candidate.state);
16
54
  }
17
55
  async saveRun(state) {
18
56
  await this.ensureDir();
@@ -22,10 +60,14 @@ var FileStorage = class {
22
60
  );
23
61
  }
24
62
  async getRun(runId) {
63
+ this.validateRunId(runId);
25
64
  try {
26
65
  const data = await fs.readFile(this.getFilePath(runId), "utf-8");
27
66
  return JSON.parse(data);
28
- } catch (_e) {
67
+ } catch (e) {
68
+ if (e.message.includes("Invalid runId") || e.message.includes("Path traversal")) {
69
+ throw e;
70
+ }
29
71
  return null;
30
72
  }
31
73
  }
@@ -41,18 +83,22 @@ var FileStorage = class {
41
83
  const files = await fs.readdir(this.baseDir);
42
84
  const runs = [];
43
85
  for (const file of files) {
44
- if (file.endsWith(".json")) {
45
- try {
46
- const data = await fs.readFile(
47
- path.join(this.baseDir, file),
48
- "utf-8"
49
- );
50
- const run = JSON.parse(data);
51
- if (run.workflowId === workflowId && run.status === status) {
52
- runs.push(run);
53
- }
54
- } catch (_e) {
86
+ if (!this.isValidRunFileName(file)) {
87
+ continue;
88
+ }
89
+ try {
90
+ const data = await fs.readFile(path.join(this.baseDir, file), "utf-8");
91
+ const run = JSON.parse(data);
92
+ if (!this.isValidRunStateShape(run)) {
93
+ continue;
94
+ }
95
+ if (run.runId !== file.slice(0, -".json".length)) {
96
+ continue;
55
97
  }
98
+ if (run.workflowId === workflowId && run.status === status) {
99
+ runs.push(run);
100
+ }
101
+ } catch (_e) {
56
102
  }
57
103
  }
58
104
  return runs;
@@ -61,9 +107,13 @@ var FileStorage = class {
61
107
  }
62
108
  }
63
109
  async deleteRun(runId) {
110
+ this.validateRunId(runId);
64
111
  try {
65
112
  await fs.unlink(this.getFilePath(runId));
66
- } catch (_e) {
113
+ } catch (e) {
114
+ if (e.message.includes("Invalid runId") || e.message.includes("Path traversal")) {
115
+ throw e;
116
+ }
67
117
  }
68
118
  }
69
119
  };
@@ -76,10 +126,7 @@ var Workflow = class {
76
126
  this.workflowId = config.id;
77
127
  if (config.autoResume) {
78
128
  this.resumeAllIncomplete().catch((err) => {
79
- console.error(
80
- `[ResumableWorkflow: ${this.workflowId}] Auto-resume failed:`,
81
- err
82
- );
129
+ console.error("[ResumableWorkflow] Auto-resume failed:", err);
83
130
  });
84
131
  }
85
132
  }
@@ -191,7 +238,9 @@ var Workflow = class {
191
238
  */
192
239
  async clearCompleted() {
193
240
  const completed = await this.storage.listCompletedRuns(this.workflowId);
194
- await Promise.all(completed.map((run) => this.storage.deleteRun(run.runId)));
241
+ await Promise.all(
242
+ completed.map((run) => this.storage.deleteRun(run.runId))
243
+ );
195
244
  }
196
245
  };
197
246
  function createWorkflow(config) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/core/engine.ts","../src/storage/file-storage.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { FileStorage } from '../storage/file-storage';\nimport type {\n StorageProvider,\n WorkflowConfig,\n WorkflowResult,\n WorkflowRunState,\n WorkflowStatus,\n} from '../types';\n\ntype Mutable<T> = {\n -readonly [P in keyof T]: T[P];\n};\n\nexport class Workflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n> {\n private readonly storage: StorageProvider;\n private readonly workflowId: string;\n private readonly config: WorkflowConfig<TInput, TState>;\n\n constructor(config: WorkflowConfig<TInput, TState>) {\n this.config = config;\n this.storage = config.storage || new FileStorage();\n this.workflowId = config.id;\n\n if (config.autoResume) {\n this.resumeAllIncomplete().catch((err) => {\n console.error(\n `[ResumableWorkflow: ${this.workflowId}] Auto-resume failed:`,\n err\n );\n });\n }\n }\n\n private async executeStep(\n currentState: Mutable<WorkflowRunState<TInput, TState>>,\n stepIndex: number\n ): Promise<void> {\n const step = this.config.steps[stepIndex];\n if (!step) {\n return;\n }\n\n try {\n const result = await step.run({\n input: currentState.input,\n state: currentState.state,\n });\n\n if (\n result !== null &&\n typeof result === 'object' &&\n !Array.isArray(result)\n ) {\n currentState.state = {\n ...currentState.state,\n ...(result as Partial<TState>),\n };\n } else if (result !== undefined) {\n (currentState.state as Record<string, unknown>)[\n step.name || `step_${stepIndex}`\n ] = result;\n }\n\n currentState.currentStepIndex = stepIndex + 1;\n\n if (currentState.currentStepIndex === this.config.steps.length) {\n currentState.status = 'completed';\n }\n\n await this.storage.saveRun(currentState);\n } catch (error) {\n currentState.status = 'failed';\n currentState.error =\n error instanceof Error ? error.message : String(error);\n await this.storage.saveRun(currentState);\n throw error;\n }\n }\n\n private async run(\n input: TInput,\n existingRun?: WorkflowRunState<TInput, TState>\n ): Promise<WorkflowResult<TState>> {\n const runId = existingRun?.runId || randomUUID();\n const currentState: Mutable<WorkflowRunState<TInput, TState>> =\n (existingRun as Mutable<WorkflowRunState<TInput, TState>>) || {\n workflowId: this.workflowId,\n runId,\n status: 'pending' as WorkflowStatus,\n currentStepIndex: 0,\n input,\n state: {} as TState,\n };\n\n if (!existingRun) {\n await this.storage.saveRun(currentState);\n }\n\n try {\n for (\n let i = currentState.currentStepIndex;\n i < this.config.steps.length;\n i++\n ) {\n await this.executeStep(currentState, i);\n }\n } catch (_error) {\n return {\n success: false,\n error: currentState.error || 'Unknown error',\n runId: currentState.runId,\n stepName:\n this.config.steps[currentState.currentStepIndex]?.name ||\n `step_${currentState.currentStepIndex}`,\n };\n }\n\n // Auto-cleanup if enabled and successful\n if (this.config.autoCleanup) {\n await this.storage.deleteRun(currentState.runId);\n }\n\n return {\n success: true,\n result: currentState.state,\n runId: currentState.runId,\n };\n }\n\n start(input: TInput): Promise<WorkflowResult<TState>> {\n return this.run(input);\n }\n\n async resume(runId: string): Promise<WorkflowResult<TState>> {\n const runState = await this.storage.getRun(runId);\n if (!runState) {\n return {\n success: false,\n error: `Run ${runId} not found`,\n runId,\n stepName: 'unknown',\n };\n }\n if (runState.status === 'completed') {\n return {\n success: true,\n result: runState.state as TState,\n runId: runState.runId,\n };\n }\n return this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n );\n }\n\n listIncomplete(): Promise<\n WorkflowRunState<unknown, Record<string, unknown>>[]\n > {\n return this.storage.listIncompleteRuns(this.workflowId);\n }\n\n async resumeAllIncomplete(): Promise<WorkflowResult<TState>[]> {\n const incomplete = await this.storage.listIncompleteRuns(this.workflowId);\n return Promise.all(\n incomplete.map((runState) =>\n this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n )\n )\n );\n }\n\n /**\n * Manually clears all completed workflow runs for this workflow ID from storage.\n */\n async clearCompleted(): Promise<void> {\n const completed = await this.storage.listCompletedRuns(this.workflowId);\n await Promise.all(completed.map((run) => this.storage.deleteRun(run.runId)));\n }\n}\n\n/**\n * Factory function for backward compatibility and ease of use\n */\nexport function createWorkflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n>(config: WorkflowConfig<TInput, TState>) {\n return new Workflow(config);\n}","import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { StorageProvider, WorkflowRunState } from '../types';\n\nexport class FileStorage implements StorageProvider {\n private readonly baseDir: string;\n\n constructor(baseDir = '.resumable-workflow') {\n this.baseDir = path.resolve(baseDir);\n }\n\n private async ensureDir(): Promise<void> {\n await fs.mkdir(this.baseDir, { recursive: true });\n }\n\n private getFilePath(runId: string): string {\n return path.join(this.baseDir, `${runId}.json`);\n }\n\n async saveRun(\n state: WorkflowRunState<unknown, Record<string, unknown>>\n ): Promise<void> {\n await this.ensureDir();\n await fs.writeFile(\n this.getFilePath(state.runId),\n JSON.stringify(state, null, 2)\n );\n }\n\n async getRun(\n runId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>> | null> {\n try {\n const data = await fs.readFile(this.getFilePath(runId), 'utf-8');\n return JSON.parse(data) as WorkflowRunState<\n unknown,\n Record<string, unknown>\n >;\n } catch (_e) {\n // Return null if file doesn't exist or is invalid\n return null;\n }\n }\n\n listIncompleteRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'pending');\n }\n\n listCompletedRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'completed');\n }\n\n private async listRuns(\n workflowId: string,\n status: 'pending' | 'completed'\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n try {\n await this.ensureDir();\n const files = await fs.readdir(this.baseDir);\n const runs: WorkflowRunState<unknown, Record<string, unknown>>[] = [];\n\n for (const file of files) {\n if (file.endsWith('.json')) {\n try {\n const data = await fs.readFile(\n path.join(this.baseDir, file),\n 'utf-8'\n );\n const run = JSON.parse(data) as WorkflowRunState<\n unknown,\n Record<string, unknown>\n >;\n if (run.workflowId === workflowId && run.status === status) {\n runs.push(run);\n }\n } catch (_e) {\n // Ignore corrupted files\n }\n }\n }\n return runs;\n } catch (_e) {\n return [];\n }\n }\n\n async deleteRun(runId: string): Promise<void> {\n try {\n await fs.unlink(this.getFilePath(runId));\n } catch (_e) {\n // Ignore if file doesn't exist\n }\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/core/engine.ts","../src/storage/file-storage.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { FileStorage } from '../storage/file-storage';\nimport type {\n StorageProvider,\n WorkflowConfig,\n WorkflowResult,\n WorkflowRunState,\n WorkflowStatus,\n} from '../types';\n\ntype Mutable<T> = {\n -readonly [P in keyof T]: T[P];\n};\n\nexport class Workflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n> {\n private readonly storage: StorageProvider;\n private readonly workflowId: string;\n private readonly config: WorkflowConfig<TInput, TState>;\n\n constructor(config: WorkflowConfig<TInput, TState>) {\n this.config = config;\n this.storage = config.storage || new FileStorage();\n this.workflowId = config.id;\n\n if (config.autoResume) {\n this.resumeAllIncomplete().catch((err) => {\n console.error('[ResumableWorkflow] Auto-resume failed:', err);\n });\n }\n }\n\n private async executeStep(\n currentState: Mutable<WorkflowRunState<TInput, TState>>,\n stepIndex: number\n ): Promise<void> {\n const step = this.config.steps[stepIndex];\n if (!step) {\n return;\n }\n\n try {\n const result = await step.run({\n input: currentState.input,\n state: currentState.state,\n });\n\n if (\n result !== null &&\n typeof result === 'object' &&\n !Array.isArray(result)\n ) {\n currentState.state = {\n ...currentState.state,\n ...(result as Partial<TState>),\n };\n } else if (result !== undefined) {\n (currentState.state as Record<string, unknown>)[\n step.name || `step_${stepIndex}`\n ] = result;\n }\n\n currentState.currentStepIndex = stepIndex + 1;\n\n if (currentState.currentStepIndex === this.config.steps.length) {\n currentState.status = 'completed';\n }\n\n await this.storage.saveRun(currentState);\n } catch (error) {\n currentState.status = 'failed';\n currentState.error =\n error instanceof Error ? error.message : String(error);\n await this.storage.saveRun(currentState);\n throw error;\n }\n }\n\n private async run(\n input: TInput,\n existingRun?: WorkflowRunState<TInput, TState>\n ): Promise<WorkflowResult<TState>> {\n const runId = existingRun?.runId || randomUUID();\n const currentState: Mutable<WorkflowRunState<TInput, TState>> =\n (existingRun as Mutable<WorkflowRunState<TInput, TState>>) || {\n workflowId: this.workflowId,\n runId,\n status: 'pending' as WorkflowStatus,\n currentStepIndex: 0,\n input,\n state: {} as TState,\n };\n\n if (!existingRun) {\n await this.storage.saveRun(currentState);\n }\n\n try {\n for (\n let i = currentState.currentStepIndex;\n i < this.config.steps.length;\n i++\n ) {\n await this.executeStep(currentState, i);\n }\n } catch (_error) {\n return {\n success: false,\n error: currentState.error || 'Unknown error',\n runId: currentState.runId,\n stepName:\n this.config.steps[currentState.currentStepIndex]?.name ||\n `step_${currentState.currentStepIndex}`,\n };\n }\n\n // Auto-cleanup if enabled and successful\n if (this.config.autoCleanup) {\n await this.storage.deleteRun(currentState.runId);\n }\n\n return {\n success: true,\n result: currentState.state,\n runId: currentState.runId,\n };\n }\n\n start(input: TInput): Promise<WorkflowResult<TState>> {\n return this.run(input);\n }\n\n async resume(runId: string): Promise<WorkflowResult<TState>> {\n const runState = await this.storage.getRun(runId);\n if (!runState) {\n return {\n success: false,\n error: `Run ${runId} not found`,\n runId,\n stepName: 'unknown',\n };\n }\n if (runState.status === 'completed') {\n return {\n success: true,\n result: runState.state as TState,\n runId: runState.runId,\n };\n }\n return this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n );\n }\n\n listIncomplete(): Promise<\n WorkflowRunState<unknown, Record<string, unknown>>[]\n > {\n return this.storage.listIncompleteRuns(this.workflowId);\n }\n\n async resumeAllIncomplete(): Promise<WorkflowResult<TState>[]> {\n const incomplete = await this.storage.listIncompleteRuns(this.workflowId);\n return Promise.all(\n incomplete.map((runState) =>\n this.run(\n runState.input as TInput,\n runState as WorkflowRunState<TInput, TState>\n )\n )\n );\n }\n\n /**\n * Manually clears all completed workflow runs for this workflow ID from storage.\n */\n async clearCompleted(): Promise<void> {\n const completed = await this.storage.listCompletedRuns(this.workflowId);\n await Promise.all(\n completed.map((run) => this.storage.deleteRun(run.runId))\n );\n }\n}\n\n/**\n * Factory function for backward compatibility and ease of use\n */\nexport function createWorkflow<\n TInput = unknown,\n TState extends Record<string, unknown> = Record<string, unknown>,\n>(config: WorkflowConfig<TInput, TState>) {\n return new Workflow(config);\n}\n","import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { StorageProvider, WorkflowRunState } from '../types';\n\nconst RUN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;\n\nexport class FileStorage implements StorageProvider {\n private readonly baseDir: string;\n\n constructor(baseDir = '.resumable-workflow') {\n this.baseDir = path.resolve(baseDir);\n }\n\n private async ensureDir(): Promise<void> {\n await fs.mkdir(this.baseDir, { recursive: true });\n }\n\n private validateRunId(runId: string): void {\n if (!runId || typeof runId !== 'string') {\n throw new Error(`Invalid runId: ${runId}`);\n }\n\n // Check for path traversal attempts\n if (\n runId.includes('../') ||\n runId.includes('..\\\\') ||\n runId.startsWith('..')\n ) {\n throw new Error(`Invalid runId: Path traversal detected in ${runId}`);\n }\n\n // Additional validation: only allow alphanumeric, hyphens, and underscores\n if (!RUN_ID_PATTERN.test(runId)) {\n throw new Error(\n `Invalid runId: Only alphanumeric characters, hyphens, and underscores are allowed in ${runId}`\n );\n }\n }\n\n private getFilePath(runId: string): string {\n this.validateRunId(runId);\n const filePath = path.join(this.baseDir, `${runId}.json`);\n\n // Additional security check: ensure the resolved path is within the base directory\n const resolvedPath = path.resolve(filePath);\n const resolvedBaseDir = path.resolve(this.baseDir);\n\n if (!resolvedPath.startsWith(resolvedBaseDir)) {\n throw new Error('Invalid runId: Path traversal detected');\n }\n\n return resolvedPath;\n }\n\n private isValidRunFileName(fileName: string): boolean {\n if (!fileName.endsWith('.json')) {\n return false;\n }\n\n const runId = fileName.slice(0, -'.json'.length);\n return RUN_ID_PATTERN.test(runId);\n }\n\n private isValidWorkflowStatus(\n value: unknown\n ): value is 'pending' | 'completed' | 'failed' {\n return value === 'pending' || value === 'completed' || value === 'failed';\n }\n\n private isValidRunStateShape(\n run: unknown\n ): run is WorkflowRunState<unknown, Record<string, unknown>> {\n if (typeof run !== 'object' || run === null || Array.isArray(run)) {\n return false;\n }\n\n const candidate = run as Record<string, unknown>;\n return (\n typeof candidate.workflowId === 'string' &&\n typeof candidate.runId === 'string' &&\n RUN_ID_PATTERN.test(candidate.runId) &&\n this.isValidWorkflowStatus(candidate.status) &&\n typeof candidate.currentStepIndex === 'number' &&\n Number.isInteger(candidate.currentStepIndex) &&\n candidate.currentStepIndex >= 0 &&\n 'input' in candidate &&\n typeof candidate.state === 'object' &&\n candidate.state !== null &&\n !Array.isArray(candidate.state)\n );\n }\n\n async saveRun(\n state: WorkflowRunState<unknown, Record<string, unknown>>\n ): Promise<void> {\n await this.ensureDir();\n await fs.writeFile(\n this.getFilePath(state.runId),\n JSON.stringify(state, null, 2)\n );\n }\n\n async getRun(\n runId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>> | null> {\n // Validate runId first before attempting file operations\n this.validateRunId(runId);\n\n try {\n const data = await fs.readFile(this.getFilePath(runId), 'utf-8');\n return JSON.parse(data) as WorkflowRunState<\n unknown,\n Record<string, unknown>\n >;\n } catch (e) {\n // Only return null for file system errors, not validation errors\n // If it's a validation error, it should have been caught by validateRunId\n if (\n (e as Error).message.includes('Invalid runId') ||\n (e as Error).message.includes('Path traversal')\n ) {\n throw e; // Re-throw validation errors\n }\n // Return null if file doesn't exist or is invalid\n return null;\n }\n }\n\n listIncompleteRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'pending');\n }\n\n listCompletedRuns(\n workflowId: string\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n return this.listRuns(workflowId, 'completed');\n }\n\n private async listRuns(\n workflowId: string,\n status: 'pending' | 'completed'\n ): Promise<WorkflowRunState<unknown, Record<string, unknown>>[]> {\n try {\n await this.ensureDir();\n const files = await fs.readdir(this.baseDir);\n const runs: WorkflowRunState<unknown, Record<string, unknown>>[] = [];\n\n for (const file of files) {\n if (!this.isValidRunFileName(file)) {\n continue;\n }\n\n try {\n const data = await fs.readFile(path.join(this.baseDir, file), 'utf-8');\n const run = JSON.parse(data) as unknown;\n\n if (!this.isValidRunStateShape(run)) {\n continue;\n }\n\n if (run.runId !== file.slice(0, -'.json'.length)) {\n continue;\n }\n\n if (run.workflowId === workflowId && run.status === status) {\n runs.push(run);\n }\n } catch (_e) {\n // Ignore corrupted files\n }\n }\n return runs;\n } catch (_e) {\n return [];\n }\n }\n\n async deleteRun(runId: string): Promise<void> {\n // Validate runId first before attempting file operations\n this.validateRunId(runId);\n\n try {\n await fs.unlink(this.getFilePath(runId));\n } catch (e) {\n // Only ignore file system errors, not validation errors\n if (\n (e as Error).message.includes('Invalid runId') ||\n (e as Error).message.includes('Path traversal')\n ) {\n throw e; // Re-throw validation errors\n }\n // Ignore if file doesn't exist\n }\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resumable-workflow",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "A lightweight, persistent workflow engine for Node.js. Ensure multi-step processes survive crashes and resume automatically.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",