bare-agent 0.1.0 → 0.1.1

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/src/planner.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const PLAN_PROMPT = `You are a planning agent. Break the user's goal into concrete steps.
4
+
5
+ Rules:
6
+ - Each step must be a single, actionable task an agent can execute with tools.
7
+ - Use dependsOn to express ordering. Steps with no dependencies can run in parallel.
8
+ - Keep it minimal: 2-7 steps. Don't over-decompose simple goals.
9
+ - Output ONLY a JSON array, no markdown, no explanation.
10
+
11
+ Output format:
12
+ [
13
+ { "id": "s1", "action": "description of step", "dependsOn": [] },
14
+ { "id": "s2", "action": "description of step", "dependsOn": ["s1"] }
15
+ ]`;
16
+
17
+ class Planner {
18
+ /**
19
+ * @param {object} options
20
+ * @param {object} options.provider - LLM provider (must implement generate()).
21
+ * @param {string} [options.prompt] - Custom planning prompt override.
22
+ * @throws {Error} `[Planner] requires a provider` — when options.provider is missing.
23
+ */
24
+ constructor(options = {}) {
25
+ if (!options.provider) throw new Error('[Planner] requires a provider');
26
+ this.provider = options.provider;
27
+ this.prompt = options.prompt || PLAN_PROMPT;
28
+ }
29
+
30
+ /**
31
+ * Generate a step DAG from a goal.
32
+ * @param {string} goal - The user's goal to decompose.
33
+ * @param {object} [context={}] - Optional context with info field.
34
+ * @returns {Promise<Array<{id: string, action: string, dependsOn: string[], status: string}>>}
35
+ * @throws {Error} `[Planner] could not parse plan` — when LLM output is not parseable JSON.
36
+ * @throws {Error} `[Planner] expected JSON array` — when parsed result is not an array.
37
+ * @throws {Error} `[Planner] step missing id or action` — when a step lacks required fields.
38
+ */
39
+ async plan(goal, context = {}) {
40
+ const messages = [
41
+ { role: 'system', content: this.prompt },
42
+ ];
43
+ if (context.info) {
44
+ messages.push({ role: 'user', content: `Context: ${context.info}` });
45
+ messages.push({ role: 'assistant', content: 'Understood. I will factor this context into the plan.' });
46
+ }
47
+ messages.push({ role: 'user', content: goal });
48
+
49
+ const result = await this.provider.generate(messages, [], {
50
+ temperature: 0,
51
+ });
52
+
53
+ return this._parse(result.text);
54
+ }
55
+
56
+ _parse(text) {
57
+ // Extract JSON array from response (handle markdown code blocks)
58
+ const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
59
+ let steps;
60
+ try {
61
+ steps = JSON.parse(cleaned);
62
+ } catch (e) {
63
+ // Try to find array in the text
64
+ const match = cleaned.match(/\[[\s\S]*\]/);
65
+ if (!match) throw new Error(`[Planner] could not parse plan from LLM output: ${text.slice(0, 200)}`);
66
+ steps = JSON.parse(match[0]);
67
+ }
68
+
69
+ if (!Array.isArray(steps)) throw new Error('[Planner] expected JSON array');
70
+
71
+ // Validate and normalize
72
+ const ids = new Set(steps.map(s => s.id));
73
+ return steps.map(s => {
74
+ if (!s.id || !s.action) throw new Error(`[Planner] step missing id or action: ${JSON.stringify(s)}`);
75
+ const deps = (s.dependsOn || []).filter(d => ids.has(d));
76
+ return { id: s.id, action: s.action, dependsOn: deps, status: 'pending' };
77
+ });
78
+ }
79
+ }
80
+
81
+ module.exports = { Planner };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ class AnthropicProvider {
6
+ /**
7
+ * @param {object} options
8
+ * @param {string} options.apiKey - Anthropic API key (required).
9
+ * @param {string} [options.model='claude-haiku-4-5-20251001'] - Model ID.
10
+ * @throws {Error} `[AnthropicProvider] requires apiKey` — when apiKey is missing.
11
+ */
12
+ constructor(options = {}) {
13
+ if (!options.apiKey) throw new Error('[AnthropicProvider] requires apiKey');
14
+ this.apiKey = options.apiKey.trim();
15
+ this.model = options.model || 'claude-haiku-4-5-20251001';
16
+ }
17
+
18
+ /**
19
+ * Generate a response from the Anthropic API.
20
+ * @param {Array<object>} messages - Conversation messages (OpenAI format, auto-converted).
21
+ * @param {Array<object>} [tools=[]] - Tool definitions.
22
+ * @param {object} [options={}] - Options (temperature, maxTokens, system).
23
+ * @returns {Promise<{text: string, toolCalls: Array, usage: object}>}
24
+ * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
25
+ */
26
+ async generate(messages, tools = [], options = {}) {
27
+ // Separate system message from conversation messages
28
+ let system;
29
+ const msgs = [];
30
+ for (const m of messages) {
31
+ if (m.role === 'system') {
32
+ system = m.content;
33
+ } else {
34
+ msgs.push(this._toAnthropicMessage(m));
35
+ }
36
+ }
37
+
38
+ // Override with options.system if provided
39
+ if (options.system) system = options.system;
40
+
41
+ const body = {
42
+ model: this.model,
43
+ max_tokens: options.maxTokens || 4096,
44
+ messages: msgs,
45
+ ...(system && { system }),
46
+ ...(options.temperature != null && { temperature: options.temperature }),
47
+ };
48
+ if (tools.length > 0) {
49
+ body.tools = tools.map(t => ({
50
+ name: t.name,
51
+ description: t.description,
52
+ input_schema: t.parameters,
53
+ }));
54
+ }
55
+
56
+ const data = await this._request(body);
57
+
58
+ let text = '';
59
+ const toolCalls = [];
60
+ for (const block of data.content) {
61
+ if (block.type === 'text') text += block.text;
62
+ if (block.type === 'tool_use') {
63
+ toolCalls.push({ id: block.id, name: block.name, arguments: block.input });
64
+ }
65
+ }
66
+
67
+ return {
68
+ text,
69
+ toolCalls,
70
+ usage: {
71
+ inputTokens: data.usage?.input_tokens || 0,
72
+ outputTokens: data.usage?.output_tokens || 0,
73
+ },
74
+ };
75
+ }
76
+
77
+ _toAnthropicMessage(msg) {
78
+ // Convert OpenAI-format tool results → Anthropic tool_result blocks
79
+ if (msg.role === 'tool') {
80
+ return {
81
+ role: 'user',
82
+ content: [{
83
+ type: 'tool_result',
84
+ tool_use_id: msg.tool_call_id,
85
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
86
+ }],
87
+ };
88
+ }
89
+ // Convert OpenAI-format assistant tool_calls → Anthropic tool_use content blocks
90
+ if (msg.role === 'assistant' && msg.tool_calls?.length > 0) {
91
+ const content = [];
92
+ if (msg.content) content.push({ type: 'text', text: msg.content });
93
+ for (const tc of msg.tool_calls) {
94
+ content.push({
95
+ type: 'tool_use',
96
+ id: tc.id,
97
+ name: tc.function.name,
98
+ input: typeof tc.function.arguments === 'string'
99
+ ? JSON.parse(tc.function.arguments)
100
+ : tc.function.arguments,
101
+ });
102
+ }
103
+ return { role: 'assistant', content };
104
+ }
105
+ return { role: msg.role, content: msg.content };
106
+ }
107
+
108
+ _request(body) {
109
+ return new Promise((resolve, reject) => {
110
+ const payload = JSON.stringify(body);
111
+ const req = https.request('https://api.anthropic.com/v1/messages', {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'Content-Length': Buffer.byteLength(payload),
116
+ 'x-api-key': this.apiKey,
117
+ 'anthropic-version': '2023-06-01',
118
+ },
119
+ }, (res) => {
120
+ let chunks = '';
121
+ res.on('data', d => chunks += d);
122
+ res.on('end', () => {
123
+ try {
124
+ const parsed = JSON.parse(chunks);
125
+ if (res.statusCode >= 400) {
126
+ const err = new Error(`[AnthropicProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`);
127
+ err.status = res.statusCode;
128
+ err.body = parsed;
129
+ return reject(err);
130
+ }
131
+ resolve(parsed);
132
+ } catch (e) {
133
+ reject(new Error(`[AnthropicProvider] Invalid JSON response: ${chunks.slice(0, 200)}`));
134
+ }
135
+ });
136
+ });
137
+ req.on('error', reject);
138
+ req.write(payload);
139
+ req.end();
140
+ });
141
+ }
142
+ }
143
+
144
+ module.exports = { AnthropicProvider };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+
5
+ class OllamaProvider {
6
+ constructor(options = {}) {
7
+ this.model = options.model || 'llama3.2';
8
+ this.url = options.url || 'http://localhost:11434';
9
+ }
10
+
11
+ /**
12
+ * Generate a response from a local Ollama instance.
13
+ * @param {Array<object>} messages - Conversation messages.
14
+ * @param {Array<object>} [tools=[]] - Tool definitions.
15
+ * @param {object} [options={}] - Options (temperature).
16
+ * @returns {Promise<{text: string, toolCalls: Array, usage: object}>}
17
+ * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
18
+ */
19
+ async generate(messages, tools = [], options = {}) {
20
+ const body = {
21
+ model: this.model,
22
+ messages,
23
+ stream: false,
24
+ ...(options.temperature != null && { options: { temperature: options.temperature } }),
25
+ };
26
+ if (tools.length > 0) {
27
+ body.tools = tools.map(t => ({
28
+ type: 'function',
29
+ function: { name: t.name, description: t.description, parameters: t.parameters },
30
+ }));
31
+ }
32
+
33
+ const data = await this._request('/api/chat', body);
34
+ const msg = data.message || {};
35
+
36
+ return {
37
+ text: msg.content || '',
38
+ toolCalls: (msg.tool_calls || []).map(tc => ({
39
+ id: tc.id || `call_${Date.now()}`,
40
+ name: tc.function.name,
41
+ arguments: typeof tc.function.arguments === 'string'
42
+ ? JSON.parse(tc.function.arguments)
43
+ : tc.function.arguments,
44
+ })),
45
+ usage: {
46
+ inputTokens: data.prompt_eval_count || 0,
47
+ outputTokens: data.eval_count || 0,
48
+ },
49
+ };
50
+ }
51
+
52
+ _request(path, body) {
53
+ return new Promise((resolve, reject) => {
54
+ const url = new URL(this.url + path);
55
+ const payload = JSON.stringify(body);
56
+
57
+ const req = http.request(url, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'Content-Length': Buffer.byteLength(payload),
62
+ },
63
+ }, (res) => {
64
+ let chunks = '';
65
+ res.on('data', d => chunks += d);
66
+ res.on('end', () => {
67
+ try {
68
+ const parsed = JSON.parse(chunks);
69
+ if (res.statusCode >= 400) {
70
+ const err = new Error(`[OllamaProvider] ${parsed.error || `HTTP ${res.statusCode}`}`);
71
+ err.status = res.statusCode;
72
+ return reject(err);
73
+ }
74
+ resolve(parsed);
75
+ } catch (e) {
76
+ reject(new Error(`[OllamaProvider] Invalid JSON response: ${chunks.slice(0, 200)}`));
77
+ }
78
+ });
79
+ });
80
+ req.on('error', reject);
81
+ req.write(payload);
82
+ req.end();
83
+ });
84
+ }
85
+ }
86
+
87
+ module.exports = { OllamaProvider };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+
6
+ class OpenAIProvider {
7
+ constructor(options = {}) {
8
+ this.apiKey = options.apiKey?.trim();
9
+ this.model = options.model || 'gpt-4o-mini';
10
+ this.baseUrl = options.baseUrl || 'https://api.openai.com/v1';
11
+ }
12
+
13
+ /**
14
+ * Generate a response from the OpenAI API.
15
+ * @param {Array<object>} messages - Conversation messages.
16
+ * @param {Array<object>} [tools=[]] - Tool definitions.
17
+ * @param {object} [options={}] - Options (temperature, maxTokens).
18
+ * @returns {Promise<{text: string, toolCalls: Array, usage: object}>}
19
+ * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
20
+ */
21
+ async generate(messages, tools = [], options = {}) {
22
+ const body = {
23
+ model: this.model,
24
+ messages,
25
+ ...(options.temperature != null && { temperature: options.temperature }),
26
+ ...(options.maxTokens && { max_tokens: options.maxTokens }),
27
+ };
28
+ if (tools.length > 0) {
29
+ body.tools = tools.map(t => ({
30
+ type: 'function',
31
+ function: { name: t.name, description: t.description, parameters: t.parameters },
32
+ }));
33
+ }
34
+
35
+ const data = await this._request('/chat/completions', body);
36
+ const choice = data.choices[0];
37
+ const msg = choice.message;
38
+
39
+ return {
40
+ text: msg.content || '',
41
+ toolCalls: (msg.tool_calls || []).map(tc => ({
42
+ id: tc.id,
43
+ name: tc.function.name,
44
+ arguments: JSON.parse(tc.function.arguments),
45
+ })),
46
+ usage: {
47
+ inputTokens: data.usage?.prompt_tokens || 0,
48
+ outputTokens: data.usage?.completion_tokens || 0,
49
+ },
50
+ };
51
+ }
52
+
53
+ _request(path, body) {
54
+ return new Promise((resolve, reject) => {
55
+ const url = new URL(this.baseUrl + path);
56
+ const transport = url.protocol === 'https:' ? https : http;
57
+ const payload = JSON.stringify(body);
58
+
59
+ const req = transport.request(url, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ 'Content-Length': Buffer.byteLength(payload),
64
+ ...(this.apiKey && { 'Authorization': `Bearer ${this.apiKey}` }),
65
+ },
66
+ }, (res) => {
67
+ let chunks = '';
68
+ res.on('data', d => chunks += d);
69
+ res.on('end', () => {
70
+ try {
71
+ const parsed = JSON.parse(chunks);
72
+ if (res.statusCode >= 400) {
73
+ const err = new Error(`[OpenAIProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`);
74
+ err.status = res.statusCode;
75
+ err.body = parsed;
76
+ return reject(err);
77
+ }
78
+ resolve(parsed);
79
+ } catch (e) {
80
+ reject(new Error(`[OpenAIProvider] Invalid JSON response: ${chunks.slice(0, 200)}`));
81
+ }
82
+ });
83
+ });
84
+ req.on('error', reject);
85
+ req.write(payload);
86
+ req.end();
87
+ });
88
+ }
89
+ }
90
+
91
+ module.exports = { OpenAIProvider };
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const { OpenAIProvider } = require('./provider-openai');
4
+ const { AnthropicProvider } = require('./provider-anthropic');
5
+ const { OllamaProvider } = require('./provider-ollama');
6
+
7
+ module.exports = {
8
+ OpenAI: OpenAIProvider,
9
+ Anthropic: AnthropicProvider,
10
+ Ollama: OllamaProvider,
11
+ };
package/src/retry.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_RETRY_ON = (err) => {
4
+ const status = err.status || err.statusCode;
5
+ if (status === 429 || (status >= 500 && status <= 504)) return true;
6
+ const code = err.code;
7
+ if (code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'ENOTFOUND') return true;
8
+ return false;
9
+ };
10
+
11
+ class Retry {
12
+ constructor(options = {}) {
13
+ this.maxAttempts = options.maxAttempts || 3;
14
+ this.backoff = options.backoff || 'exponential';
15
+ this.timeout = options.timeout || 60000;
16
+ this.retryOn = options.retryOn || DEFAULT_RETRY_ON;
17
+ }
18
+
19
+ /**
20
+ * Call a function with retry logic.
21
+ * @param {() => Promise<*>} fn - Async function to execute.
22
+ * @param {object} [options={}] - Per-call overrides for maxAttempts, retryOn, timeout.
23
+ * @returns {Promise<*>} The result of fn().
24
+ * @throws {Error} `[Retry] Timeout` — when an individual attempt exceeds the timeout.
25
+ * @throws {Error} Rethrows the last error when maxAttempts is exhausted or error is not retryable.
26
+ */
27
+ async call(fn, options = {}) {
28
+ const max = options.maxAttempts || this.maxAttempts;
29
+ const retryOn = options.retryOn || this.retryOn;
30
+ const timeout = options.timeout || this.timeout;
31
+
32
+ for (let attempt = 1; attempt <= max; attempt++) {
33
+ try {
34
+ const result = await (timeout
35
+ ? Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(Object.assign(new Error('[Retry] Timeout'), { code: 'ETIMEDOUT' })), timeout))])
36
+ : fn());
37
+ return result;
38
+ } catch (err) {
39
+ if (attempt === max || !retryOn(err)) throw err;
40
+ const delay = this._delay(attempt);
41
+ await new Promise(r => setTimeout(r, delay));
42
+ }
43
+ }
44
+ }
45
+
46
+ _delay(attempt) {
47
+ if (typeof this.backoff === 'number') return this.backoff;
48
+ if (this.backoff === 'linear') return attempt * 1000;
49
+ return Math.min(2 ** (attempt - 1) * 1000, 30000); // exponential, cap 30s
50
+ }
51
+ }
52
+
53
+ module.exports = { Retry };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync, writeFileSync, existsSync } = require('node:fs');
4
+
5
+ /**
6
+ * Time-triggered agent turns. The only way the agent acts without being messaged.
7
+ *
8
+ * Interface:
9
+ * add(job) → jobId
10
+ * remove(jobId) → void
11
+ * list() → [jobs]
12
+ * start(handler) → begin tick loop (handler receives due jobs)
13
+ * stop() → stop tick loop
14
+ */
15
+ class Scheduler {
16
+ constructor(options = {}) {
17
+ this._file = options.file || null;
18
+ this._interval = options.interval || 60000;
19
+ this.onError = options.onError || null;
20
+ this._jobs = this._file && existsSync(this._file)
21
+ ? JSON.parse(readFileSync(this._file, 'utf8'))
22
+ : [];
23
+ this._timer = null;
24
+ this._nextId = this._jobs.length
25
+ ? Math.max(...this._jobs.map(j => j.id)) + 1
26
+ : 1;
27
+ }
28
+
29
+ _save() {
30
+ if (this._file) writeFileSync(this._file, JSON.stringify(this._jobs, null, 2));
31
+ }
32
+
33
+ add(job) {
34
+ const id = this._nextId++;
35
+ const nextRun = this._parseSchedule(job.schedule);
36
+ this._jobs.push({
37
+ id,
38
+ type: job.type || 'once',
39
+ schedule: job.schedule,
40
+ action: job.action,
41
+ status: 'active',
42
+ nextRun: nextRun.toISOString(),
43
+ createdAt: new Date().toISOString(),
44
+ });
45
+ this._save();
46
+ return id;
47
+ }
48
+
49
+ remove(jobId) {
50
+ this._jobs = this._jobs.filter(j => j.id !== jobId);
51
+ this._save();
52
+ }
53
+
54
+ list() {
55
+ return this._jobs.map(j => ({ ...j }));
56
+ }
57
+
58
+ /**
59
+ * Begin the tick loop. Calls `handler(job)` for each due job every tick interval.
60
+ *
61
+ * - `handler` is called with the full job object: `{ id, type, schedule, action, status, nextRun }`.
62
+ * - Jobs that are still running (handler hasn't resolved) are skipped on subsequent ticks
63
+ * via the internal `_running` Set — this prevents overlapping executions of the same job.
64
+ * - Within a single tick, due jobs are executed sequentially (awaited one at a time).
65
+ * - If a handler throws, the error is passed to `onError(err, job)` if configured.
66
+ * The tick loop continues to the next job — handler errors never crash the scheduler.
67
+ *
68
+ * @param {(job: object) => Promise<void>} handler - Async function called for each due job.
69
+ */
70
+ start(handler) {
71
+ if (this._timer) return;
72
+ this._running = new Set();
73
+ const tick = async () => {
74
+ const now = new Date();
75
+ for (const job of this._jobs) {
76
+ if (job.status !== 'active') continue;
77
+ if (this._running.has(job.id)) continue;
78
+ if (new Date(job.nextRun) > now) continue;
79
+ this._running.add(job.id);
80
+ try {
81
+ await handler(job);
82
+ } catch (err) {
83
+ this.onError?.(err, job);
84
+ }
85
+ this._running.delete(job.id);
86
+ if (job.type === 'once') {
87
+ job.status = 'done';
88
+ } else {
89
+ job.nextRun = this._parseSchedule(job.schedule).toISOString();
90
+ }
91
+ this._save();
92
+ }
93
+ };
94
+ tick();
95
+ this._timer = setInterval(tick, this._interval);
96
+ }
97
+
98
+ stop() {
99
+ if (this._timer) {
100
+ clearInterval(this._timer);
101
+ this._timer = null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @param {string} schedule - Relative ('5s','30m','2h','1d') or cron expression.
107
+ * @returns {Date} The next run time.
108
+ * @throws {Error} `[Scheduler] Cannot parse schedule` — when format is not recognized.
109
+ * @private
110
+ */
111
+ _parseSchedule(schedule) {
112
+ // Relative: '5s', '30m', '2h', '1d'
113
+ const rel = schedule.match(/^(\d+)(s|m|h|d)$/);
114
+ if (rel) {
115
+ const [, n, unit] = rel;
116
+ const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[unit];
117
+ return new Date(Date.now() + Number(n) * ms);
118
+ }
119
+ // Cron: try cron-parser
120
+ try {
121
+ const { parseExpression } = require('cron-parser');
122
+ return parseExpression(schedule).next().toDate();
123
+ } catch {
124
+ throw new Error(`[Scheduler] Cannot parse schedule: "${schedule}". Use relative (5s/30m/2h/1d) or cron expression.`);
125
+ }
126
+ }
127
+ }
128
+
129
+ module.exports = { Scheduler };
package/src/state.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync, writeFileSync } = require('fs');
4
+ const { EventEmitter } = require('events');
5
+
6
+ const TRANSITIONS = {
7
+ pending: { start: 'running', cancel: 'cancelled' },
8
+ running: { complete: 'done', fail: 'failed', pause: 'waiting_for_input', cancel: 'cancelled' },
9
+ failed: { retry: 'running', cancel: 'cancelled' },
10
+ waiting_for_input: { resume: 'running', cancel: 'cancelled' },
11
+ // done and cancelled are terminal
12
+ };
13
+
14
+ class StateMachine extends EventEmitter {
15
+ constructor(options = {}) {
16
+ super();
17
+ this.file = options.file || null;
18
+ this.tasks = new Map();
19
+ if (this.file) this._load();
20
+ }
21
+
22
+ /**
23
+ * Transition a task to a new state.
24
+ * @param {string} taskId - Task identifier.
25
+ * @param {string} event - Transition event (start, complete, fail, pause, resume, cancel, retry).
26
+ * @param {*} [data] - Optional data to attach to the task.
27
+ * @returns {string} The new status.
28
+ * @throws {Error} `[StateMachine] Invalid transition` — when the event is not valid for the current state.
29
+ */
30
+ transition(taskId, event, data) {
31
+ let task = this.tasks.get(taskId);
32
+ if (!task) {
33
+ task = { status: 'pending', data: null, error: null, updatedAt: new Date().toISOString() };
34
+ this.tasks.set(taskId, task);
35
+ }
36
+
37
+ const allowed = TRANSITIONS[task.status];
38
+ if (!allowed || !allowed[event]) {
39
+ throw new Error(`[StateMachine] Invalid transition: ${task.status} + ${event} (task: ${taskId})`);
40
+ }
41
+
42
+ const prev = task.status;
43
+ task.status = allowed[event];
44
+ task.updatedAt = new Date().toISOString();
45
+ if (data !== undefined) task.data = data;
46
+ if (event === 'fail') task.error = data || null;
47
+ if (event === 'complete') task.error = null;
48
+
49
+ this.emit('transition', { taskId, from: prev, to: task.status, event, data });
50
+ if (this.file) this._save();
51
+ return task.status;
52
+ }
53
+
54
+ getStatus(taskId) {
55
+ return this.tasks.get(taskId) || null;
56
+ }
57
+
58
+ onTransition(callback) {
59
+ this.on('transition', callback);
60
+ return () => this.off('transition', callback);
61
+ }
62
+
63
+ getAll() {
64
+ const result = {};
65
+ for (const [id, task] of this.tasks) result[id] = { ...task };
66
+ return result;
67
+ }
68
+
69
+ _load() {
70
+ try {
71
+ const raw = readFileSync(this.file, 'utf8');
72
+ const data = JSON.parse(raw);
73
+ for (const [id, task] of Object.entries(data)) this.tasks.set(id, task);
74
+ } catch (e) {
75
+ if (e.code !== 'ENOENT') throw e;
76
+ }
77
+ }
78
+
79
+ _save() {
80
+ const obj = {};
81
+ for (const [id, task] of this.tasks) obj[id] = task;
82
+ writeFileSync(this.file, JSON.stringify(obj, null, 2) + '\n');
83
+ }
84
+ }
85
+
86
+ module.exports = { StateMachine };