wicked-bus 2.2.0 → 2.2.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/commands/cli.js +23 -0
- package/lib/atomic.js +90 -0
- package/lib/cas.js +6 -8
- package/package.json +1 -1
- package/skills/wicked-bus/naming/SKILL.md +102 -0
package/commands/cli.js
CHANGED
|
@@ -4,8 +4,22 @@
|
|
|
4
4
|
* wicked-bus CLI entry point.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
7
10
|
import { WBError, EXIT_CODES } from '../lib/errors.js';
|
|
8
11
|
|
|
12
|
+
// Resolve the package version from package.json (single source of truth).
|
|
13
|
+
function readVersion() {
|
|
14
|
+
try {
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
|
|
17
|
+
return pkg.version || '0.0.0';
|
|
18
|
+
} catch (_e) {
|
|
19
|
+
return '0.0.0';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
// Argument parser. Returns flags + positional args (anything that isn't --flag
|
|
10
24
|
// or its value). Positional args are needed for subcommands like `dlq list`.
|
|
11
25
|
function parseArgs(argv) {
|
|
@@ -56,6 +70,15 @@ function handleError(err) {
|
|
|
56
70
|
async function main() {
|
|
57
71
|
const argv = process.argv.slice(2);
|
|
58
72
|
const command = argv[0];
|
|
73
|
+
|
|
74
|
+
// `--version` / `-v` prints the package version and exits 0. Handled before
|
|
75
|
+
// command dispatch so health probes (e.g. wicked-loom's doctor) get a clean
|
|
76
|
+
// version string instead of the usage/help JSON.
|
|
77
|
+
if (command === '--version' || command === '-v') {
|
|
78
|
+
process.stdout.write(readVersion() + '\n');
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
const flagArgv = argv.slice(1);
|
|
60
83
|
const args = parseArgs(flagArgv);
|
|
61
84
|
|
package/lib/atomic.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform atomic file placement helpers.
|
|
3
|
+
*
|
|
4
|
+
* POSIX `rename(2)` atomically replaces an existing destination. Windows
|
|
5
|
+
* `MoveFileEx`-backed `fs.rename` REJECTS a rename onto an existing file with
|
|
6
|
+
* `EPERM` / `EEXIST` / `EACCES`, and may transiently fail with `EBUSY` when the
|
|
7
|
+
* destination (or a directory entry) is briefly held open by AV/indexer/another
|
|
8
|
+
* handle. POSIX never sees these for an overwrite.
|
|
9
|
+
*
|
|
10
|
+
* This module centralizes the cross-platform fallback so every "write to a temp
|
|
11
|
+
* file, then rename it into final position" site behaves identically and can't
|
|
12
|
+
* regress independently.
|
|
13
|
+
*
|
|
14
|
+
* @module lib/atomic
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
|
|
19
|
+
// Number of times we retry a transient EBUSY on win32 before giving up, and the
|
|
20
|
+
// backoff between attempts. Kept tiny — EBUSY here is from a momentary handle
|
|
21
|
+
// (AV scan / indexer), not sustained contention. Synchronous spin-wait so the
|
|
22
|
+
// helper stays drop-in for the existing synchronous CAS/fs codepaths.
|
|
23
|
+
const WIN32_EBUSY_RETRIES = 5;
|
|
24
|
+
const WIN32_EBUSY_BACKOFF_MS = 20;
|
|
25
|
+
|
|
26
|
+
function sleepSync(ms) {
|
|
27
|
+
// Busy-ish wait without pulling in a dependency. Used only on the win32 EBUSY
|
|
28
|
+
// retry path, which is rare and short.
|
|
29
|
+
const end = Date.now() + ms;
|
|
30
|
+
while (Date.now() < end) { /* spin */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rename `tmp` onto `target`, overwriting any existing `target` atomically.
|
|
35
|
+
*
|
|
36
|
+
* On POSIX a single `renameSync` already overwrites atomically. On win32, if the
|
|
37
|
+
* rename is rejected because `target` exists (EPERM/EEXIST/EACCES) we unlink the
|
|
38
|
+
* destination then retry the rename; a transient EBUSY is retried with a short
|
|
39
|
+
* backoff. On any unrecoverable failure the `tmp` file is cleaned up before the
|
|
40
|
+
* error is rethrown so we never leak partial temp files.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} tmp path to the fully-written temp file
|
|
43
|
+
* @param {string} target final destination path
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {string[]} [opts.swallowCodes] on POSIX-or-win32, rethrow is skipped
|
|
46
|
+
* for these error codes (e.g. ['EEXIST']
|
|
47
|
+
* for content-addressed no-op races). The
|
|
48
|
+
* tmp file is still cleaned up.
|
|
49
|
+
*/
|
|
50
|
+
export function atomicRename(tmp, target, opts = {}) {
|
|
51
|
+
const swallow = new Set(opts.swallowCodes ?? []);
|
|
52
|
+
const isWin = process.platform === 'win32';
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
fs.renameSync(tmp, target);
|
|
56
|
+
return;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// win32: rename-over-existing is rejected. Unlink the destination and retry.
|
|
59
|
+
if (isWin && (e.code === 'EPERM' || e.code === 'EEXIST' || e.code === 'EACCES' || e.code === 'EBUSY')) {
|
|
60
|
+
let lastErr = e;
|
|
61
|
+
for (let attempt = 0; attempt <= WIN32_EBUSY_RETRIES; attempt++) {
|
|
62
|
+
try {
|
|
63
|
+
// Remove the destination so the rename has a clear path. The
|
|
64
|
+
// destination may legitimately not exist (pure EBUSY on the source),
|
|
65
|
+
// so a failing unlink is non-fatal.
|
|
66
|
+
try { fs.unlinkSync(target); } catch (_e) { /* may not exist */ }
|
|
67
|
+
fs.renameSync(tmp, target);
|
|
68
|
+
return;
|
|
69
|
+
} catch (retryErr) {
|
|
70
|
+
lastErr = retryErr;
|
|
71
|
+
// Only EBUSY is worth retrying — it's the transient AV/indexer hold.
|
|
72
|
+
if (retryErr.code === 'EBUSY' && attempt < WIN32_EBUSY_RETRIES) {
|
|
73
|
+
sleepSync(WIN32_EBUSY_BACKOFF_MS);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Exhausted retries (or a non-EBUSY error on the fallback path).
|
|
80
|
+
try { fs.unlinkSync(tmp); } catch (_e) { /* avoid leaking the temp file */ }
|
|
81
|
+
if (swallow.has(lastErr.code)) return;
|
|
82
|
+
throw lastErr;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// POSIX (or a win32 error we don't special-case). Clean up the temp file.
|
|
86
|
+
try { fs.unlinkSync(tmp); } catch (_e) { /* avoid leaking the temp file */ }
|
|
87
|
+
if (swallow.has(e.code)) return;
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/cas.js
CHANGED
|
@@ -23,6 +23,7 @@ import { createHash } from 'node:crypto';
|
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
24
24
|
import { WBError } from './errors.js';
|
|
25
25
|
import { archiveDir, listBuckets } from './archive.js';
|
|
26
|
+
import { atomicRename } from './atomic.js';
|
|
26
27
|
|
|
27
28
|
const require = createRequire(import.meta.url);
|
|
28
29
|
|
|
@@ -102,14 +103,11 @@ export function put(dataDir, content, opts = {}) {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
try { fs.unlinkSync(tmp); } catch (_e) { /* ignore */ }
|
|
111
|
-
if (e.code !== 'EEXIST') throw e;
|
|
112
|
-
}
|
|
106
|
+
// Cross-platform atomic placement. POSIX rename overwrites atomically;
|
|
107
|
+
// Windows rejects rename-over-existing (EPERM/EEXIST/EACCES) and may hit a
|
|
108
|
+
// transient EBUSY — atomicRename() handles both. EEXIST is swallowed because
|
|
109
|
+
// a concurrent writer placing identical content (same SHA) is a safe no-op.
|
|
110
|
+
atomicRename(tmp, target, { swallowCodes: ['EEXIST'] });
|
|
113
111
|
|
|
114
112
|
return sha;
|
|
115
113
|
}
|
package/package.json
CHANGED
|
@@ -150,3 +150,105 @@ into event_type forces subscribers to enumerate every producer.
|
|
|
150
150
|
**Why subdomain is a column (not in event_type):**
|
|
151
151
|
`wicked.phase.started` is semantic. Whether it's `crew.phase` or `deploy.phase`
|
|
152
152
|
is identity, not semantics. Columns enable index-based filtering.
|
|
153
|
+
|
|
154
|
+
## Worked example: lifecycle & gate events
|
|
155
|
+
|
|
156
|
+
> **This is an illustrative pattern, not a mandate.** wicked-bus does not ship,
|
|
157
|
+
> register, or enforce a lifecycle catalog — skills teach conventions, they do
|
|
158
|
+
> **not** hardcode other plugins' event catalogs. Treat the names below as an
|
|
159
|
+
> example that a multi-stage pipeline tool **MAY** adopt to stay consistent with the
|
|
160
|
+
> `wicked.<noun>.<past-tense-verb>` convention. Pick the nouns/verbs that fit
|
|
161
|
+
> your domain; nothing here is reserved.
|
|
162
|
+
|
|
163
|
+
Many ecosystem tools run a **staged, gated pipeline** — an engine that moves work
|
|
164
|
+
through ordered stages, with governance gates between them (for example a
|
|
165
|
+
migration pipeline with stages like *discover → knowledge-base → spec → plan →
|
|
166
|
+
transform → validate → deliver/cutover*, each guarded by an approval gate). Such a
|
|
167
|
+
tool needs a consistent way to signal "a stage was entered/completed" and "a gate
|
|
168
|
+
cleared/blocked" so other tools can observe progress.
|
|
169
|
+
|
|
170
|
+
Here is one internally-consistent way to name those events under the existing
|
|
171
|
+
convention.
|
|
172
|
+
|
|
173
|
+
### Suggested event types
|
|
174
|
+
|
|
175
|
+
| Event type | Emitted when | Notes |
|
|
176
|
+
|------------|--------------|-------|
|
|
177
|
+
| `wicked.stage.entered` | A pipeline stage begins | noun=`stage`, verb=`entered` |
|
|
178
|
+
| `wicked.stage.completed` | A stage finishes successfully | mirror of `entered` |
|
|
179
|
+
| `wicked.gate.cleared` | A governance gate passes | the "go" signal |
|
|
180
|
+
| `wicked.gate.blocked` | A gate fails / withholds approval | the "stop" signal |
|
|
181
|
+
| `wicked.pipeline.completed` | The whole pipeline reaches its terminal milestone (e.g. delivery/cutover) | terminal milestone — stage carried in `subdomain` |
|
|
182
|
+
|
|
183
|
+
All five satisfy the rules: `wicked.` prefix, three segments, past-tense verb,
|
|
184
|
+
no domain or subdomain baked in. They are **semantic** — any pipeline engine
|
|
185
|
+
emitting "a stage was entered" shares `wicked.stage.entered`, regardless of which
|
|
186
|
+
tool it is.
|
|
187
|
+
|
|
188
|
+
### Suggested domain & subdomain
|
|
189
|
+
|
|
190
|
+
- **`domain`** = the engine/orchestrator's identity (its package or tool name),
|
|
191
|
+
e.g. `domain = "engine"` (or `migration-factory`, `anti-legacy`, …). One domain
|
|
192
|
+
per engine — don't subdivide here.
|
|
193
|
+
- **`subdomain`** = `lifecycle.<stage>` — the functional area plus the specific
|
|
194
|
+
stage the event concerns, e.g. `lifecycle.transform`, `lifecycle.validate`,
|
|
195
|
+
`lifecycle.cutover`. *Which* stage is identity, not semantics, so it belongs in
|
|
196
|
+
the column, never in the event_type.
|
|
197
|
+
|
|
198
|
+
### How a multi-stage pipeline names its transitions
|
|
199
|
+
|
|
200
|
+
A 7-stage pipeline does **not** invent a new event_type per stage. It reuses the
|
|
201
|
+
five semantic types above and distinguishes the stage via `subdomain`:
|
|
202
|
+
|
|
203
|
+
```javascript
|
|
204
|
+
import { emit } from 'wicked-bus';
|
|
205
|
+
|
|
206
|
+
// Entering the "transform" stage
|
|
207
|
+
emit(db, config, {
|
|
208
|
+
event_type: 'wicked.stage.entered',
|
|
209
|
+
domain: 'engine',
|
|
210
|
+
subdomain: 'lifecycle.transform',
|
|
211
|
+
payload: { stage: 'transform', stage_number: 5, ref: '<authoritative-state-id>' },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The gate guarding the transform→validate transition clears
|
|
215
|
+
emit(db, config, {
|
|
216
|
+
event_type: 'wicked.gate.cleared',
|
|
217
|
+
domain: 'engine',
|
|
218
|
+
subdomain: 'lifecycle.transform',
|
|
219
|
+
payload: { gate: 'transform', approver: 'architect', spec_version: '3.2.0' },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// A later gate withholds approval
|
|
223
|
+
emit(db, config, {
|
|
224
|
+
event_type: 'wicked.gate.blocked',
|
|
225
|
+
domain: 'engine',
|
|
226
|
+
subdomain: 'lifecycle.validate',
|
|
227
|
+
payload: { gate: 'validate', reason: 'acceptance criteria not green' },
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Why this shape filters well
|
|
232
|
+
|
|
233
|
+
Because the stage lives in `subdomain` and the engine in `domain`, subscribers
|
|
234
|
+
get expressive filters for free:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# Every gate outcome from any engine, any stage
|
|
238
|
+
wicked-bus subscribe --filter 'wicked.gate.*'
|
|
239
|
+
|
|
240
|
+
# Every stage transition from any engine, any stage
|
|
241
|
+
wicked-bus subscribe --filter 'wicked.stage.*'
|
|
242
|
+
|
|
243
|
+
# Everything a specific engine emits across its whole lifecycle
|
|
244
|
+
wicked-bus subscribe --filter '*@engine'
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### The bus is transport, not the system of record
|
|
248
|
+
|
|
249
|
+
These events **announce** transitions; they do not **store** lifecycle state. The
|
|
250
|
+
bus is fire-and-forget transport and TTL-sweeps payloads — authoritative lifecycle
|
|
251
|
+
state (which stage you're in, who signed which gate) lives in the pipeline tool's
|
|
252
|
+
own durable store (its spec headers, DB, or audit log), not on the bus. Put a
|
|
253
|
+
reference (an id) in the payload and resolve details from the system of record;
|
|
254
|
+
never treat a polled event as the source of truth for current state.
|