@gravito/flux 1.0.0-alpha.6 → 1.0.0-beta.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/README.zh-TW.md +220 -1
- package/bin/flux.js +96 -0
- package/dev/viewer/app.js +131 -0
- package/dev/viewer/index.html +52 -0
- package/dev/viewer/styles.css +163 -0
- package/package.json +12 -4
- package/dist/node/index.cjs +0 -651
package/README.zh-TW.md
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
# @gravito/flux
|
|
2
2
|
|
|
3
|
-
> Gravito
|
|
3
|
+
> Gravito 的高效能工作流程引擎,跨平台、型別安全的狀態機,強調可追蹤、可重播與可靠重試。
|
|
4
|
+
|
|
5
|
+
## 核心特色
|
|
6
|
+
|
|
7
|
+
- **純狀態機模型** - 以明確狀態描述流程,讓每一步清晰可控
|
|
8
|
+
- **鏈式 Builder API** - 型別安全的流程定義方式
|
|
9
|
+
- **儲存介面** - Memory、Bun SQLite,其他儲存可自訂
|
|
10
|
+
- **重試與逾時** - Step 可設定 retries/timeout/when,面對不穩定依賴也能自動恢復
|
|
11
|
+
- **同步/非同步** - 同時支援同步流程與非同步長流程
|
|
12
|
+
- **事件 Hooks** - 監聽流程與步驟生命週期
|
|
13
|
+
- **事件 Hooks** - 監聽流程與步驟生命周期
|
|
14
|
+
- **雙平台支援** - Bun 與 Node.js 均可使用
|
|
4
15
|
|
|
5
16
|
## 安裝
|
|
6
17
|
|
|
7
18
|
```bash
|
|
8
19
|
bun add @gravito/flux
|
|
20
|
+
|
|
21
|
+
# npm
|
|
22
|
+
npm install @gravito/flux
|
|
9
23
|
```
|
|
10
24
|
|
|
11
25
|
## 快速開始
|
|
@@ -28,3 +42,208 @@ const orderFlow = createWorkflow('order-process')
|
|
|
28
42
|
const engine = new FluxEngine()
|
|
29
43
|
const result = await engine.execute(orderFlow, { orderId: '123' })
|
|
30
44
|
```
|
|
45
|
+
|
|
46
|
+
## 範例
|
|
47
|
+
|
|
48
|
+
### 訂單履約
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
const orderWorkflow = createWorkflow('order-fulfillment')
|
|
52
|
+
.input<{ orderId: string; items: Item[] }>()
|
|
53
|
+
.step('validate', async (ctx) => {
|
|
54
|
+
for (const item of ctx.input.items) {
|
|
55
|
+
const stock = await db.products.getStock(item.productId)
|
|
56
|
+
if (stock < item.qty) throw new Error(`Out of stock: ${item.productId}`)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.step(
|
|
60
|
+
'payment',
|
|
61
|
+
async (ctx) => {
|
|
62
|
+
ctx.data.payment = await payment.charge(ctx.input.orderId)
|
|
63
|
+
},
|
|
64
|
+
{ retries: 3, timeout: 30_000 }
|
|
65
|
+
)
|
|
66
|
+
.commit('deduct', async (ctx) => {
|
|
67
|
+
await inventory.deduct(ctx.data.reservationIds)
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 影像處理
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const uploadWorkflow = createWorkflow('image-processing')
|
|
75
|
+
.input<{ fileBuffer: Buffer; fileName: string; userId: string }>()
|
|
76
|
+
.step('validate', async (ctx) => {
|
|
77
|
+
if (ctx.input.fileBuffer.length > 10 * 1024 * 1024) {
|
|
78
|
+
throw new Error('File size exceeds 10MB')
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.step('resize', async (ctx) => {
|
|
82
|
+
ctx.data.thumbnail = await sharp(ctx.input.fileBuffer).resize(200).toBuffer()
|
|
83
|
+
})
|
|
84
|
+
.commit('upload', async (ctx) => {
|
|
85
|
+
ctx.data.url = await s3.upload(ctx.input.fileName, ctx.data.thumbnail)
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 報表生成
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const reportWorkflow = createWorkflow('generate-report')
|
|
93
|
+
.input<{ reportType: string; dateRange: DateRange; requestedBy: string }>()
|
|
94
|
+
.step('fetch-data', async (ctx) => {
|
|
95
|
+
ctx.data.sales = await db.orders.aggregate(ctx.input.dateRange)
|
|
96
|
+
}, { timeout: 60_000 })
|
|
97
|
+
.step('calculate', async (ctx) => {
|
|
98
|
+
ctx.data.metrics = { revenue: 0, orders: 0 }
|
|
99
|
+
})
|
|
100
|
+
.commit('upload', async (ctx) => {
|
|
101
|
+
ctx.data.url = await s3.upload(`reports/${ctx.id}.pdf`, ctx.data.pdf)
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
### `createWorkflow(name)`
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
const flow = createWorkflow('my-workflow')
|
|
111
|
+
.input<{ value: number }>()
|
|
112
|
+
.step('step1', handler)
|
|
113
|
+
.step('step2', handler, { retries: 3 })
|
|
114
|
+
.commit('save', handler)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `FluxEngine`
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
const engine = new FluxEngine({
|
|
121
|
+
storage: new MemoryStorage(),
|
|
122
|
+
defaultRetries: 3,
|
|
123
|
+
defaultTimeout: 30_000,
|
|
124
|
+
logger: new FluxConsoleLogger(),
|
|
125
|
+
on: {
|
|
126
|
+
stepStart: (step, ctx) => {},
|
|
127
|
+
stepComplete: (step, ctx, result) => {},
|
|
128
|
+
stepError: (step, ctx, error) => {},
|
|
129
|
+
workflowComplete: (ctx) => {},
|
|
130
|
+
workflowError: (ctx, error) => {},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Step Options
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
.step('name', handler, {
|
|
139
|
+
retries: 5,
|
|
140
|
+
timeout: 60_000,
|
|
141
|
+
when: (ctx) => ctx.data.x > 0,
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 分支流程(多支點)
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const flow = createWorkflow('event-routing')
|
|
149
|
+
.input<{ payload: EventPayload }>()
|
|
150
|
+
.step('classify', async (ctx) => {
|
|
151
|
+
ctx.data.route = classify(ctx.input.payload)
|
|
152
|
+
})
|
|
153
|
+
.step(
|
|
154
|
+
'auto-handle',
|
|
155
|
+
async (ctx) => {
|
|
156
|
+
ctx.data.result = await autoProcess(ctx.input.payload)
|
|
157
|
+
},
|
|
158
|
+
{ when: (ctx) => ctx.data.route === 'auto' }
|
|
159
|
+
)
|
|
160
|
+
.step(
|
|
161
|
+
'manual-review',
|
|
162
|
+
async (ctx) => {
|
|
163
|
+
ctx.data.ticketId = await ticketing.create(ctx.input.payload)
|
|
164
|
+
},
|
|
165
|
+
{ when: (ctx) => ctx.data.route === 'manual' }
|
|
166
|
+
)
|
|
167
|
+
.step(
|
|
168
|
+
'risk-audit',
|
|
169
|
+
async (ctx) => {
|
|
170
|
+
ctx.data.auditId = await auditQueue.enqueue(ctx.input.payload)
|
|
171
|
+
},
|
|
172
|
+
{ when: (ctx) => ctx.data.route === 'risk' }
|
|
173
|
+
)
|
|
174
|
+
.commit('notify', async (ctx) => {
|
|
175
|
+
await notifier.send(ctx.data)
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## 本地開發視覺化
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { FluxEngine, JsonFileTraceSink } from '@gravito/flux'
|
|
183
|
+
|
|
184
|
+
const engine = new FluxEngine({
|
|
185
|
+
trace: new JsonFileTraceSink({ path: './.flux/trace.ndjson', reset: true }),
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
flux dev --trace ./.flux/trace.ndjson --port 4280
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 驗證流程(本 repo)
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
bun run examples/trace-viewer.ts
|
|
197
|
+
bun run dev:viewer
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## 企業級追蹤
|
|
201
|
+
|
|
202
|
+
透過 `FluxTraceSink` 可以把事件流送到你自己的監控、排程或分析模組,建立完整的執行查詢、重播與告警能力。
|
|
203
|
+
|
|
204
|
+
## 儲存介面
|
|
205
|
+
|
|
206
|
+
### MemoryStorage
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { FluxEngine, MemoryStorage } from '@gravito/flux'
|
|
210
|
+
const engine = new FluxEngine({ storage: new MemoryStorage() })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### BunSQLiteStorage (Bun only)
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { FluxEngine } from '@gravito/flux'
|
|
217
|
+
import { BunSQLiteStorage } from '@gravito/flux/bun'
|
|
218
|
+
|
|
219
|
+
const engine = new FluxEngine({
|
|
220
|
+
storage: new BunSQLiteStorage({ path: './data/workflows.db' })
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Gravito 整合
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { OrbitFlux } from '@gravito/flux'
|
|
228
|
+
|
|
229
|
+
const core = await PlanetCore.boot({
|
|
230
|
+
orbits: [
|
|
231
|
+
new OrbitFlux({
|
|
232
|
+
storage: 'sqlite',
|
|
233
|
+
dbPath: './data/workflows.db',
|
|
234
|
+
})
|
|
235
|
+
]
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const flux = core.services.get<FluxEngine>('flux')
|
|
239
|
+
await flux.execute(myWorkflow, input)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## 平台支援
|
|
243
|
+
|
|
244
|
+
| Feature | Bun | Node.js |
|
|
245
|
+
|---------|-----|---------|
|
|
246
|
+
| FluxEngine | ✅ | ✅ |
|
|
247
|
+
| MemoryStorage | ✅ | ✅ |
|
|
248
|
+
| BunSQLiteStorage | ✅ | ❌ |
|
|
249
|
+
| OrbitFlux | ✅ | ✅ |
|
package/bin/flux.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2)
|
|
8
|
+
const command = args[0]
|
|
9
|
+
|
|
10
|
+
const help = () => {
|
|
11
|
+
console.log(`Flux CLI
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
flux dev --trace ./.flux/trace.ndjson --port 4280
|
|
15
|
+
`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!command || command === '--help' || command === '-h') {
|
|
19
|
+
help()
|
|
20
|
+
process.exit(0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (command !== 'dev') {
|
|
24
|
+
console.error(`Unknown command: ${command}`)
|
|
25
|
+
help()
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const getArg = (name, fallback) => {
|
|
30
|
+
const idx = args.indexOf(name)
|
|
31
|
+
if (idx === -1) return fallback
|
|
32
|
+
return args[idx + 1] ?? fallback
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const port = Number(getArg('--port', '4280'))
|
|
36
|
+
const host = getArg('--host', '127.0.0.1')
|
|
37
|
+
const tracePath = resolve(getArg('--trace', './.flux/trace.ndjson'))
|
|
38
|
+
|
|
39
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
40
|
+
const __dirname = dirname(__filename)
|
|
41
|
+
const viewerRoot = resolve(join(__dirname, '..', 'dev', 'viewer'))
|
|
42
|
+
|
|
43
|
+
const contentType = (filePath) => {
|
|
44
|
+
const ext = extname(filePath)
|
|
45
|
+
switch (ext) {
|
|
46
|
+
case '.html':
|
|
47
|
+
return 'text/html; charset=utf-8'
|
|
48
|
+
case '.js':
|
|
49
|
+
return 'text/javascript; charset=utf-8'
|
|
50
|
+
case '.css':
|
|
51
|
+
return 'text/css; charset=utf-8'
|
|
52
|
+
case '.json':
|
|
53
|
+
return 'application/json; charset=utf-8'
|
|
54
|
+
case '.svg':
|
|
55
|
+
return 'image/svg+xml'
|
|
56
|
+
default:
|
|
57
|
+
return 'text/plain; charset=utf-8'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const server = createServer(async (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(req.url ?? '/', `http://${host}:${port}`)
|
|
64
|
+
if (url.pathname === '/trace') {
|
|
65
|
+
try {
|
|
66
|
+
const data = await readFile(tracePath, 'utf8')
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
69
|
+
'cache-control': 'no-store',
|
|
70
|
+
})
|
|
71
|
+
res.end(data)
|
|
72
|
+
} catch {
|
|
73
|
+
res.writeHead(204, { 'cache-control': 'no-store' })
|
|
74
|
+
res.end('')
|
|
75
|
+
}
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const reqPath = url.pathname === '/' ? '/index.html' : url.pathname
|
|
80
|
+
const filePath = join(viewerRoot, reqPath)
|
|
81
|
+
const data = await readFile(filePath)
|
|
82
|
+
res.writeHead(200, {
|
|
83
|
+
'content-type': contentType(filePath),
|
|
84
|
+
'cache-control': 'no-store',
|
|
85
|
+
})
|
|
86
|
+
res.end(data)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
89
|
+
res.end('Not Found')
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
server.listen(port, host, () => {
|
|
94
|
+
console.log(`Flux dev viewer running at http://${host}:${port}`)
|
|
95
|
+
console.log(`Trace file: ${tracePath}`)
|
|
96
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const pathEl = document.getElementById('tracePath')
|
|
2
|
+
const updateEl = document.getElementById('lastUpdate')
|
|
3
|
+
const pathElContainer = document.getElementById('path')
|
|
4
|
+
const statusEl = document.getElementById('status')
|
|
5
|
+
const eventsEl = document.getElementById('events')
|
|
6
|
+
|
|
7
|
+
const state = {
|
|
8
|
+
events: [],
|
|
9
|
+
lastHash: '',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const formatTime = (ts) => new Date(ts).toLocaleTimeString()
|
|
13
|
+
|
|
14
|
+
const parseNdjson = (text) =>
|
|
15
|
+
text
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map((line) => line.trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((line) => {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(line)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
|
|
28
|
+
const hashText = (text) => {
|
|
29
|
+
let hash = 0
|
|
30
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
31
|
+
hash = (hash << 5) - hash + text.charCodeAt(i)
|
|
32
|
+
hash |= 0
|
|
33
|
+
}
|
|
34
|
+
return String(hash)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const renderPath = (events) => {
|
|
38
|
+
const steps = []
|
|
39
|
+
const seen = new Set()
|
|
40
|
+
for (const event of events) {
|
|
41
|
+
if (event.stepName && event.type.startsWith('step:')) {
|
|
42
|
+
if (!seen.has(event.stepName)) {
|
|
43
|
+
seen.add(event.stepName)
|
|
44
|
+
steps.push(event.stepName)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!steps.length) {
|
|
50
|
+
pathElContainer.innerHTML = '<div class="empty">No execution data yet.</div>'
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pathElContainer.innerHTML = steps
|
|
55
|
+
.map((step, index) => {
|
|
56
|
+
const node = `<span class="node">${step}</span>`
|
|
57
|
+
const arrow = index < steps.length - 1 ? '<span class="arrow">-></span>' : ''
|
|
58
|
+
return `${node}${arrow}`
|
|
59
|
+
})
|
|
60
|
+
.join('')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const renderStatus = (events) => {
|
|
64
|
+
if (!events.length) {
|
|
65
|
+
statusEl.innerHTML = '<div class="empty">No trace events.</div>'
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lastWorkflow = [...events]
|
|
70
|
+
.reverse()
|
|
71
|
+
.find((event) => event.type.startsWith('workflow:'))
|
|
72
|
+
|
|
73
|
+
const status = lastWorkflow?.status ?? 'unknown'
|
|
74
|
+
const badgeClass = status === 'completed' ? 'ok' : status === 'failed' ? 'fail' : ''
|
|
75
|
+
|
|
76
|
+
statusEl.innerHTML = `
|
|
77
|
+
<div>Workflow: <span class="badge ${badgeClass}">${status}</span></div>
|
|
78
|
+
<div>Workflow ID: ${lastWorkflow?.workflowId ?? '-'}</div>
|
|
79
|
+
<div>Workflow Name: ${lastWorkflow?.workflowName ?? '-'}</div>
|
|
80
|
+
<div>Duration: ${lastWorkflow?.duration ? `${lastWorkflow.duration}ms` : '-'}</div>
|
|
81
|
+
`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const renderEvents = (events) => {
|
|
85
|
+
if (!events.length) {
|
|
86
|
+
eventsEl.innerHTML = '<tr><td colspan="6" class="empty">No events yet.</td></tr>'
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
eventsEl.innerHTML = events
|
|
91
|
+
.slice()
|
|
92
|
+
.reverse()
|
|
93
|
+
.map((event) => {
|
|
94
|
+
const retry =
|
|
95
|
+
typeof event.retries === 'number'
|
|
96
|
+
? `${event.retries}${event.maxRetries ? `/${event.maxRetries}` : ''}`
|
|
97
|
+
: '-'
|
|
98
|
+
const duration = event.duration ? `${event.duration}ms` : '-'
|
|
99
|
+
const msg = event.error ?? ''
|
|
100
|
+
return `
|
|
101
|
+
<tr>
|
|
102
|
+
<td>${formatTime(event.timestamp)}</td>
|
|
103
|
+
<td>${event.type}</td>
|
|
104
|
+
<td>${event.stepName ?? '-'}</td>
|
|
105
|
+
<td>${retry}</td>
|
|
106
|
+
<td>${duration}</td>
|
|
107
|
+
<td>${msg}</td>
|
|
108
|
+
</tr>
|
|
109
|
+
`
|
|
110
|
+
})
|
|
111
|
+
.join('')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const refresh = async () => {
|
|
115
|
+
const res = await fetch('/trace', { cache: 'no-store' })
|
|
116
|
+
const text = await res.text()
|
|
117
|
+
const hash = hashText(text)
|
|
118
|
+
if (hash === state.lastHash) return
|
|
119
|
+
|
|
120
|
+
state.lastHash = hash
|
|
121
|
+
state.events = parseNdjson(text)
|
|
122
|
+
pathEl.textContent = 'Trace: /trace'
|
|
123
|
+
updateEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`
|
|
124
|
+
|
|
125
|
+
renderPath(state.events)
|
|
126
|
+
renderStatus(state.events)
|
|
127
|
+
renderEvents(state.events)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
refresh()
|
|
131
|
+
setInterval(refresh, 1500)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>Flux Dev Viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="topbar">
|
|
11
|
+
<div>
|
|
12
|
+
<h1>Flux Dev Viewer</h1>
|
|
13
|
+
<p class="subtitle">Local workflow trace explorer</p>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="meta">
|
|
16
|
+
<div id="tracePath">Trace: -</div>
|
|
17
|
+
<div id="lastUpdate">Updated: -</div>
|
|
18
|
+
</div>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<main class="grid">
|
|
22
|
+
<section class="card">
|
|
23
|
+
<h2>Execution Path</h2>
|
|
24
|
+
<div id="path" class="path"></div>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<section class="card">
|
|
28
|
+
<h2>Status</h2>
|
|
29
|
+
<div id="status" class="status"></div>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<section class="card full">
|
|
33
|
+
<h2>Timeline</h2>
|
|
34
|
+
<table class="timeline">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr>
|
|
37
|
+
<th>Time</th>
|
|
38
|
+
<th>Event</th>
|
|
39
|
+
<th>Step</th>
|
|
40
|
+
<th>Retry</th>
|
|
41
|
+
<th>Duration</th>
|
|
42
|
+
<th>Message</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody id="events"></tbody>
|
|
46
|
+
</table>
|
|
47
|
+
</section>
|
|
48
|
+
</main>
|
|
49
|
+
|
|
50
|
+
<script src="/app.js"></script>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
body {
|
|
6
|
+
margin: 0;
|
|
7
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
8
|
+
background: #0c0f14;
|
|
9
|
+
color: #e6eaf2;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.topbar {
|
|
13
|
+
display: flex;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
align-items: center;
|
|
16
|
+
padding: 20px 32px;
|
|
17
|
+
border-bottom: 1px solid #1d2330;
|
|
18
|
+
background: linear-gradient(120deg, #111826, #0f1420);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.topbar h1 {
|
|
22
|
+
margin: 0 0 6px;
|
|
23
|
+
font-size: 20px;
|
|
24
|
+
letter-spacing: 0.04em;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.subtitle {
|
|
28
|
+
margin: 0;
|
|
29
|
+
color: #93a0b4;
|
|
30
|
+
font-size: 13px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.meta {
|
|
34
|
+
text-align: right;
|
|
35
|
+
font-size: 12px;
|
|
36
|
+
color: #9aa6ba;
|
|
37
|
+
line-height: 1.6;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.grid {
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: 18px;
|
|
43
|
+
padding: 24px 32px 40px;
|
|
44
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.card {
|
|
48
|
+
background: #131a26;
|
|
49
|
+
border: 1px solid #1d2330;
|
|
50
|
+
border-radius: 14px;
|
|
51
|
+
padding: 18px 18px 12px;
|
|
52
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.card.full {
|
|
56
|
+
grid-column: 1 / -1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.card h2 {
|
|
60
|
+
margin: 0 0 14px;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
color: #c7d1e6;
|
|
63
|
+
letter-spacing: 0.08em;
|
|
64
|
+
text-transform: uppercase;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.path {
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-wrap: wrap;
|
|
70
|
+
gap: 10px;
|
|
71
|
+
align-items: center;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.node {
|
|
75
|
+
padding: 8px 12px;
|
|
76
|
+
border-radius: 999px;
|
|
77
|
+
background: #1a2333;
|
|
78
|
+
border: 1px solid #2b3a52;
|
|
79
|
+
font-size: 12px;
|
|
80
|
+
color: #d6deec;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.arrow {
|
|
84
|
+
color: #7c8da7;
|
|
85
|
+
font-size: 14px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.status {
|
|
89
|
+
display: grid;
|
|
90
|
+
gap: 8px;
|
|
91
|
+
font-size: 13px;
|
|
92
|
+
color: #b9c5d9;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.badge {
|
|
96
|
+
display: inline-block;
|
|
97
|
+
padding: 2px 8px;
|
|
98
|
+
border-radius: 999px;
|
|
99
|
+
font-size: 11px;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
letter-spacing: 0.08em;
|
|
102
|
+
background: #1e2532;
|
|
103
|
+
color: #9db0cc;
|
|
104
|
+
border: 1px solid #2b364a;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.badge.ok {
|
|
108
|
+
background: rgba(34, 197, 94, 0.15);
|
|
109
|
+
color: #7ee4a6;
|
|
110
|
+
border-color: rgba(34, 197, 94, 0.3);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.badge.fail {
|
|
114
|
+
background: rgba(248, 113, 113, 0.15);
|
|
115
|
+
color: #fca5a5;
|
|
116
|
+
border-color: rgba(248, 113, 113, 0.3);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.timeline {
|
|
120
|
+
width: 100%;
|
|
121
|
+
border-collapse: collapse;
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.timeline th,
|
|
126
|
+
.timeline td {
|
|
127
|
+
padding: 10px 8px;
|
|
128
|
+
border-bottom: 1px solid #1d2330;
|
|
129
|
+
text-align: left;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.timeline th {
|
|
133
|
+
color: #91a0b7;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
font-size: 11px;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
letter-spacing: 0.08em;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.timeline tr:last-child td {
|
|
141
|
+
border-bottom: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.empty {
|
|
145
|
+
color: #7f8ba3;
|
|
146
|
+
font-size: 12px;
|
|
147
|
+
padding: 10px 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@media (max-width: 900px) {
|
|
151
|
+
.grid {
|
|
152
|
+
grid-template-columns: 1fr;
|
|
153
|
+
}
|
|
154
|
+
.meta {
|
|
155
|
+
text-align: left;
|
|
156
|
+
margin-top: 10px;
|
|
157
|
+
}
|
|
158
|
+
.topbar {
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
align-items: flex-start;
|
|
161
|
+
gap: 12px;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/flux",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
4
|
"description": "Platform-agnostic workflow engine for Gravito",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/node/index.cjs",
|
|
@@ -28,17 +28,24 @@
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"flux": "./bin/flux.js"
|
|
33
|
+
},
|
|
31
34
|
"files": [
|
|
32
35
|
"dist",
|
|
33
|
-
"README.md"
|
|
36
|
+
"README.md",
|
|
37
|
+
"README.zh-TW.md",
|
|
38
|
+
"bin",
|
|
39
|
+
"dev"
|
|
34
40
|
],
|
|
35
41
|
"scripts": {
|
|
36
42
|
"build": "bun run build.ts",
|
|
43
|
+
"dev:viewer": "node ./bin/flux.js dev --trace ./.flux/trace.ndjson --port 4280",
|
|
37
44
|
"typecheck": "tsc --noEmit",
|
|
38
45
|
"test": "bun test"
|
|
39
46
|
},
|
|
40
47
|
"peerDependencies": {
|
|
41
|
-
"gravito-core": "1.0.0-beta.
|
|
48
|
+
"gravito-core": "1.0.0-beta.6"
|
|
42
49
|
},
|
|
43
50
|
"peerDependenciesMeta": {
|
|
44
51
|
"gravito-core": {
|
|
@@ -47,7 +54,8 @@
|
|
|
47
54
|
},
|
|
48
55
|
"devDependencies": {
|
|
49
56
|
"bun-types": "latest",
|
|
50
|
-
"gravito-core": "1.0.0-beta.
|
|
57
|
+
"gravito-core": "1.0.0-beta.6",
|
|
58
|
+
"tsup": "^8.5.1",
|
|
51
59
|
"typescript": "^5.0.0"
|
|
52
60
|
},
|
|
53
61
|
"keywords": [
|
package/dist/node/index.cjs
DELETED
|
@@ -1,651 +0,0 @@
|
|
|
1
|
-
var __defProp = Object.defineProperty;
|
|
2
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
-
var __moduleCache = /* @__PURE__ */ new WeakMap;
|
|
6
|
-
var __toCommonJS = (from) => {
|
|
7
|
-
var entry = __moduleCache.get(from), desc;
|
|
8
|
-
if (entry)
|
|
9
|
-
return entry;
|
|
10
|
-
entry = __defProp({}, "__esModule", { value: true });
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function")
|
|
12
|
-
__getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
|
|
13
|
-
get: () => from[key],
|
|
14
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
-
}));
|
|
16
|
-
__moduleCache.set(from, entry);
|
|
17
|
-
return entry;
|
|
18
|
-
};
|
|
19
|
-
var __export = (target, all) => {
|
|
20
|
-
for (var name in all)
|
|
21
|
-
__defProp(target, name, {
|
|
22
|
-
get: all[name],
|
|
23
|
-
enumerable: true,
|
|
24
|
-
configurable: true,
|
|
25
|
-
set: (newValue) => all[name] = () => newValue
|
|
26
|
-
});
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// src/index.node.ts
|
|
30
|
-
var exports_index_node = {};
|
|
31
|
-
__export(exports_index_node, {
|
|
32
|
-
createWorkflow: () => createWorkflow,
|
|
33
|
-
WorkflowBuilder: () => WorkflowBuilder,
|
|
34
|
-
StepExecutor: () => StepExecutor,
|
|
35
|
-
StateMachine: () => StateMachine,
|
|
36
|
-
OrbitFlux: () => OrbitFlux,
|
|
37
|
-
MemoryStorage: () => MemoryStorage,
|
|
38
|
-
FluxSilentLogger: () => FluxSilentLogger,
|
|
39
|
-
FluxEngine: () => FluxEngine,
|
|
40
|
-
FluxConsoleLogger: () => FluxConsoleLogger,
|
|
41
|
-
ContextManager: () => ContextManager
|
|
42
|
-
});
|
|
43
|
-
module.exports = __toCommonJS(exports_index_node);
|
|
44
|
-
|
|
45
|
-
// src/builder/WorkflowBuilder.ts
|
|
46
|
-
class WorkflowBuilder {
|
|
47
|
-
_name;
|
|
48
|
-
_steps = [];
|
|
49
|
-
_validateInput;
|
|
50
|
-
constructor(name) {
|
|
51
|
-
this._name = name;
|
|
52
|
-
}
|
|
53
|
-
input() {
|
|
54
|
-
return this;
|
|
55
|
-
}
|
|
56
|
-
validate(validator) {
|
|
57
|
-
this._validateInput = validator;
|
|
58
|
-
return this;
|
|
59
|
-
}
|
|
60
|
-
step(name, handler, options) {
|
|
61
|
-
this._steps.push({
|
|
62
|
-
name,
|
|
63
|
-
handler,
|
|
64
|
-
retries: options?.retries,
|
|
65
|
-
timeout: options?.timeout,
|
|
66
|
-
when: options?.when,
|
|
67
|
-
commit: false
|
|
68
|
-
});
|
|
69
|
-
return this;
|
|
70
|
-
}
|
|
71
|
-
commit(name, handler, options) {
|
|
72
|
-
this._steps.push({
|
|
73
|
-
name,
|
|
74
|
-
handler,
|
|
75
|
-
retries: options?.retries,
|
|
76
|
-
timeout: options?.timeout,
|
|
77
|
-
when: options?.when,
|
|
78
|
-
commit: true
|
|
79
|
-
});
|
|
80
|
-
return this;
|
|
81
|
-
}
|
|
82
|
-
build() {
|
|
83
|
-
if (this._steps.length === 0) {
|
|
84
|
-
throw new Error(`Workflow "${this._name}" has no steps`);
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
name: this._name,
|
|
88
|
-
steps: [...this._steps],
|
|
89
|
-
validateInput: this._validateInput
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
get name() {
|
|
93
|
-
return this._name;
|
|
94
|
-
}
|
|
95
|
-
get stepCount() {
|
|
96
|
-
return this._steps.length;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
function createWorkflow(name) {
|
|
100
|
-
return new WorkflowBuilder(name);
|
|
101
|
-
}
|
|
102
|
-
// src/core/ContextManager.ts
|
|
103
|
-
function generateId() {
|
|
104
|
-
return crypto.randomUUID();
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
class ContextManager {
|
|
108
|
-
create(name, input, stepCount) {
|
|
109
|
-
const history = Array.from({ length: stepCount }, (_, _i) => ({
|
|
110
|
-
name: "",
|
|
111
|
-
status: "pending",
|
|
112
|
-
retries: 0
|
|
113
|
-
}));
|
|
114
|
-
return {
|
|
115
|
-
id: generateId(),
|
|
116
|
-
name,
|
|
117
|
-
input,
|
|
118
|
-
data: {},
|
|
119
|
-
status: "pending",
|
|
120
|
-
currentStep: 0,
|
|
121
|
-
history
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
restore(state) {
|
|
125
|
-
return {
|
|
126
|
-
id: state.id,
|
|
127
|
-
name: state.name,
|
|
128
|
-
input: state.input,
|
|
129
|
-
data: { ...state.data },
|
|
130
|
-
status: state.status,
|
|
131
|
-
currentStep: state.currentStep,
|
|
132
|
-
history: state.history.map((h) => ({ ...h }))
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
toState(ctx) {
|
|
136
|
-
return {
|
|
137
|
-
id: ctx.id,
|
|
138
|
-
name: ctx.name,
|
|
139
|
-
status: ctx.status,
|
|
140
|
-
input: ctx.input,
|
|
141
|
-
data: { ...ctx.data },
|
|
142
|
-
currentStep: ctx.currentStep,
|
|
143
|
-
history: ctx.history.map((h) => ({ ...h })),
|
|
144
|
-
createdAt: new Date,
|
|
145
|
-
updatedAt: new Date
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
updateStatus(ctx, status) {
|
|
149
|
-
return {
|
|
150
|
-
...ctx,
|
|
151
|
-
status
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
advanceStep(ctx) {
|
|
155
|
-
return {
|
|
156
|
-
...ctx,
|
|
157
|
-
currentStep: ctx.currentStep + 1
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
setStepName(ctx, index, name) {
|
|
161
|
-
if (ctx.history[index]) {
|
|
162
|
-
ctx.history[index].name = name;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// src/core/StateMachine.ts
|
|
168
|
-
var TRANSITIONS = {
|
|
169
|
-
pending: ["running", "failed"],
|
|
170
|
-
running: ["paused", "completed", "failed"],
|
|
171
|
-
paused: ["running", "failed"],
|
|
172
|
-
completed: [],
|
|
173
|
-
failed: ["pending"]
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
class StateMachine extends EventTarget {
|
|
177
|
-
_status = "pending";
|
|
178
|
-
get status() {
|
|
179
|
-
return this._status;
|
|
180
|
-
}
|
|
181
|
-
canTransition(to) {
|
|
182
|
-
return TRANSITIONS[this._status].includes(to);
|
|
183
|
-
}
|
|
184
|
-
transition(to) {
|
|
185
|
-
if (!this.canTransition(to)) {
|
|
186
|
-
throw new Error(`Invalid state transition: ${this._status} → ${to}`);
|
|
187
|
-
}
|
|
188
|
-
const from = this._status;
|
|
189
|
-
this._status = to;
|
|
190
|
-
this.dispatchEvent(new CustomEvent("transition", {
|
|
191
|
-
detail: { from, to }
|
|
192
|
-
}));
|
|
193
|
-
}
|
|
194
|
-
forceStatus(status) {
|
|
195
|
-
this._status = status;
|
|
196
|
-
}
|
|
197
|
-
isTerminal() {
|
|
198
|
-
return this._status === "completed" || this._status === "failed";
|
|
199
|
-
}
|
|
200
|
-
canExecute() {
|
|
201
|
-
return this._status === "pending" || this._status === "paused";
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// src/core/StepExecutor.ts
|
|
206
|
-
class StepExecutor {
|
|
207
|
-
defaultRetries;
|
|
208
|
-
defaultTimeout;
|
|
209
|
-
constructor(options = {}) {
|
|
210
|
-
this.defaultRetries = options.defaultRetries ?? 3;
|
|
211
|
-
this.defaultTimeout = options.defaultTimeout ?? 30000;
|
|
212
|
-
}
|
|
213
|
-
async execute(step, ctx, execution) {
|
|
214
|
-
const maxRetries = step.retries ?? this.defaultRetries;
|
|
215
|
-
const timeout = step.timeout ?? this.defaultTimeout;
|
|
216
|
-
const startTime = Date.now();
|
|
217
|
-
if (step.when && !step.when(ctx)) {
|
|
218
|
-
execution.status = "skipped";
|
|
219
|
-
return {
|
|
220
|
-
success: true,
|
|
221
|
-
duration: 0
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
execution.status = "running";
|
|
225
|
-
execution.startedAt = new Date;
|
|
226
|
-
let lastError;
|
|
227
|
-
for (let attempt = 0;attempt <= maxRetries; attempt++) {
|
|
228
|
-
execution.retries = attempt;
|
|
229
|
-
try {
|
|
230
|
-
await this.executeWithTimeout(step.handler, ctx, timeout);
|
|
231
|
-
execution.status = "completed";
|
|
232
|
-
execution.completedAt = new Date;
|
|
233
|
-
execution.duration = Date.now() - startTime;
|
|
234
|
-
return {
|
|
235
|
-
success: true,
|
|
236
|
-
duration: execution.duration
|
|
237
|
-
};
|
|
238
|
-
} catch (error) {
|
|
239
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
240
|
-
if (attempt < maxRetries) {
|
|
241
|
-
await this.sleep(Math.min(1000 * 2 ** attempt, 1e4));
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
execution.status = "failed";
|
|
246
|
-
execution.completedAt = new Date;
|
|
247
|
-
execution.duration = Date.now() - startTime;
|
|
248
|
-
execution.error = lastError?.message;
|
|
249
|
-
return {
|
|
250
|
-
success: false,
|
|
251
|
-
error: lastError,
|
|
252
|
-
duration: execution.duration
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
async executeWithTimeout(handler, ctx, timeout) {
|
|
256
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
257
|
-
setTimeout(() => reject(new Error("Step timeout")), timeout);
|
|
258
|
-
});
|
|
259
|
-
await Promise.race([Promise.resolve(handler(ctx)), timeoutPromise]);
|
|
260
|
-
}
|
|
261
|
-
sleep(ms) {
|
|
262
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// src/storage/MemoryStorage.ts
|
|
267
|
-
class MemoryStorage {
|
|
268
|
-
store = new Map;
|
|
269
|
-
async save(state) {
|
|
270
|
-
this.store.set(state.id, {
|
|
271
|
-
...state,
|
|
272
|
-
updatedAt: new Date
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
async load(id) {
|
|
276
|
-
return this.store.get(id) ?? null;
|
|
277
|
-
}
|
|
278
|
-
async list(filter) {
|
|
279
|
-
let results = Array.from(this.store.values());
|
|
280
|
-
if (filter?.name) {
|
|
281
|
-
results = results.filter((s) => s.name === filter.name);
|
|
282
|
-
}
|
|
283
|
-
if (filter?.status) {
|
|
284
|
-
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
285
|
-
results = results.filter((s) => statuses.includes(s.status));
|
|
286
|
-
}
|
|
287
|
-
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
288
|
-
if (filter?.offset) {
|
|
289
|
-
results = results.slice(filter.offset);
|
|
290
|
-
}
|
|
291
|
-
if (filter?.limit) {
|
|
292
|
-
results = results.slice(0, filter.limit);
|
|
293
|
-
}
|
|
294
|
-
return results;
|
|
295
|
-
}
|
|
296
|
-
async delete(id) {
|
|
297
|
-
this.store.delete(id);
|
|
298
|
-
}
|
|
299
|
-
async init() {}
|
|
300
|
-
async close() {
|
|
301
|
-
this.store.clear();
|
|
302
|
-
}
|
|
303
|
-
size() {
|
|
304
|
-
return this.store.size;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// src/engine/FluxEngine.ts
|
|
309
|
-
class FluxEngine {
|
|
310
|
-
storage;
|
|
311
|
-
executor;
|
|
312
|
-
contextManager;
|
|
313
|
-
config;
|
|
314
|
-
constructor(config = {}) {
|
|
315
|
-
this.config = config;
|
|
316
|
-
this.storage = config.storage ?? new MemoryStorage;
|
|
317
|
-
this.executor = new StepExecutor({
|
|
318
|
-
defaultRetries: config.defaultRetries,
|
|
319
|
-
defaultTimeout: config.defaultTimeout
|
|
320
|
-
});
|
|
321
|
-
this.contextManager = new ContextManager;
|
|
322
|
-
}
|
|
323
|
-
async execute(workflow, input) {
|
|
324
|
-
const startTime = Date.now();
|
|
325
|
-
const definition = workflow instanceof WorkflowBuilder ? workflow.build() : workflow;
|
|
326
|
-
if (definition.validateInput && !definition.validateInput(input)) {
|
|
327
|
-
throw new Error(`Invalid input for workflow "${definition.name}"`);
|
|
328
|
-
}
|
|
329
|
-
const ctx = this.contextManager.create(definition.name, input, definition.steps.length);
|
|
330
|
-
const stateMachine = new StateMachine;
|
|
331
|
-
await this.storage.save(this.contextManager.toState(ctx));
|
|
332
|
-
try {
|
|
333
|
-
stateMachine.transition("running");
|
|
334
|
-
Object.assign(ctx, { status: "running" });
|
|
335
|
-
for (let i = 0;i < definition.steps.length; i++) {
|
|
336
|
-
const step = definition.steps[i];
|
|
337
|
-
const execution = ctx.history[i];
|
|
338
|
-
this.contextManager.setStepName(ctx, i, step.name);
|
|
339
|
-
Object.assign(ctx, { currentStep: i });
|
|
340
|
-
this.config.on?.stepStart?.(step.name, ctx);
|
|
341
|
-
const result = await this.executor.execute(step, ctx, execution);
|
|
342
|
-
if (result.success) {
|
|
343
|
-
this.config.on?.stepComplete?.(step.name, ctx, result);
|
|
344
|
-
} else {
|
|
345
|
-
this.config.on?.stepError?.(step.name, ctx, result.error);
|
|
346
|
-
stateMachine.transition("failed");
|
|
347
|
-
Object.assign(ctx, { status: "failed" });
|
|
348
|
-
await this.storage.save({
|
|
349
|
-
...this.contextManager.toState(ctx),
|
|
350
|
-
error: result.error?.message
|
|
351
|
-
});
|
|
352
|
-
return {
|
|
353
|
-
id: ctx.id,
|
|
354
|
-
status: "failed",
|
|
355
|
-
data: ctx.data,
|
|
356
|
-
history: ctx.history,
|
|
357
|
-
duration: Date.now() - startTime,
|
|
358
|
-
error: result.error
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
await this.storage.save(this.contextManager.toState(ctx));
|
|
362
|
-
}
|
|
363
|
-
stateMachine.transition("completed");
|
|
364
|
-
Object.assign(ctx, { status: "completed" });
|
|
365
|
-
await this.storage.save({
|
|
366
|
-
...this.contextManager.toState(ctx),
|
|
367
|
-
completedAt: new Date
|
|
368
|
-
});
|
|
369
|
-
this.config.on?.workflowComplete?.(ctx);
|
|
370
|
-
return {
|
|
371
|
-
id: ctx.id,
|
|
372
|
-
status: "completed",
|
|
373
|
-
data: ctx.data,
|
|
374
|
-
history: ctx.history,
|
|
375
|
-
duration: Date.now() - startTime
|
|
376
|
-
};
|
|
377
|
-
} catch (error) {
|
|
378
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
379
|
-
this.config.on?.workflowError?.(ctx, err);
|
|
380
|
-
stateMachine.forceStatus("failed");
|
|
381
|
-
Object.assign(ctx, { status: "failed" });
|
|
382
|
-
await this.storage.save({
|
|
383
|
-
...this.contextManager.toState(ctx),
|
|
384
|
-
error: err.message
|
|
385
|
-
});
|
|
386
|
-
return {
|
|
387
|
-
id: ctx.id,
|
|
388
|
-
status: "failed",
|
|
389
|
-
data: ctx.data,
|
|
390
|
-
history: ctx.history,
|
|
391
|
-
duration: Date.now() - startTime,
|
|
392
|
-
error: err
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
async resume(workflowId) {
|
|
397
|
-
const state = await this.storage.load(workflowId);
|
|
398
|
-
if (!state) {
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
throw new Error("Resume not yet implemented");
|
|
402
|
-
}
|
|
403
|
-
async get(workflowId) {
|
|
404
|
-
return this.storage.load(workflowId);
|
|
405
|
-
}
|
|
406
|
-
async list(filter) {
|
|
407
|
-
return this.storage.list(filter);
|
|
408
|
-
}
|
|
409
|
-
async init() {
|
|
410
|
-
await this.storage.init?.();
|
|
411
|
-
}
|
|
412
|
-
async close() {
|
|
413
|
-
await this.storage.close?.();
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
// src/logger/FluxLogger.ts
|
|
417
|
-
class FluxConsoleLogger {
|
|
418
|
-
prefix;
|
|
419
|
-
constructor(prefix = "[Flux]") {
|
|
420
|
-
this.prefix = prefix;
|
|
421
|
-
}
|
|
422
|
-
debug(message, ...args) {
|
|
423
|
-
console.debug(`${this.prefix} ${message}`, ...args);
|
|
424
|
-
}
|
|
425
|
-
info(message, ...args) {
|
|
426
|
-
console.info(`${this.prefix} ${message}`, ...args);
|
|
427
|
-
}
|
|
428
|
-
warn(message, ...args) {
|
|
429
|
-
console.warn(`${this.prefix} ${message}`, ...args);
|
|
430
|
-
}
|
|
431
|
-
error(message, ...args) {
|
|
432
|
-
console.error(`${this.prefix} ${message}`, ...args);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
class FluxSilentLogger {
|
|
437
|
-
debug() {}
|
|
438
|
-
info() {}
|
|
439
|
-
warn() {}
|
|
440
|
-
error() {}
|
|
441
|
-
}
|
|
442
|
-
// src/storage/BunSQLiteStorage.ts
|
|
443
|
-
var import_bun_sqlite = require("bun:sqlite");
|
|
444
|
-
|
|
445
|
-
class BunSQLiteStorage {
|
|
446
|
-
db;
|
|
447
|
-
tableName;
|
|
448
|
-
initialized = false;
|
|
449
|
-
constructor(options = {}) {
|
|
450
|
-
this.db = new import_bun_sqlite.Database(options.path ?? ":memory:");
|
|
451
|
-
this.tableName = options.tableName ?? "flux_workflows";
|
|
452
|
-
}
|
|
453
|
-
async init() {
|
|
454
|
-
if (this.initialized) {
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
this.db.run(`
|
|
458
|
-
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
459
|
-
id TEXT PRIMARY KEY,
|
|
460
|
-
name TEXT NOT NULL,
|
|
461
|
-
status TEXT NOT NULL,
|
|
462
|
-
input TEXT NOT NULL,
|
|
463
|
-
data TEXT NOT NULL,
|
|
464
|
-
current_step INTEGER NOT NULL,
|
|
465
|
-
history TEXT NOT NULL,
|
|
466
|
-
error TEXT,
|
|
467
|
-
created_at TEXT NOT NULL,
|
|
468
|
-
updated_at TEXT NOT NULL,
|
|
469
|
-
completed_at TEXT
|
|
470
|
-
)
|
|
471
|
-
`);
|
|
472
|
-
this.db.run(`
|
|
473
|
-
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_name
|
|
474
|
-
ON ${this.tableName}(name)
|
|
475
|
-
`);
|
|
476
|
-
this.db.run(`
|
|
477
|
-
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_status
|
|
478
|
-
ON ${this.tableName}(status)
|
|
479
|
-
`);
|
|
480
|
-
this.db.run(`
|
|
481
|
-
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_created
|
|
482
|
-
ON ${this.tableName}(created_at DESC)
|
|
483
|
-
`);
|
|
484
|
-
this.initialized = true;
|
|
485
|
-
}
|
|
486
|
-
async save(state) {
|
|
487
|
-
await this.init();
|
|
488
|
-
const stmt = this.db.prepare(`
|
|
489
|
-
INSERT OR REPLACE INTO ${this.tableName}
|
|
490
|
-
(id, name, status, input, data, current_step, history, error, created_at, updated_at, completed_at)
|
|
491
|
-
VALUES ($id, $name, $status, $input, $data, $currentStep, $history, $error, $createdAt, $updatedAt, $completedAt)
|
|
492
|
-
`);
|
|
493
|
-
stmt.run({
|
|
494
|
-
$id: state.id,
|
|
495
|
-
$name: state.name,
|
|
496
|
-
$status: state.status,
|
|
497
|
-
$input: JSON.stringify(state.input),
|
|
498
|
-
$data: JSON.stringify(state.data),
|
|
499
|
-
$currentStep: state.currentStep,
|
|
500
|
-
$history: JSON.stringify(state.history),
|
|
501
|
-
$error: state.error ?? null,
|
|
502
|
-
$createdAt: state.createdAt.toISOString(),
|
|
503
|
-
$updatedAt: state.updatedAt.toISOString(),
|
|
504
|
-
$completedAt: state.completedAt?.toISOString() ?? null
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
async load(id) {
|
|
508
|
-
await this.init();
|
|
509
|
-
const stmt = this.db.prepare(`
|
|
510
|
-
SELECT * FROM ${this.tableName} WHERE id = $id
|
|
511
|
-
`);
|
|
512
|
-
const row = stmt.get({ $id: id });
|
|
513
|
-
if (!row) {
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
return this.rowToState(row);
|
|
517
|
-
}
|
|
518
|
-
async list(filter) {
|
|
519
|
-
await this.init();
|
|
520
|
-
let query = `SELECT * FROM ${this.tableName} WHERE 1=1`;
|
|
521
|
-
const params = {};
|
|
522
|
-
if (filter?.name) {
|
|
523
|
-
query += " AND name = $name";
|
|
524
|
-
params.$name = filter.name;
|
|
525
|
-
}
|
|
526
|
-
if (filter?.status) {
|
|
527
|
-
if (Array.isArray(filter.status)) {
|
|
528
|
-
const placeholders = filter.status.map((_, i) => `$status${i}`).join(", ");
|
|
529
|
-
query += ` AND status IN (${placeholders})`;
|
|
530
|
-
filter.status.forEach((s, i) => {
|
|
531
|
-
params[`$status${i}`] = s;
|
|
532
|
-
});
|
|
533
|
-
} else {
|
|
534
|
-
query += " AND status = $status";
|
|
535
|
-
params.$status = filter.status;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
query += " ORDER BY created_at DESC";
|
|
539
|
-
if (filter?.limit) {
|
|
540
|
-
query += " LIMIT $limit";
|
|
541
|
-
params.$limit = filter.limit;
|
|
542
|
-
}
|
|
543
|
-
if (filter?.offset) {
|
|
544
|
-
query += " OFFSET $offset";
|
|
545
|
-
params.$offset = filter.offset;
|
|
546
|
-
}
|
|
547
|
-
const stmt = this.db.prepare(query);
|
|
548
|
-
const rows = stmt.all(params);
|
|
549
|
-
return rows.map((row) => this.rowToState(row));
|
|
550
|
-
}
|
|
551
|
-
async delete(id) {
|
|
552
|
-
await this.init();
|
|
553
|
-
const stmt = this.db.prepare(`
|
|
554
|
-
DELETE FROM ${this.tableName} WHERE id = $id
|
|
555
|
-
`);
|
|
556
|
-
stmt.run({ $id: id });
|
|
557
|
-
}
|
|
558
|
-
async close() {
|
|
559
|
-
this.db.close();
|
|
560
|
-
this.initialized = false;
|
|
561
|
-
}
|
|
562
|
-
rowToState(row) {
|
|
563
|
-
return {
|
|
564
|
-
id: row.id,
|
|
565
|
-
name: row.name,
|
|
566
|
-
status: row.status,
|
|
567
|
-
input: JSON.parse(row.input),
|
|
568
|
-
data: JSON.parse(row.data),
|
|
569
|
-
currentStep: row.current_step,
|
|
570
|
-
history: JSON.parse(row.history),
|
|
571
|
-
error: row.error ?? undefined,
|
|
572
|
-
createdAt: new Date(row.created_at),
|
|
573
|
-
updatedAt: new Date(row.updated_at),
|
|
574
|
-
completedAt: row.completed_at ? new Date(row.completed_at) : undefined
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
getDatabase() {
|
|
578
|
-
return this.db;
|
|
579
|
-
}
|
|
580
|
-
vacuum() {
|
|
581
|
-
this.db.run("VACUUM");
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// src/orbit/OrbitFlux.ts
|
|
586
|
-
class OrbitFlux {
|
|
587
|
-
options;
|
|
588
|
-
engine;
|
|
589
|
-
constructor(options = {}) {
|
|
590
|
-
this.options = {
|
|
591
|
-
storage: "memory",
|
|
592
|
-
exposeAs: "flux",
|
|
593
|
-
defaultRetries: 3,
|
|
594
|
-
defaultTimeout: 30000,
|
|
595
|
-
...options
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
static configure(options = {}) {
|
|
599
|
-
return new OrbitFlux(options);
|
|
600
|
-
}
|
|
601
|
-
async install(core) {
|
|
602
|
-
const { storage, dbPath, exposeAs, defaultRetries, defaultTimeout, logger } = this.options;
|
|
603
|
-
let storageAdapter;
|
|
604
|
-
if (typeof storage === "string") {
|
|
605
|
-
switch (storage) {
|
|
606
|
-
case "sqlite":
|
|
607
|
-
storageAdapter = new BunSQLiteStorage({ path: dbPath });
|
|
608
|
-
break;
|
|
609
|
-
default:
|
|
610
|
-
storageAdapter = new MemoryStorage;
|
|
611
|
-
}
|
|
612
|
-
} else {
|
|
613
|
-
storageAdapter = storage;
|
|
614
|
-
}
|
|
615
|
-
await storageAdapter.init?.();
|
|
616
|
-
const engineConfig = {
|
|
617
|
-
storage: storageAdapter,
|
|
618
|
-
defaultRetries,
|
|
619
|
-
defaultTimeout,
|
|
620
|
-
logger: logger ?? {
|
|
621
|
-
debug: (msg) => core.logger.debug(`[Flux] ${msg}`),
|
|
622
|
-
info: (msg) => core.logger.info(`[Flux] ${msg}`),
|
|
623
|
-
warn: (msg) => core.logger.warn(`[Flux] ${msg}`),
|
|
624
|
-
error: (msg) => core.logger.error(`[Flux] ${msg}`)
|
|
625
|
-
},
|
|
626
|
-
on: {
|
|
627
|
-
stepStart: (step) => {
|
|
628
|
-
core.hooks.doAction("flux:step:start", { step });
|
|
629
|
-
},
|
|
630
|
-
stepComplete: (step, ctx, result) => {
|
|
631
|
-
core.hooks.doAction("flux:step:complete", { step, ctx, result });
|
|
632
|
-
},
|
|
633
|
-
stepError: (step, ctx, error) => {
|
|
634
|
-
core.hooks.doAction("flux:step:error", { step, ctx, error });
|
|
635
|
-
},
|
|
636
|
-
workflowComplete: (ctx) => {
|
|
637
|
-
core.hooks.doAction("flux:workflow:complete", { ctx });
|
|
638
|
-
},
|
|
639
|
-
workflowError: (ctx, error) => {
|
|
640
|
-
core.hooks.doAction("flux:workflow:error", { ctx, error });
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
};
|
|
644
|
-
this.engine = new FluxEngine(engineConfig);
|
|
645
|
-
core.services.set(exposeAs, this.engine);
|
|
646
|
-
core.logger.info(`[OrbitFlux] Initialized (Storage: ${typeof storage === "string" ? storage : "custom"})`);
|
|
647
|
-
}
|
|
648
|
-
getEngine() {
|
|
649
|
-
return this.engine;
|
|
650
|
-
}
|
|
651
|
-
}
|