@rip-lang/swarm 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.svg" style="width:50px" /> <br>
2
+
3
+ # Rip Swarm - @rip-lang/swarm
4
+
5
+ > **Parallel job runner with worker threads — setup once, swarm many**
6
+
7
+ Swarm processes large batches of tasks in parallel using Bun's worker
8
+ threads. Define a setup function (runs once) and a perform function
9
+ (runs per task), and swarm handles the rest — worker pool management,
10
+ file-based task lifecycle, ANSI progress bars, crash recovery, and a
11
+ clean summary at the end.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ bun add -g @rip-lang/swarm
17
+ ```
18
+
19
+ Create a job script:
20
+
21
+ ```coffee
22
+ import { swarm, init, retry, todo } from '@rip-lang/swarm'
23
+
24
+ setup = ->
25
+ unless retry()
26
+ init()
27
+ for i in [1..100] then todo(i)
28
+ { startedAt: Date.now() }
29
+
30
+ perform = (task, ctx) ->
31
+ await Bun.sleep(Math.random() * 1000)
32
+ throw new Error("boom") if Math.random() < 0.03
33
+
34
+ swarm { setup, perform, workers: 10 }
35
+ ```
36
+
37
+ Run it:
38
+
39
+ ```bash
40
+ rip jobs.rip
41
+ rip jobs.rip -w 10 # 10 workers (default: CPU count)
42
+ ```
43
+
44
+ ## How It Works
45
+
46
+ ```
47
+ ┌──────────────────────────────────────────────┐
48
+ │ Single Bun Process │
49
+ │ │
50
+ │ Main Thread Worker Threads │
51
+ │ ─────────── ────────────── │
52
+ │ setup() perform(task, ctx) │
53
+ │ task dispatch perform(task, ctx) │
54
+ │ progress bars perform(task, ctx) │
55
+ │ file lifecycle ... │
56
+ │ │
57
+ │ .swarm/todo/ ──→ .swarm/done/ │
58
+ │ └──→ .swarm/died/ │
59
+ └──────────────────────────────────────────────┘
60
+ ```
61
+
62
+ 1. **setup()** runs once in the main thread — creates tasks and returns
63
+ an optional context object
64
+ 2. **N worker threads** are spawned — each loads your script and gets
65
+ the `perform` function
66
+ 3. Tasks are dispatched from `.swarm/todo/` to workers via message passing
67
+ 4. Workers call `perform(task, ctx)` and report done or failed
68
+ 5. Main thread moves files to `.swarm/done/` or `.swarm/died/` and
69
+ updates the progress display
70
+ 6. When all tasks complete, a summary is printed
71
+
72
+ ## API
73
+
74
+ ### Task Queue
75
+
76
+ ```coffee
77
+ import { init, retry, todo } from '@rip-lang/swarm'
78
+
79
+ init() # Remove old .swarm, create todo/done/died dirs
80
+ retry() # Move .swarm/died/* back to .swarm/todo/ for retry
81
+ todo('task-1') # Create empty task file
82
+ todo('task-2', data) # Create task file with data (string or JSON)
83
+ ```
84
+
85
+ ### swarm()
86
+
87
+ ```coffee
88
+ swarm { setup, perform }
89
+ swarm { setup, perform, workers: 8, bar: 30, char: '█' }
90
+ ```
91
+
92
+ Options:
93
+ - **setup** — function, runs once in main thread, returns optional context
94
+ - **perform** — function `(taskPath, ctx)`, runs in worker threads
95
+ - **workers** — number of worker threads (default: CPU count)
96
+ - **bar** — progress bar width in characters (default: 20)
97
+ - **char** — character for progress bars (default: `•`)
98
+
99
+ ### CLI Flags
100
+
101
+ ```
102
+ -w, --workers <n> Number of workers (default: CPU count)
103
+ -b, --bar <width> Progress bar width (default: 20)
104
+ -c, --char <ch> Bar character (default: •)
105
+ -r, --reset Remove .swarm directory and quit
106
+ ```
107
+
108
+ CLI flags override options passed to `swarm()`.
109
+
110
+ ## Task Lifecycle
111
+
112
+ ```
113
+ .swarm/
114
+ ├── todo/ ← tasks waiting to be processed
115
+ ├── done/ ← successfully completed tasks
116
+ └── died/ ← failed tasks (can be retried)
117
+ ```
118
+
119
+ Tasks are plain files. The filename identifies the task. Files can be
120
+ empty (filename is the data) or contain a payload (JSON, text, etc.).
121
+ File moves use `renameSync` for atomic operations.
122
+
123
+ ## Crash Recovery
124
+
125
+ | Failure | What Happens | Recovery |
126
+ |---------|-------------|----------|
127
+ | `perform()` throws | Worker catches it, reports failed, continues | Automatic |
128
+ | Unhandled rejection | Worker error handler fires, continues | Automatic |
129
+ | Worker thread dies | Main thread detects exit, respawns worker | Automatic |
130
+ | Task timeout | (planned) AbortSignal kills task | Worker continues |
131
+
132
+ Failed tasks land in `.swarm/died/`. Call `retry()` in your next
133
+ `setup()` to move them back to `.swarm/todo/` for reprocessing.
134
+
135
+ ## Comparison with vete (Ruby)
136
+
137
+ | Feature | vete (Ruby) | swarm (Rip/Bun) |
138
+ |---------|------------|-----------------|
139
+ | Parallelism | fork() per task | Worker threads (reused) |
140
+ | Setup | Runs once (fork shares memory) | Runs once (context cloned) |
141
+ | Per-task overhead | ~100μs (fork) | ~0 (message passing) |
142
+ | Crash recovery | Process dies, slot freed | Exception caught, worker continues |
143
+ | Timeout support | None | Planned (AbortSignal) |
144
+ | Default workers | 1 | CPU count |
145
+ | Dependencies | fileutils, optparse, thread | Zero (Bun builtins) |
package/bin/swarm ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execFileSync } from 'child_process';
4
+
5
+ const script = process.argv[2];
6
+ if (!script) {
7
+ console.error('usage: swarm [options] <script.rip>');
8
+ process.exit(1);
9
+ }
10
+
11
+ try {
12
+ execFileSync('rip', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
13
+ } catch (e) {
14
+ process.exit(e.status || 1);
15
+ }
package/lib/worker.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // Worker bootstrap — loaded by each worker thread
2
+ // The rip-loader is preloaded via Worker({ preload: [...] }), so .rip imports work.
3
+ // Imports the user script (which calls swarm() — a no-op in worker mode),
4
+ // then processes tasks via IPC from the main thread.
5
+
6
+ import { parentPort, workerData } from 'worker_threads';
7
+
8
+ const { scriptPath, context } = workerData;
9
+
10
+ let perform;
11
+
12
+ try {
13
+ // Import the user script — triggers swarm() which registers perform() in worker mode
14
+ await import(scriptPath);
15
+
16
+ // Get perform from the swarm module (registered by swarm() in worker mode)
17
+ const swarmMod = await import(new URL('../swarm.rip', import.meta.url).href);
18
+ perform = swarmMod._getPerform();
19
+
20
+ if (typeof perform !== 'function') {
21
+ throw new Error('No perform() function provided to swarm()');
22
+ }
23
+ } catch (err) {
24
+ parentPort.postMessage({ type: 'error', error: err.message });
25
+ process.exit(1);
26
+ }
27
+
28
+ // Signal ready
29
+ parentPort.postMessage({ type: 'ready' });
30
+
31
+ // Process tasks as they arrive
32
+ parentPort.on('message', async (msg) => {
33
+ if (msg.type === 'task') {
34
+ try {
35
+ await perform(msg.taskPath, context);
36
+ parentPort.postMessage({ type: 'done', taskPath: msg.taskPath });
37
+ } catch (err) {
38
+ parentPort.postMessage({ type: 'failed', taskPath: msg.taskPath, error: err.message });
39
+ }
40
+ } else if (msg.type === 'shutdown') {
41
+ process.exit(0);
42
+ }
43
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@rip-lang/swarm",
3
+ "version": "1.0.0",
4
+ "description": "Parallel job runner with worker threads — setup once, swarm many",
5
+ "type": "module",
6
+ "main": "swarm.rip",
7
+ "bin": {
8
+ "swarm": "./bin/swarm"
9
+ },
10
+ "exports": {
11
+ ".": "./swarm.rip"
12
+ },
13
+ "scripts": {
14
+ "test": "rip test/example.rip -w 5"
15
+ },
16
+ "keywords": [
17
+ "parallel",
18
+ "workers",
19
+ "jobs",
20
+ "tasks",
21
+ "concurrency",
22
+ "progress",
23
+ "cli",
24
+ "rip"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/shreeve/rip-lang.git",
29
+ "directory": "packages/swarm"
30
+ },
31
+ "homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/swarm#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/shreeve/rip-lang/issues"
34
+ },
35
+ "author": "Steve Shreeve <steve.shreeve@gmail.com>",
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "rip-lang": "^2.9.0"
39
+ },
40
+ "files": [
41
+ "swarm.rip",
42
+ "lib/",
43
+ "bin/",
44
+ "README.md"
45
+ ]
46
+ }
package/swarm.rip ADDED
@@ -0,0 +1,332 @@
1
+ # ==============================================================================
2
+ # @rip-lang/swarm — Parallel job runner with worker threads
3
+ #
4
+ # Author: Steve Shreeve (steve.shreeve@gmail.com)
5
+ # Date: January 18, 2026
6
+ #
7
+ # Usage:
8
+ # import { swarm, init, retry, todo } from '@rip-lang/swarm'
9
+ #
10
+ # setup = ->
11
+ # unless retry()
12
+ # init()
13
+ # for i in [1..100] then todo(i)
14
+ # { startedAt: Date.now() }
15
+ #
16
+ # perform = (task, ctx) ->
17
+ # await Bun.sleep(Math.random() * 1000)
18
+ #
19
+ # swarm { setup, perform }
20
+ # ==============================================================================
21
+
22
+ import { isMainThread } from 'worker_threads'
23
+ import { existsSync, mkdirSync, readdirSync, renameSync, writeFileSync, rmSync } from 'fs'
24
+ import { join, resolve, dirname } from 'path'
25
+ import { cpus } from 'os'
26
+
27
+ # ==============================================================================
28
+ # Module state
29
+ # ==============================================================================
30
+
31
+ _dir = resolve('.swarm')
32
+ _todo = join(_dir, 'todo')
33
+ _done = join(_dir, 'done')
34
+ _died = join(_dir, 'died')
35
+
36
+ # ==============================================================================
37
+ # Task queue (file-based)
38
+ # ==============================================================================
39
+
40
+ export init = ->
41
+ rmSync(_dir, { recursive: true, force: true })
42
+ mkdirSync(_todo, { recursive: true })
43
+ mkdirSync(_done, { recursive: true })
44
+ mkdirSync(_died, { recursive: true })
45
+
46
+ export retry = ->
47
+ return false unless existsSync(_died) and existsSync(_todo)
48
+ died = readdirSync(_died)
49
+ todo = readdirSync(_todo)
50
+ return todo.length > 0 if died.length is 0
51
+ for file in died
52
+ renameSync(join(_died, file), join(_todo, file))
53
+ true
54
+
55
+ export todo = (name, data) ->
56
+ path = join(_todo, String(name))
57
+ if data?
58
+ writeFileSync(path, if typeof data is 'string' then data else JSON.stringify(data))
59
+ else
60
+ writeFileSync(path, '')
61
+
62
+ move = (path, dest) ->
63
+ try renameSync(path, join(dest, path.split('/').pop()))
64
+ catch then null
65
+
66
+ # ==============================================================================
67
+ # ANSI terminal
68
+ # ==============================================================================
69
+
70
+ STDOUT = process.stdout
71
+
72
+ write = (s) -> STDOUT.write(s)
73
+ clear = (line) -> if line then "\x1b[K" else "\x1b[2J"
74
+ cursor = (show) -> write(if show then "\x1b[?25h" else "\x1b[?25l")
75
+ go = (r = 1, c = 1) -> "\x1b[#{r};#{c}H"
76
+
77
+ _hex = {}
78
+
79
+ hex = (str) ->
80
+ return _hex[str] if _hex[str]?
81
+ s = str.replace(/^#/, '')
82
+ m = s.match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) or s.match(/^([0-9a-f])([0-9a-f])([0-9a-f])$/i)
83
+ return '' unless m
84
+ result = if m[1].length is 1
85
+ [parseInt(m[1]+m[1], 16), parseInt(m[2]+m[2], 16), parseInt(m[3]+m[3], 16)].join(';')
86
+ else
87
+ [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)].join(';')
88
+ _hex[str] = result
89
+
90
+ fg = (rgb) -> if rgb then "\x1b[38;2;#{hex(rgb)}m" else "\x1b[39m"
91
+ bg = (rgb) -> if rgb then "\x1b[48;2;#{hex(rgb)}m" else "\x1b[49m"
92
+
93
+ # ==============================================================================
94
+ # Progress display
95
+ # ==============================================================================
96
+
97
+ _char = _wide = _len = null
98
+
99
+ drawFrame = (workers) ->
100
+ _len = String(workers).length
101
+ write clear()
102
+ write go(2 + workers, _len + 3) + "╰" + "─".repeat(_wide + 2) + "╯"
103
+ write go(1, _len + 3) + "╭" + "─".repeat(_wide + 2) + "╮"
104
+ for i in [0...workers]
105
+ write go(i + 2, 1)
106
+ label = String(i + 1).padStart(_len)
107
+ write " #{label} │ " + " ".repeat(_wide) + " │"
108
+ # summary bar row
109
+ write go(workers + 3, _len + 3) + "│ " + " ".repeat(_wide) + " │"
110
+
111
+ draw = (state) ->
112
+ { live, done, died, jobs, workers, info } = state
113
+
114
+ ppct = (done + died) / jobs
115
+ most = Math.max(...Object.values(info), 1)
116
+
117
+ # worker bars
118
+ for slot, count of info
119
+ tpct = count / most
120
+ cols = Math.floor(ppct * tpct * _wide)
121
+ write go(parseInt(slot) + 1, _len + 5) + bg("5383ec") + _char.repeat(cols) + bg()
122
+
123
+ # summary bar
124
+ dpct = done / jobs
125
+ lpct = live / jobs
126
+ gcol = Math.floor(dpct * _wide)
127
+ ycol = Math.floor(lpct * _wide)
128
+ rcol = Math.max(0, _wide - gcol - ycol)
129
+ row = workers + 3
130
+
131
+ write go(row, _len + 5)
132
+ write fg("fff")
133
+ write bg("58a65c") + _char.repeat(gcol) # green (done)
134
+ write bg("f1bf42") + _char.repeat(ycol) # yellow (live)
135
+ write bg("d85140") + " ".repeat(rcol) # red (rest)
136
+ write go(row, _len + 5 + _wide + 3)
137
+ write bg("5383ec") + " #{(ppct * 100).toFixed(1)}% "
138
+ write bg() + " " + bg("58a65c") + " #{done}/#{jobs} done " if done > 0
139
+ write bg() + " " + bg("d85140") + " #{died} died " if died > 0
140
+ write fg() + bg()
141
+
142
+ # ==============================================================================
143
+ # Worker orchestration
144
+ # ==============================================================================
145
+
146
+ # Worker mode: register perform function for the worker bootstrap
147
+ _workerPerform = null
148
+ export _getPerform = -> _workerPerform
149
+
150
+ export swarm = (opts = {}) ->
151
+ unless isMainThread
152
+ _workerPerform = opts.perform
153
+ return
154
+
155
+ { Worker } = await import('worker_threads')
156
+
157
+ # parse CLI options
158
+ args = process.argv.slice(2)
159
+ workers = parseInt(findArg(args, '-w', '--workers')) or opts.workers or cpus().length
160
+ barw = parseInt(findArg(args, '-b', '--bar')) or opts.bar or 20
161
+ char = findArg(args, '-c', '--char') or opts.char or '•'
162
+ doreset = args.includes('-r') or args.includes('--reset')
163
+
164
+ if workers < 1
165
+ console.error 'error: workers must be at least 1'
166
+ process.exit(1)
167
+
168
+ _wide = barw
169
+ _char = char[0]
170
+
171
+ if doreset
172
+ rmSync(_dir, { recursive: true, force: true })
173
+ console.log 'removed .swarm directory'
174
+ process.exit(0)
175
+
176
+ # run setup
177
+ unless typeof opts.perform is 'function'
178
+ console.error 'error: perform() function is required'
179
+ process.exit(1)
180
+
181
+ context = {}
182
+ if typeof opts.setup is 'function'
183
+ result = await opts.setup()
184
+ context = result if result? and typeof result is 'object'
185
+
186
+ # read task list
187
+ unless existsSync(_todo)
188
+ console.error 'error: no .swarm/todo directory found (did setup run?)'
189
+ process.exit(1)
190
+
191
+ tasks = readdirSync(_todo).sort().map (f) -> join(_todo, f)
192
+
193
+ if tasks.length is 0
194
+ console.log 'no tasks to process'
195
+ process.exit(0)
196
+
197
+ jobs = tasks.length
198
+
199
+ # resolve paths
200
+ workerPath = join(dirname(new URL(import.meta.url).pathname), 'lib', 'worker.mjs')
201
+ scriptPath = resolve(process.argv[1] or '')
202
+
203
+ # find rip-loader for workers
204
+ loaderPath = null
205
+ try
206
+ loaderPath = join(dirname(require.resolve('rip-lang')), '..', 'rip-loader.js')
207
+ catch
208
+ null
209
+
210
+ # state
211
+ live = 0
212
+ done = 0
213
+ died = 0
214
+ info = {}
215
+ taskIdx = 0
216
+ inflight = {} # slot → taskPath (track in-flight tasks for crash recovery)
217
+
218
+ # signal handlers
219
+ process.on 'SIGINT', ->
220
+ cursor(true)
221
+ write go(workers + 5, 1) + "\n"
222
+ process.exit(1)
223
+ process.on 'SIGWINCH', ->
224
+ drawFrame(workers)
225
+ draw({ live, done, died, jobs, workers, info })
226
+
227
+ # draw initial frame
228
+ startTime = Date.now()
229
+ cursor(false)
230
+ drawFrame(workers)
231
+
232
+ # create workers and dispatch tasks
233
+ allWorkers = []
234
+
235
+ await new Promise (resolveAll) ->
236
+
237
+ finished = false
238
+ checkDone = ->
239
+ if not finished and done + died >= jobs
240
+ finished = true
241
+ for wk in allWorkers
242
+ try wk.postMessage { type: 'shutdown' }
243
+ catch then null
244
+ resolveAll()
245
+
246
+ dispatchNext = (worker, slot) ->
247
+ if taskIdx < tasks.length
248
+ taskPath = tasks[taskIdx++]
249
+ inflight[slot] = taskPath
250
+ live++
251
+ write go(slot + 1, _len + 5 + _wide + 3) + " " + taskPath.split('/').pop() + clear(true)
252
+ draw({ live, done, died, jobs, workers, info })
253
+ worker.postMessage { type: 'task', taskPath }
254
+ else
255
+ inflight[slot] = null
256
+ checkDone()
257
+
258
+ spawnWorker = (slot) ->
259
+ info[slot] ?= 0
260
+
261
+ wopts = { workerData: { scriptPath, context } }
262
+ wopts.preload = [loaderPath] if loaderPath
263
+ w = new Worker(workerPath, wopts)
264
+ allWorkers.push(w)
265
+
266
+ w.on 'message', (msg) ->
267
+ switch msg.type
268
+ when 'ready'
269
+ dispatchNext(w, slot)
270
+ when 'done'
271
+ move(msg.taskPath, _done)
272
+ inflight[slot] = null
273
+ live--
274
+ done++
275
+ info[slot]++
276
+ draw({ live, done, died, jobs, workers, info })
277
+ dispatchNext(w, slot)
278
+ when 'failed'
279
+ move(msg.taskPath, _died)
280
+ inflight[slot] = null
281
+ live--
282
+ died++
283
+ info[slot]++
284
+ draw({ live, done, died, jobs, workers, info })
285
+ dispatchNext(w, slot)
286
+
287
+ w.on 'error', (err) ->
288
+ console.error "\nworker #{slot} error: #{err.message}"
289
+
290
+ w.on 'exit', (code) ->
291
+ # if worker crashed mid-task, count the in-flight task as died
292
+ if inflight[slot]
293
+ move(inflight[slot], _died)
294
+ inflight[slot] = null
295
+ live--
296
+ died++
297
+ info[slot]++
298
+ draw({ live, done, died, jobs, workers, info })
299
+ # respawn if there's still work to do
300
+ if done + died < jobs
301
+ spawnWorker(slot)
302
+ else
303
+ checkDone()
304
+
305
+ # spawn worker pool
306
+ count = Math.min(workers, jobs)
307
+ for slot in [1..count]
308
+ spawnWorker(slot)
309
+
310
+ # summary
311
+ cursor(true)
312
+ secs = (Date.now() - startTime) / 1000
313
+ write go(workers + 5, 1)
314
+ write "#{secs.toFixed(2)} secs"
315
+ write " for #{jobs} jobs"
316
+ write " by #{workers} workers"
317
+ write " @ #{(jobs / secs).toFixed(2)} jobs/sec" if secs > 0
318
+ write "\n\n"
319
+
320
+ # ==============================================================================
321
+ # CLI helpers
322
+ # ==============================================================================
323
+
324
+ findArg = (args, short, long) ->
325
+ for arg, i in args
326
+ if arg is short or arg is long
327
+ return args[i + 1] if args[i + 1]?
328
+ if arg.startsWith("#{long}=")
329
+ return arg.split('=')[1]
330
+ if arg.startsWith("#{short}=")
331
+ return arg.split('=')[1]
332
+ null