wicked-bus 1.0.0 → 1.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/README.md +2 -0
- package/commands/cli.js +12 -1
- package/commands/cmd-dlq.js +102 -0
- package/install.mjs +100 -20
- package/lib/db.js +1 -1
- package/lib/dlq.js +144 -0
- package/lib/index.js +2 -0
- package/lib/schema.sql +52 -0
- package/lib/subscribe.js +452 -0
- package/package.json +10 -2
- package/skills/wicked-bus/emit/SKILL.md +1 -0
- package/skills/wicked-bus/init/SKILL.md +1 -0
- package/skills/wicked-bus/naming/SKILL.md +1 -0
- package/skills/wicked-bus/query/SKILL.md +1 -0
- package/skills/wicked-bus/status/SKILL.md +108 -0
- package/skills/wicked-bus/subscribe/SKILL.md +1 -0
- package/skills/wicked-bus/update/SKILL.md +122 -0
package/README.md
CHANGED
|
@@ -125,6 +125,8 @@ Auto-detects installed CLIs and copies skills. Available skills:
|
|
|
125
125
|
| `wicked-bus/subscribe` | Consume events |
|
|
126
126
|
| `wicked-bus/naming` | Event naming conventions |
|
|
127
127
|
| `wicked-bus/query` | Query and debug |
|
|
128
|
+
| `wicked-bus/status` | Bus health and diagnostics |
|
|
129
|
+
| `wicked-bus/update` | Check for and install updates |
|
|
128
130
|
|
|
129
131
|
## Why wicked-bus?
|
|
130
132
|
|
package/commands/cli.js
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { WBError, EXIT_CODES } from '../lib/errors.js';
|
|
8
8
|
|
|
9
|
-
// Argument parser
|
|
9
|
+
// Argument parser. Returns flags + positional args (anything that isn't --flag
|
|
10
|
+
// or its value). Positional args are needed for subcommands like `dlq list`.
|
|
10
11
|
function parseArgs(argv) {
|
|
11
12
|
const args = {};
|
|
13
|
+
const positional = [];
|
|
12
14
|
for (let i = 0; i < argv.length; i++) {
|
|
13
15
|
if (argv[i].startsWith('--')) {
|
|
14
16
|
const key = argv[i].slice(2);
|
|
@@ -17,8 +19,11 @@ function parseArgs(argv) {
|
|
|
17
19
|
} else {
|
|
18
20
|
args[key] = argv[++i];
|
|
19
21
|
}
|
|
22
|
+
} else {
|
|
23
|
+
positional.push(argv[i]);
|
|
20
24
|
}
|
|
21
25
|
}
|
|
26
|
+
args._positional = positional;
|
|
22
27
|
return args;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -28,6 +33,7 @@ function printUsage() {
|
|
|
28
33
|
commands: [
|
|
29
34
|
'init', 'emit', 'subscribe', 'status', 'replay',
|
|
30
35
|
'cleanup', 'register', 'deregister', 'list', 'ack',
|
|
36
|
+
'dlq',
|
|
31
37
|
],
|
|
32
38
|
global_flags: ['--db-path <path>', '--json', '--log-level <level>'],
|
|
33
39
|
};
|
|
@@ -117,6 +123,11 @@ async function main() {
|
|
|
117
123
|
await cmdAck(args, globals);
|
|
118
124
|
break;
|
|
119
125
|
}
|
|
126
|
+
case 'dlq': {
|
|
127
|
+
const { cmdDlq } = await import('./cmd-dlq.js');
|
|
128
|
+
await cmdDlq(args, globals, args._positional || []);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
120
131
|
default:
|
|
121
132
|
printUsage();
|
|
122
133
|
process.exit(command ? 1 : 0);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wicked-bus dlq command — list, replay, drop dead-lettered events.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadConfig } from '../lib/config.js';
|
|
6
|
+
import { openDb } from '../lib/db.js';
|
|
7
|
+
import { listDeadLetters, replayDeadLetter, dropDeadLetter } from '../lib/dlq.js';
|
|
8
|
+
import { WBError } from '../lib/errors.js';
|
|
9
|
+
|
|
10
|
+
export async function cmdDlq(args, globals, positional = []) {
|
|
11
|
+
const subcommand = positional[0];
|
|
12
|
+
|
|
13
|
+
if (!subcommand) {
|
|
14
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
15
|
+
message: 'dlq requires a subcommand: list | replay | drop',
|
|
16
|
+
reason: 'missing dlq subcommand',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const configOverrides = {};
|
|
21
|
+
if (globals.db_path) configOverrides.db_path = globals.db_path;
|
|
22
|
+
if (globals.log_level) configOverrides.log_level = globals.log_level;
|
|
23
|
+
|
|
24
|
+
const config = loadConfig(configOverrides);
|
|
25
|
+
const db = openDb(config);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
switch (subcommand) {
|
|
29
|
+
case 'list': {
|
|
30
|
+
const opts = {};
|
|
31
|
+
if (args.plugin) opts.plugin = args.plugin;
|
|
32
|
+
if (args['cursor-id']) opts.cursorId = args['cursor-id'];
|
|
33
|
+
if (args.limit) opts.limit = parseInt(args.limit, 10);
|
|
34
|
+
const rows = listDeadLetters(db, opts);
|
|
35
|
+
process.stdout.write(JSON.stringify({ dead_letters: rows, count: rows.length }) + '\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'replay': {
|
|
40
|
+
const dlId = parseDlId(args);
|
|
41
|
+
if (args['dry-run']) {
|
|
42
|
+
const row = db.prepare('SELECT * FROM dead_letters WHERE dl_id = ?').get(dlId);
|
|
43
|
+
if (!row) {
|
|
44
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
45
|
+
message: `Dead letter not found: ${dlId}`,
|
|
46
|
+
dl_id: dlId,
|
|
47
|
+
reason: 'dead letter row not found',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write(JSON.stringify({
|
|
51
|
+
dry_run: true,
|
|
52
|
+
would_replay: {
|
|
53
|
+
dl_id: row.dl_id,
|
|
54
|
+
event_id: row.event_id,
|
|
55
|
+
event_type: row.event_type,
|
|
56
|
+
domain: row.domain,
|
|
57
|
+
attempts: row.attempts,
|
|
58
|
+
last_error: row.last_error,
|
|
59
|
+
},
|
|
60
|
+
}) + '\n');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const result = replayDeadLetter(db, dlId);
|
|
64
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case 'drop': {
|
|
69
|
+
const dlId = parseDlId(args);
|
|
70
|
+
const result = dropDeadLetter(db, dlId);
|
|
71
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
default:
|
|
76
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
77
|
+
message: `Unknown dlq subcommand: ${subcommand}`,
|
|
78
|
+
reason: 'unknown dlq subcommand',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseDlId(args) {
|
|
87
|
+
const raw = args['dl-id'];
|
|
88
|
+
if (raw == null || raw === true) {
|
|
89
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
90
|
+
message: '--dl-id <number> is required',
|
|
91
|
+
reason: 'missing --dl-id',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const dlId = parseInt(raw, 10);
|
|
95
|
+
if (!Number.isInteger(dlId) || dlId <= 0) {
|
|
96
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
97
|
+
message: `--dl-id must be a positive integer, got: ${raw}`,
|
|
98
|
+
reason: 'invalid --dl-id',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return dlId;
|
|
102
|
+
}
|
package/install.mjs
CHANGED
|
@@ -11,8 +11,46 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
|
11
11
|
const skillsSource = join(__dirname, "skills");
|
|
12
12
|
const home = homedir();
|
|
13
13
|
|
|
14
|
+
// Claude-root candidate builder. Mirrors the 1.1.1 wicked-testing /
|
|
15
|
+
// wicked-brain fix: $CLAUDE_CONFIG_DIR is authoritative when set;
|
|
16
|
+
// otherwise probe common alt-config layouts. Claude Code's config root
|
|
17
|
+
// is redirectable, and hardcoded ~/.claude silently misses users on
|
|
18
|
+
// shared-home / multi-tenant setups.
|
|
19
|
+
function buildClaudeTarget(rootDir, source, { trusted = false } = {}) {
|
|
20
|
+
return {
|
|
21
|
+
name: "claude",
|
|
22
|
+
rootDir,
|
|
23
|
+
dir: join(rootDir, "skills"),
|
|
24
|
+
platform: "claude",
|
|
25
|
+
identityMarkers: ["settings.json", "plugins", "projects"],
|
|
26
|
+
source,
|
|
27
|
+
trusted,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveClaudeCandidates() {
|
|
32
|
+
const envDir = process.env.CLAUDE_CONFIG_DIR;
|
|
33
|
+
if (envDir && typeof envDir === "string" && envDir.trim()) {
|
|
34
|
+
// Function replacement avoids `$&` etc. being interpreted as regex
|
|
35
|
+
// back-references if $HOME contains those literals.
|
|
36
|
+
const root = resolve(envDir.trim().replace(/^~/, () => home));
|
|
37
|
+
return [buildClaudeTarget(root, "env:CLAUDE_CONFIG_DIR", { trusted: true })];
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
buildClaudeTarget(join(home, ".claude"), "default"),
|
|
41
|
+
buildClaudeTarget(join(home, "alt-configs", ".claude"), "alt-configs"),
|
|
42
|
+
buildClaudeTarget(join(home, ".config", "claude"), "xdg"),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function claudeHasIdentityMarker(target) {
|
|
47
|
+
if (target.trusted) return true;
|
|
48
|
+
if (!existsSync(target.rootDir)) return false;
|
|
49
|
+
return (target.identityMarkers || []).some(m => existsSync(join(target.rootDir, m)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Non-claude canonical targets. Claude is expanded dynamically above.
|
|
14
53
|
const CLI_TARGETS = [
|
|
15
|
-
{ name: "claude", dir: join(home, ".claude", "skills"), platform: "claude" },
|
|
16
54
|
{ name: "gemini", dir: join(home, ".gemini", "skills"), platform: "gemini" },
|
|
17
55
|
{ name: "copilot", dir: join(home, ".github", "skills"), platform: "copilot" },
|
|
18
56
|
{ name: "codex", dir: join(home, ".codex", "skills"), platform: "codex" },
|
|
@@ -24,19 +62,43 @@ const CLI_TARGETS = [
|
|
|
24
62
|
console.log("wicked-bus installer\n");
|
|
25
63
|
|
|
26
64
|
const args = argv.slice(2);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
65
|
+
|
|
66
|
+
// Flag parser supporting both --flag=value and --flag value forms, plus
|
|
67
|
+
// narrow string-boolean coercion ("true" / "false" → booleans). Previously
|
|
68
|
+
// the ad-hoc parser silently dropped space-separated values — same bug
|
|
69
|
+
// that hit wicked-testing 0.3.2 / wicked-brain 0.3.7.
|
|
70
|
+
const flagValue = (name) => {
|
|
71
|
+
const f = args.find(a => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
72
|
+
if (!f) return null;
|
|
73
|
+
let val;
|
|
74
|
+
if (f.includes("=")) {
|
|
75
|
+
// slice from the first '=' forward — split("=")[1] would truncate at
|
|
76
|
+
// the second '=' (e.g. --path=/volumes/build=artifacts).
|
|
77
|
+
val = f.slice(f.indexOf("=") + 1);
|
|
78
|
+
} else {
|
|
79
|
+
const idx = args.indexOf(f);
|
|
80
|
+
const next = args[idx + 1];
|
|
81
|
+
val = (next && !next.startsWith("-")) ? next : true;
|
|
82
|
+
}
|
|
83
|
+
if (val === "false") return false;
|
|
84
|
+
if (val === "true") return true;
|
|
85
|
+
return val;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const cliArg = flagValue("cli");
|
|
89
|
+
const pathArg = flagValue("path");
|
|
90
|
+
|
|
91
|
+
// Validate --cli upfront so a mistyped --cli / --cli= fails fast
|
|
92
|
+
// instead of silently falling through to "all detected".
|
|
93
|
+
if (cliArg === true || cliArg === "") {
|
|
94
|
+
console.error("Error: --cli requires a value (e.g. --cli=claude or --cli claude)");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
30
97
|
|
|
31
98
|
let targets;
|
|
32
99
|
|
|
33
|
-
if (pathArg) {
|
|
34
|
-
const
|
|
35
|
-
if (!rawPath) {
|
|
36
|
-
console.error("Error: --path requires a value (e.g. --path=~/.claude)");
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
const customPath = resolve(rawPath.replace(/^~/, home));
|
|
100
|
+
if (pathArg && typeof pathArg === "string" && pathArg !== "") {
|
|
101
|
+
const customPath = resolve(pathArg.replace(/^~/, () => home));
|
|
40
102
|
const dirName = basename(customPath).replace(/^\./, "");
|
|
41
103
|
targets = [{
|
|
42
104
|
name: dirName,
|
|
@@ -44,35 +106,51 @@ if (pathArg) {
|
|
|
44
106
|
platform: dirName,
|
|
45
107
|
}];
|
|
46
108
|
console.log(`Custom path: ${customPath}\n`);
|
|
109
|
+
} else if (pathArg === true || pathArg === "") {
|
|
110
|
+
console.error("Error: --path requires a value (e.g. --path=~/.claude or --path ~/.claude)");
|
|
111
|
+
process.exit(1);
|
|
47
112
|
} else {
|
|
48
|
-
|
|
113
|
+
// Expanded detection: claude candidates (env var OR alt-config probes,
|
|
114
|
+
// identity-marker gated) + non-claude parent-dir-exists heuristic.
|
|
115
|
+
const claudeDetected = resolveClaudeCandidates().filter(claudeHasIdentityMarker);
|
|
116
|
+
const otherDetected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
|
|
117
|
+
const detected = [...claudeDetected, ...otherDetected];
|
|
49
118
|
|
|
50
119
|
if (detected.length === 0) {
|
|
51
120
|
console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
|
|
52
|
-
console.log("Install skills manually by copying the skills/ directory.");
|
|
121
|
+
console.log("Install skills manually by copying the skills/ directory, or set CLAUDE_CONFIG_DIR.");
|
|
53
122
|
process.exit(1);
|
|
54
123
|
}
|
|
55
124
|
|
|
56
|
-
|
|
125
|
+
const claudeCount = claudeDetected.length;
|
|
126
|
+
const label = (d) => d.name === "claude" && claudeCount > 1 && d.source
|
|
127
|
+
? `${d.name}[${d.source}]`
|
|
128
|
+
: d.name;
|
|
129
|
+
console.log(`Detected CLIs: ${detected.map(label).join(", ")}\n`);
|
|
57
130
|
|
|
58
|
-
const cliFilter = cliArg ?
|
|
131
|
+
const cliFilter = (typeof cliArg === "string" && cliArg !== "") ? cliArg.split(",") : null;
|
|
59
132
|
targets = cliFilter ? detected.filter((d) => cliFilter.includes(d.name)) : detected;
|
|
60
133
|
}
|
|
61
134
|
|
|
62
135
|
// Copy skills to each target CLI
|
|
63
|
-
|
|
136
|
+
// Repo structure: skills/wicked-bus/{name}/SKILL.md (nested namespace)
|
|
137
|
+
// Installed structure: {cli}/skills/wicked-bus-{name}/SKILL.md (flat, one level deep)
|
|
138
|
+
// CLI skill discovery only scans one level deep under the skills directory.
|
|
139
|
+
const namespace = "wicked-bus";
|
|
140
|
+
const namespaceSrc = join(skillsSource, namespace);
|
|
141
|
+
const subSkills = readdirSync(namespaceSrc).filter((d) => !d.startsWith("."));
|
|
64
142
|
|
|
65
143
|
for (const target of targets) {
|
|
66
144
|
console.log(`Installing to ${target.name} (${target.dir})...`);
|
|
67
145
|
mkdirSync(target.dir, { recursive: true });
|
|
68
146
|
|
|
69
|
-
for (const skill of
|
|
70
|
-
const src = join(
|
|
71
|
-
const dest = join(target.dir, skill);
|
|
147
|
+
for (const skill of subSkills) {
|
|
148
|
+
const src = join(namespaceSrc, skill);
|
|
149
|
+
const dest = join(target.dir, `${namespace}-${skill}`);
|
|
72
150
|
cpSync(src, dest, { recursive: true });
|
|
73
151
|
}
|
|
74
152
|
|
|
75
|
-
console.log(` ${
|
|
153
|
+
console.log(` ${subSkills.length} skills installed`);
|
|
76
154
|
}
|
|
77
155
|
|
|
78
156
|
console.log(`\nwicked-bus skills installed! Available skills:`);
|
|
@@ -81,3 +159,5 @@ console.log(` wicked-bus/emit — Publish events`);
|
|
|
81
159
|
console.log(` wicked-bus/subscribe — Consume events`);
|
|
82
160
|
console.log(` wicked-bus/naming — Event naming conventions`);
|
|
83
161
|
console.log(` wicked-bus/query — Query and debug the bus`);
|
|
162
|
+
console.log(` wicked-bus/status — Bus health and diagnostics`);
|
|
163
|
+
console.log(` wicked-bus/update — Check for and install updates`);
|
package/lib/db.js
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveDbPath, ensureDataDir } from './paths.js';
|
|
|
12
12
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const require = createRequire(import.meta.url);
|
|
14
14
|
const SCHEMA_SQL_PATH = join(__dirname, 'schema.sql');
|
|
15
|
-
const MAX_SUPPORTED_SCHEMA_VERSION =
|
|
15
|
+
const MAX_SUPPORTED_SCHEMA_VERSION = 2;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Open (or create) the SQLite database, apply PRAGMAs and schema.
|
package/lib/dlq.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dead-letter queue inspection and operator controls.
|
|
3
|
+
* @module lib/dlq
|
|
4
|
+
*
|
|
5
|
+
* Read-only listing, replay request, and drop. The managed subscribe() helper
|
|
6
|
+
* in lib/subscribe.js owns the corresponding write paths (DLQ insertion on
|
|
7
|
+
* retry exhaustion, replay drain on each poll cycle).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { WBError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* List dead-lettered events, most recent first.
|
|
14
|
+
*
|
|
15
|
+
* Caveat: dead_letters rows are denormalized snapshots of the originating
|
|
16
|
+
* event taken at DLQ time. The original row in `events` may have been swept
|
|
17
|
+
* by `dedup_expires_at` (24h default) by the time the DLQ entry is read, so
|
|
18
|
+
* the returned `payload` / `event_type` / `domain` / `subdomain` reflect the
|
|
19
|
+
* event as it existed when it failed, not the current state of `events`.
|
|
20
|
+
*
|
|
21
|
+
* @param {import('better-sqlite3').Database} db
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {string} [opts.plugin] - Filter to a single subscriber plugin
|
|
24
|
+
* @param {string} [opts.cursorId] - Filter to a single cursor
|
|
25
|
+
* @param {number} [opts.limit=100] - Max rows to return
|
|
26
|
+
* @returns {object[]} Dead letter rows with `payload` parsed from JSON
|
|
27
|
+
*/
|
|
28
|
+
export function listDeadLetters(db, opts = {}) {
|
|
29
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 100;
|
|
30
|
+
|
|
31
|
+
const conditions = [];
|
|
32
|
+
const params = { limit };
|
|
33
|
+
|
|
34
|
+
if (opts.plugin) {
|
|
35
|
+
conditions.push(`dl.subscription_id IN (
|
|
36
|
+
SELECT subscription_id FROM subscriptions WHERE plugin = :plugin
|
|
37
|
+
)`);
|
|
38
|
+
params.plugin = opts.plugin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (opts.cursorId) {
|
|
42
|
+
conditions.push('dl.cursor_id = :cursor_id');
|
|
43
|
+
params.cursor_id = opts.cursorId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
47
|
+
|
|
48
|
+
const sql = `
|
|
49
|
+
SELECT
|
|
50
|
+
dl.dl_id,
|
|
51
|
+
dl.cursor_id,
|
|
52
|
+
dl.subscription_id,
|
|
53
|
+
dl.event_id,
|
|
54
|
+
dl.event_type,
|
|
55
|
+
dl.domain,
|
|
56
|
+
dl.subdomain,
|
|
57
|
+
dl.payload,
|
|
58
|
+
dl.emitted_at,
|
|
59
|
+
dl.attempts,
|
|
60
|
+
dl.last_error,
|
|
61
|
+
dl.dead_lettered_at,
|
|
62
|
+
dl.replay_requested_at,
|
|
63
|
+
s.plugin
|
|
64
|
+
FROM dead_letters dl
|
|
65
|
+
LEFT JOIN subscriptions s ON s.subscription_id = dl.subscription_id
|
|
66
|
+
${where}
|
|
67
|
+
ORDER BY dl.dead_lettered_at DESC, dl.dl_id DESC
|
|
68
|
+
LIMIT :limit
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const rows = db.prepare(sql).all(params);
|
|
72
|
+
|
|
73
|
+
return rows.map(row => ({
|
|
74
|
+
...row,
|
|
75
|
+
payload: parsePayload(row.payload),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mark a dead-lettered event for replay. The next tick of the managed
|
|
81
|
+
* subscribe() loop for this cursor will drain pending replays before normal
|
|
82
|
+
* polling. Replay is a single attempt — no automatic retry. On success the
|
|
83
|
+
* DLQ row is deleted; on failure `replay_requested_at` is cleared and
|
|
84
|
+
* `attempts` / `last_error` are updated so the operator can re-inspect.
|
|
85
|
+
*
|
|
86
|
+
* Caveat: if the handler re-emits during replay, the original event's
|
|
87
|
+
* `idempotency_key` may already have been swept from `events` (24h
|
|
88
|
+
* `dedup_expires_at`), so the re-emission will not be deduped against the
|
|
89
|
+
* original. Replay is for recovery after fixing a bug, not transparent retry.
|
|
90
|
+
*
|
|
91
|
+
* @param {import('better-sqlite3').Database} db
|
|
92
|
+
* @param {number} dlId - dl_id of the dead_letters row to replay
|
|
93
|
+
* @returns {{ replayed: boolean, dl_id: number, replay_requested_at: number }}
|
|
94
|
+
*/
|
|
95
|
+
export function replayDeadLetter(db, dlId) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const result = db.prepare(`
|
|
98
|
+
UPDATE dead_letters
|
|
99
|
+
SET replay_requested_at = ?
|
|
100
|
+
WHERE dl_id = ?
|
|
101
|
+
`).run(now, dlId);
|
|
102
|
+
|
|
103
|
+
if (result.changes === 0) {
|
|
104
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
105
|
+
message: `Dead letter not found: ${dlId}`,
|
|
106
|
+
dl_id: dlId,
|
|
107
|
+
reason: 'dead letter row not found',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { replayed: true, dl_id: dlId, replay_requested_at: now };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Permanently drop a dead-lettered event. Use this when an event is
|
|
116
|
+
* unrecoverable and the operator does not want it consuming DLQ slots.
|
|
117
|
+
*
|
|
118
|
+
* @param {import('better-sqlite3').Database} db
|
|
119
|
+
* @param {number} dlId
|
|
120
|
+
* @returns {{ dropped: boolean, dl_id: number }}
|
|
121
|
+
*/
|
|
122
|
+
export function dropDeadLetter(db, dlId) {
|
|
123
|
+
const result = db.prepare('DELETE FROM dead_letters WHERE dl_id = ?').run(dlId);
|
|
124
|
+
if (result.changes === 0) {
|
|
125
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
126
|
+
message: `Dead letter not found: ${dlId}`,
|
|
127
|
+
dl_id: dlId,
|
|
128
|
+
reason: 'dead letter row not found',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return { dropped: true, dl_id: dlId };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parsePayload(raw) {
|
|
135
|
+
if (raw == null) return null;
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(raw);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
140
|
+
message: 'dead_letters row has malformed JSON payload',
|
|
141
|
+
reason: err.message,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -10,4 +10,6 @@ export { openDb } from './db.js';
|
|
|
10
10
|
export { loadConfig } from './config.js';
|
|
11
11
|
export { resolveDataDir, ensureDataDir, resolveDbPath } from './paths.js';
|
|
12
12
|
export { startSweep, runSweep } from './sweep.js';
|
|
13
|
+
export { listDeadLetters, replayDeadLetter, dropDeadLetter } from './dlq.js';
|
|
14
|
+
export { subscribe } from './subscribe.js';
|
|
13
15
|
export { WBError, ERROR_CODES, EXIT_CODES } from './errors.js';
|
package/lib/schema.sql
CHANGED
|
@@ -57,6 +57,55 @@ CREATE INDEX IF NOT EXISTS idx_cursors_subscription_id ON cursors(subscription_i
|
|
|
57
57
|
CREATE INDEX IF NOT EXISTS idx_cursors_active
|
|
58
58
|
ON cursors(subscription_id) WHERE deregistered_at IS NULL;
|
|
59
59
|
|
|
60
|
+
-- dead_letters: events that exhausted retries for a specific cursor.
|
|
61
|
+
-- Physically separate from events so poll()'s WB-003 MIN(event_id) check stays
|
|
62
|
+
-- correct. Denormalized so rows survive the 24h dedup_expires_at sweep of the
|
|
63
|
+
-- originating event. No automatic TTL — operator-managed via dlq subcommands.
|
|
64
|
+
CREATE TABLE IF NOT EXISTS dead_letters (
|
|
65
|
+
dl_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
cursor_id TEXT NOT NULL
|
|
67
|
+
REFERENCES cursors(cursor_id) ON DELETE RESTRICT,
|
|
68
|
+
subscription_id TEXT NOT NULL
|
|
69
|
+
REFERENCES subscriptions(subscription_id) ON DELETE RESTRICT,
|
|
70
|
+
event_id INTEGER NOT NULL,
|
|
71
|
+
event_type TEXT NOT NULL,
|
|
72
|
+
domain TEXT NOT NULL,
|
|
73
|
+
subdomain TEXT NOT NULL DEFAULT '',
|
|
74
|
+
payload TEXT NOT NULL,
|
|
75
|
+
emitted_at INTEGER NOT NULL,
|
|
76
|
+
attempts INTEGER NOT NULL,
|
|
77
|
+
last_error TEXT,
|
|
78
|
+
dead_lettered_at INTEGER NOT NULL,
|
|
79
|
+
-- Replay queue marker set by replayDeadLetter(). The managed subscribe
|
|
80
|
+
-- loop drains rows with non-null replay_requested_at before polling new
|
|
81
|
+
-- events. Cleared on replay success (row deleted) or failure (row
|
|
82
|
+
-- updated with new attempts/last_error so the operator can re-inspect).
|
|
83
|
+
replay_requested_at INTEGER
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_cursor_id ON dead_letters(cursor_id);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_subscription_id ON dead_letters(subscription_id);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_dead_lettered_at ON dead_letters(dead_lettered_at);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_event_id ON dead_letters(event_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_replay_pending
|
|
91
|
+
ON dead_letters(cursor_id, replay_requested_at)
|
|
92
|
+
WHERE replay_requested_at IS NOT NULL;
|
|
93
|
+
|
|
94
|
+
-- delivery_attempts: per-cursor retry state for events currently in flight.
|
|
95
|
+
-- Created on first handler failure, deleted on successful ack or DLQ transition.
|
|
96
|
+
-- Survives process restarts so the retry counter is restored on the next poll.
|
|
97
|
+
CREATE TABLE IF NOT EXISTS delivery_attempts (
|
|
98
|
+
cursor_id TEXT NOT NULL
|
|
99
|
+
REFERENCES cursors(cursor_id) ON DELETE CASCADE,
|
|
100
|
+
event_id INTEGER NOT NULL,
|
|
101
|
+
attempts INTEGER NOT NULL DEFAULT 1,
|
|
102
|
+
last_attempt_at INTEGER NOT NULL,
|
|
103
|
+
last_error TEXT,
|
|
104
|
+
PRIMARY KEY (cursor_id, event_id)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_cursor_id ON delivery_attempts(cursor_id);
|
|
108
|
+
|
|
60
109
|
-- schema_migrations
|
|
61
110
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
62
111
|
version INTEGER PRIMARY KEY,
|
|
@@ -66,3 +115,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
|
66
115
|
|
|
67
116
|
INSERT OR IGNORE INTO schema_migrations(version, applied_at, description)
|
|
68
117
|
VALUES (1, unixepoch() * 1000, 'initial schema');
|
|
118
|
+
|
|
119
|
+
INSERT OR IGNORE INTO schema_migrations(version, applied_at, description)
|
|
120
|
+
VALUES (2, unixepoch() * 1000, 'add dead_letters and delivery_attempts tables');
|
package/lib/subscribe.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed long-running subscriber helper.
|
|
3
|
+
* @module lib/subscribe
|
|
4
|
+
*
|
|
5
|
+
* Layered on top of register/poll/ack — handles the poll loop, error
|
|
6
|
+
* isolation, retry/backoff, dead-lettering, lifecycle, lag introspection,
|
|
7
|
+
* and replay drain.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - Serial per subscription: events are processed one at a time in cursor
|
|
11
|
+
* order. The next poll does not start until the current batch is drained.
|
|
12
|
+
* - Retry state lives in `delivery_attempts` so it survives process restarts.
|
|
13
|
+
* On the next poll after a crash, the loop reads the existing attempt count
|
|
14
|
+
* and resumes from where it left off.
|
|
15
|
+
* - On exhaustion, the event is copied (denormalized) into `dead_letters` and
|
|
16
|
+
* the cursor is acked past it. The original `events` row may be swept by
|
|
17
|
+
* the 24h `dedup_expires_at` later — the DLQ row is self-contained.
|
|
18
|
+
* - `stop()` cancels any in-flight backoff timer, dead-letters the sleeping
|
|
19
|
+
* event, and acks the cursor. Cursor cleanliness beats avoiding an
|
|
20
|
+
* unexpected DLQ entry the operator can replay.
|
|
21
|
+
* - `replayDeadLetter()` sets `replay_requested_at`. The loop drains pending
|
|
22
|
+
* replays before each normal poll — success deletes the DLQ row, failure
|
|
23
|
+
* clears `replay_requested_at` and updates `attempts` / `last_error`.
|
|
24
|
+
*
|
|
25
|
+
* Caveats:
|
|
26
|
+
* - At-least-once delivery + retries means the handler may be invoked more
|
|
27
|
+
* than once for the same logical event. Handlers must be idempotent.
|
|
28
|
+
* - If a DLQ entry is replayed and the handler re-emits as part of recovery,
|
|
29
|
+
* the original `idempotency_key` may already have been swept from `events`
|
|
30
|
+
* (24h `dedup_expires_at`). The re-emission will not be deduped against
|
|
31
|
+
* the original. Replay is for recovery, not for transparent retry.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { poll, ack } from './poll.js';
|
|
35
|
+
import { register } from './register.js';
|
|
36
|
+
|
|
37
|
+
const DEFAULTS = Object.freeze({
|
|
38
|
+
pollIntervalMs: 15000,
|
|
39
|
+
batchSize: 50,
|
|
40
|
+
maxRetries: 0,
|
|
41
|
+
backoffMs: 1000,
|
|
42
|
+
lagIntervalMs: 60000,
|
|
43
|
+
cursor_init: 'latest',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resume an existing subscription by (plugin, filter), or register a new one.
|
|
48
|
+
* Internal — not exported. Direct callers of register() keep the original
|
|
49
|
+
* "always create a new UUID" semantics.
|
|
50
|
+
*
|
|
51
|
+
* @param {import('better-sqlite3').Database} db
|
|
52
|
+
* @param {object} opts
|
|
53
|
+
* @returns {{ subscription_id: string, cursor_id: string, created: boolean }}
|
|
54
|
+
*/
|
|
55
|
+
function registerOrResume(db, opts) {
|
|
56
|
+
const existing = db.prepare(`
|
|
57
|
+
SELECT s.subscription_id, c.cursor_id
|
|
58
|
+
FROM subscriptions s
|
|
59
|
+
INNER JOIN cursors c ON c.subscription_id = s.subscription_id
|
|
60
|
+
WHERE s.plugin = ?
|
|
61
|
+
AND s.role = 'subscriber'
|
|
62
|
+
AND s.event_type_filter = ?
|
|
63
|
+
AND s.deregistered_at IS NULL
|
|
64
|
+
AND c.deregistered_at IS NULL
|
|
65
|
+
ORDER BY s.registered_at DESC
|
|
66
|
+
LIMIT 1
|
|
67
|
+
`).get(opts.plugin, opts.filter);
|
|
68
|
+
|
|
69
|
+
if (existing) {
|
|
70
|
+
return {
|
|
71
|
+
subscription_id: existing.subscription_id,
|
|
72
|
+
cursor_id: existing.cursor_id,
|
|
73
|
+
created: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fresh = register(db, {
|
|
78
|
+
plugin: opts.plugin,
|
|
79
|
+
role: 'subscriber',
|
|
80
|
+
filter: opts.filter,
|
|
81
|
+
cursor_init: opts.cursor_init,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
subscription_id: fresh.subscription_id,
|
|
86
|
+
cursor_id: fresh.cursor_id,
|
|
87
|
+
created: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Normalize backoffMs into an array of length maxRetries with last-element
|
|
93
|
+
* repeat semantics. A single number becomes a constant array.
|
|
94
|
+
*/
|
|
95
|
+
function normalizeBackoff(input, maxRetries) {
|
|
96
|
+
if (maxRetries <= 0) return [];
|
|
97
|
+
const source = Array.isArray(input) ? input : [input ?? DEFAULTS.backoffMs];
|
|
98
|
+
if (source.length === 0) return new Array(maxRetries).fill(DEFAULTS.backoffMs);
|
|
99
|
+
const out = new Array(maxRetries);
|
|
100
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
101
|
+
out[i] = source[Math.min(i, source.length - 1)];
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the current attempt count for an in-flight retry, or 0 if none.
|
|
108
|
+
*/
|
|
109
|
+
function getAttempts(db, cursorId, eventId) {
|
|
110
|
+
const row = db.prepare(
|
|
111
|
+
'SELECT attempts FROM delivery_attempts WHERE cursor_id = ? AND event_id = ?'
|
|
112
|
+
).get(cursorId, eventId);
|
|
113
|
+
return row ? row.attempts : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function upsertDeliveryAttempt(db, cursorId, eventId, attempts, lastError) {
|
|
117
|
+
db.prepare(`
|
|
118
|
+
INSERT INTO delivery_attempts (cursor_id, event_id, attempts, last_attempt_at, last_error)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?)
|
|
120
|
+
ON CONFLICT(cursor_id, event_id) DO UPDATE SET
|
|
121
|
+
attempts = excluded.attempts,
|
|
122
|
+
last_attempt_at = excluded.last_attempt_at,
|
|
123
|
+
last_error = excluded.last_error
|
|
124
|
+
`).run(cursorId, eventId, attempts, Date.now(), lastError ?? null);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function deleteDeliveryAttempt(db, cursorId, eventId) {
|
|
128
|
+
db.prepare(
|
|
129
|
+
'DELETE FROM delivery_attempts WHERE cursor_id = ? AND event_id = ?'
|
|
130
|
+
).run(cursorId, eventId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function moveToDeadLetter(db, cursorId, subscriptionId, event, attempts, reason) {
|
|
134
|
+
db.prepare(`
|
|
135
|
+
INSERT INTO dead_letters (
|
|
136
|
+
cursor_id, subscription_id, event_id, event_type, domain, subdomain,
|
|
137
|
+
payload, emitted_at, attempts, last_error, dead_lettered_at
|
|
138
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
139
|
+
`).run(
|
|
140
|
+
cursorId,
|
|
141
|
+
subscriptionId,
|
|
142
|
+
event.event_id,
|
|
143
|
+
event.event_type,
|
|
144
|
+
event.domain,
|
|
145
|
+
event.subdomain ?? '',
|
|
146
|
+
typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload),
|
|
147
|
+
event.emitted_at,
|
|
148
|
+
attempts,
|
|
149
|
+
reason ?? null,
|
|
150
|
+
Date.now()
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseEvent(row) {
|
|
155
|
+
let payload = row.payload;
|
|
156
|
+
if (typeof payload === 'string') {
|
|
157
|
+
try { payload = JSON.parse(payload); } catch (_) { /* leave as string */ }
|
|
158
|
+
}
|
|
159
|
+
return { ...row, payload };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Compute lag for a cursor — how far behind the head of the matching stream.
|
|
164
|
+
* @returns {{ cursor_lag: number, oldest_unacked_age_ms: number|null, dlq_count: number }}
|
|
165
|
+
*/
|
|
166
|
+
function computeLag(db, cursorId) {
|
|
167
|
+
const cursor = db.prepare(
|
|
168
|
+
'SELECT * FROM cursors WHERE cursor_id = ?'
|
|
169
|
+
).get(cursorId);
|
|
170
|
+
if (!cursor) {
|
|
171
|
+
return { cursor_lag: 0, oldest_unacked_age_ms: null, dlq_count: 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const sub = db.prepare(
|
|
175
|
+
'SELECT * FROM subscriptions WHERE subscription_id = ?'
|
|
176
|
+
).get(cursor.subscription_id);
|
|
177
|
+
if (!sub) {
|
|
178
|
+
return { cursor_lag: 0, oldest_unacked_age_ms: null, dlq_count: 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Reuse poll() filter compilation by counting matching events ahead of cursor.
|
|
182
|
+
// We approximate: count rows where event_id > last_event_id and not expired.
|
|
183
|
+
// Filter matching is handled in a sub-query below.
|
|
184
|
+
const head = db.prepare(
|
|
185
|
+
'SELECT MAX(event_id) as max_id FROM events'
|
|
186
|
+
).get();
|
|
187
|
+
const maxId = head && head.max_id != null ? head.max_id : 0;
|
|
188
|
+
const cursorLag = Math.max(0, maxId - cursor.last_event_id);
|
|
189
|
+
|
|
190
|
+
// Oldest unacked age is best-effort: emitted_at of the lowest unacked event
|
|
191
|
+
// that the cursor would actually receive on next poll. We skip the filter
|
|
192
|
+
// join here for simplicity and use the same filtered poll() shape via
|
|
193
|
+
// event_id > last_event_id and expires_at > now.
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const oldest = db.prepare(`
|
|
196
|
+
SELECT MIN(emitted_at) as oldest
|
|
197
|
+
FROM events
|
|
198
|
+
WHERE event_id > ? AND expires_at > ?
|
|
199
|
+
`).get(cursor.last_event_id, now);
|
|
200
|
+
const oldestUnackedAgeMs = oldest && oldest.oldest != null ? now - oldest.oldest : null;
|
|
201
|
+
|
|
202
|
+
const dlq = db.prepare(
|
|
203
|
+
'SELECT COUNT(*) as c FROM dead_letters WHERE cursor_id = ?'
|
|
204
|
+
).get(cursorId);
|
|
205
|
+
const dlqCount = dlq ? dlq.c : 0;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
cursor_lag: cursorLag,
|
|
209
|
+
oldest_unacked_age_ms: oldestUnackedAgeMs,
|
|
210
|
+
dlq_count: dlqCount,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Subscribe to events with a managed loop, retry, DLQ, and lifecycle.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} opts
|
|
218
|
+
* @param {import('better-sqlite3').Database} opts.db - Open DB handle (required)
|
|
219
|
+
* @param {string} opts.plugin - Subscriber plugin identity
|
|
220
|
+
* @param {string} opts.filter - Event type filter (e.g. 'wicked.fact.extracted.*')
|
|
221
|
+
* @param {(event: object) => void|Promise<void>} opts.handler - Event handler; throws to retry
|
|
222
|
+
* @param {'latest'|'oldest'} [opts.cursor_init='latest'] - Only used on first registration
|
|
223
|
+
* @param {number} [opts.pollIntervalMs=15000]
|
|
224
|
+
* @param {number} [opts.batchSize=50]
|
|
225
|
+
* @param {number} [opts.maxRetries=0] - 0 = fail-fast (advance cursor on first failure)
|
|
226
|
+
* @param {number|number[]} [opts.backoffMs=1000] - Number = constant; array repeats last element
|
|
227
|
+
* @param {number} [opts.lagIntervalMs=60000] - onLag callback cadence (independent of poll)
|
|
228
|
+
* @param {(err: Error, event: object) => void} [opts.onError]
|
|
229
|
+
* @param {(event: object, reason: string) => void} [opts.onDeadLetter]
|
|
230
|
+
* @param {(lag: object) => void} [opts.onLag]
|
|
231
|
+
*
|
|
232
|
+
* @returns {{ stop: () => Promise<void>, getLag: () => object, cursor_id: string, subscription_id: string }}
|
|
233
|
+
*/
|
|
234
|
+
export function subscribe(opts) {
|
|
235
|
+
if (!opts || !opts.db) throw new TypeError('subscribe: opts.db is required');
|
|
236
|
+
if (!opts.plugin) throw new TypeError('subscribe: opts.plugin is required');
|
|
237
|
+
if (!opts.filter) throw new TypeError('subscribe: opts.filter is required');
|
|
238
|
+
if (typeof opts.handler !== 'function') {
|
|
239
|
+
throw new TypeError('subscribe: opts.handler must be a function');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const db = opts.db;
|
|
243
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULTS.pollIntervalMs;
|
|
244
|
+
const batchSize = opts.batchSize ?? DEFAULTS.batchSize;
|
|
245
|
+
const maxRetries = opts.maxRetries ?? DEFAULTS.maxRetries;
|
|
246
|
+
const backoffMs = normalizeBackoff(opts.backoffMs, maxRetries);
|
|
247
|
+
const lagIntervalMs = opts.lagIntervalMs ?? DEFAULTS.lagIntervalMs;
|
|
248
|
+
|
|
249
|
+
const { subscription_id, cursor_id } = registerOrResume(db, {
|
|
250
|
+
plugin: opts.plugin,
|
|
251
|
+
filter: opts.filter,
|
|
252
|
+
cursor_init: opts.cursor_init || DEFAULTS.cursor_init,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Loop state ────────────────────────────────────────────────────────────
|
|
256
|
+
let stopping = false;
|
|
257
|
+
let stopPromise = null;
|
|
258
|
+
let resolveStop = null;
|
|
259
|
+
let pollTimer = null;
|
|
260
|
+
let lagTimer = null;
|
|
261
|
+
let backoffTimer = null;
|
|
262
|
+
let cancelBackoff = null;
|
|
263
|
+
let loopActive = false;
|
|
264
|
+
|
|
265
|
+
function safeCallback(cb, ...args) {
|
|
266
|
+
if (typeof cb !== 'function') return;
|
|
267
|
+
try { cb(...args); } catch (_) { /* user callback errors are swallowed */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function sleepCancelable(ms) {
|
|
271
|
+
return new Promise(resolve => {
|
|
272
|
+
backoffTimer = setTimeout(() => {
|
|
273
|
+
backoffTimer = null;
|
|
274
|
+
cancelBackoff = null;
|
|
275
|
+
resolve(true);
|
|
276
|
+
}, ms);
|
|
277
|
+
cancelBackoff = () => {
|
|
278
|
+
if (backoffTimer) {
|
|
279
|
+
clearTimeout(backoffTimer);
|
|
280
|
+
backoffTimer = null;
|
|
281
|
+
}
|
|
282
|
+
cancelBackoff = null;
|
|
283
|
+
resolve(false);
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Process a single event with retry/DLQ semantics.
|
|
290
|
+
* Returns true when the cursor was advanced (success or DLQ), false if the
|
|
291
|
+
* loop should bail without advancing (only on shutdown mid-handler).
|
|
292
|
+
*/
|
|
293
|
+
async function processEvent(event) {
|
|
294
|
+
let attempts = getAttempts(db, cursor_id, event.event_id);
|
|
295
|
+
const parsed = parseEvent(event);
|
|
296
|
+
|
|
297
|
+
// eslint-disable-next-line no-constant-condition
|
|
298
|
+
while (true) {
|
|
299
|
+
if (stopping && attempts > 0) {
|
|
300
|
+
// Mid-retry shutdown: DLQ and advance.
|
|
301
|
+
moveToDeadLetter(db, cursor_id, subscription_id, event, attempts, 'shutdown during backoff');
|
|
302
|
+
deleteDeliveryAttempt(db, cursor_id, event.event_id);
|
|
303
|
+
ack(db, cursor_id, event.event_id);
|
|
304
|
+
safeCallback(opts.onDeadLetter, parsed, 'shutdown during backoff');
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await opts.handler(parsed);
|
|
310
|
+
deleteDeliveryAttempt(db, cursor_id, event.event_id);
|
|
311
|
+
ack(db, cursor_id, event.event_id);
|
|
312
|
+
return true;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
attempts += 1;
|
|
315
|
+
safeCallback(opts.onError, err, parsed);
|
|
316
|
+
|
|
317
|
+
if (attempts > maxRetries) {
|
|
318
|
+
moveToDeadLetter(db, cursor_id, subscription_id, event, attempts, err.message);
|
|
319
|
+
deleteDeliveryAttempt(db, cursor_id, event.event_id);
|
|
320
|
+
ack(db, cursor_id, event.event_id);
|
|
321
|
+
safeCallback(opts.onDeadLetter, parsed, err.message);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
upsertDeliveryAttempt(db, cursor_id, event.event_id, attempts, err.message);
|
|
326
|
+
const sleepMs = backoffMs[Math.min(attempts - 1, backoffMs.length - 1)];
|
|
327
|
+
const completed = await sleepCancelable(sleepMs);
|
|
328
|
+
if (!completed) {
|
|
329
|
+
// stop() interrupted the backoff
|
|
330
|
+
moveToDeadLetter(db, cursor_id, subscription_id, event, attempts, 'shutdown during backoff');
|
|
331
|
+
deleteDeliveryAttempt(db, cursor_id, event.event_id);
|
|
332
|
+
ack(db, cursor_id, event.event_id);
|
|
333
|
+
safeCallback(opts.onDeadLetter, parsed, 'shutdown during backoff');
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
// loop again — retry handler
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Drain pending replays for this cursor before normal polling.
|
|
343
|
+
* Each replay is a single attempt — no retry semantics. Success deletes the
|
|
344
|
+
* DLQ row; failure clears replay_requested_at and updates attempts / last_error.
|
|
345
|
+
*/
|
|
346
|
+
async function drainReplays() {
|
|
347
|
+
while (!stopping) {
|
|
348
|
+
const row = db.prepare(`
|
|
349
|
+
SELECT * FROM dead_letters
|
|
350
|
+
WHERE cursor_id = ? AND replay_requested_at IS NOT NULL
|
|
351
|
+
ORDER BY dl_id ASC
|
|
352
|
+
LIMIT 1
|
|
353
|
+
`).get(cursor_id);
|
|
354
|
+
if (!row) return;
|
|
355
|
+
|
|
356
|
+
const parsed = parseEvent(row);
|
|
357
|
+
try {
|
|
358
|
+
await opts.handler(parsed);
|
|
359
|
+
db.prepare('DELETE FROM dead_letters WHERE dl_id = ?').run(row.dl_id);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
safeCallback(opts.onError, err, parsed);
|
|
362
|
+
db.prepare(`
|
|
363
|
+
UPDATE dead_letters
|
|
364
|
+
SET replay_requested_at = NULL,
|
|
365
|
+
attempts = attempts + 1,
|
|
366
|
+
last_error = ?
|
|
367
|
+
WHERE dl_id = ?
|
|
368
|
+
`).run(err.message, row.dl_id);
|
|
369
|
+
// Stop draining on failure; operator can re-replay after fixing
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function tick() {
|
|
376
|
+
if (stopping || loopActive) return;
|
|
377
|
+
loopActive = true;
|
|
378
|
+
try {
|
|
379
|
+
await drainReplays();
|
|
380
|
+
if (stopping) return;
|
|
381
|
+
|
|
382
|
+
const events = poll(db, cursor_id, { batchSize });
|
|
383
|
+
for (const event of events) {
|
|
384
|
+
if (stopping) break;
|
|
385
|
+
await processEvent(event);
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
// Polling errors (WB-003, WB-006) bubble through onError so operators
|
|
389
|
+
// see them. The loop continues — the next tick will retry.
|
|
390
|
+
safeCallback(opts.onError, err, null);
|
|
391
|
+
} finally {
|
|
392
|
+
loopActive = false;
|
|
393
|
+
if (!stopping) {
|
|
394
|
+
pollTimer = setTimeout(tick, pollIntervalMs);
|
|
395
|
+
} else if (resolveStop) {
|
|
396
|
+
finalizeStop();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function finalizeStop() {
|
|
402
|
+
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
403
|
+
if (lagTimer) { clearInterval(lagTimer); lagTimer = null; }
|
|
404
|
+
if (resolveStop) {
|
|
405
|
+
const r = resolveStop;
|
|
406
|
+
resolveStop = null;
|
|
407
|
+
r();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function stop() {
|
|
412
|
+
if (stopPromise) return stopPromise;
|
|
413
|
+
stopping = true;
|
|
414
|
+
stopPromise = new Promise(resolve => { resolveStop = resolve; });
|
|
415
|
+
|
|
416
|
+
if (cancelBackoff) cancelBackoff();
|
|
417
|
+
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
418
|
+
if (lagTimer) { clearInterval(lagTimer); lagTimer = null; }
|
|
419
|
+
|
|
420
|
+
if (!loopActive) {
|
|
421
|
+
// Nothing in flight — resolve immediately
|
|
422
|
+
finalizeStop();
|
|
423
|
+
}
|
|
424
|
+
// Otherwise tick()'s finally block will resolve once the in-flight handler
|
|
425
|
+
// (or backoff cancellation) completes.
|
|
426
|
+
|
|
427
|
+
return stopPromise;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function getLag() {
|
|
431
|
+
return computeLag(db, cursor_id);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Start the loop ────────────────────────────────────────────────────────
|
|
435
|
+
// First tick on next macrotask so the caller can attach handlers / store the
|
|
436
|
+
// returned handle before the loop begins.
|
|
437
|
+
pollTimer = setTimeout(tick, 0);
|
|
438
|
+
|
|
439
|
+
if (typeof opts.onLag === 'function') {
|
|
440
|
+
lagTimer = setInterval(() => {
|
|
441
|
+
safeCallback(opts.onLag, computeLag(db, cursor_id));
|
|
442
|
+
}, lagIntervalMs);
|
|
443
|
+
// Don't keep the event loop alive for lag callbacks alone
|
|
444
|
+
if (lagTimer.unref) lagTimer.unref();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { stop, getLag, cursor_id, subscription_id };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Internal export for testing / advanced callers that want resume semantics
|
|
451
|
+
// without the full managed loop.
|
|
452
|
+
export { registerOrResume };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-bus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Lightweight, local-first SQLite event bus for AI agents and developer tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -52,5 +52,13 @@
|
|
|
52
52
|
"cursor-poll",
|
|
53
53
|
"at-least-once"
|
|
54
54
|
],
|
|
55
|
-
"license": "MIT"
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/mikeparcewski/wicked-bus.git"
|
|
59
|
+
},
|
|
60
|
+
"homepage": "https://github.com/mikeparcewski/wicked-bus#readme",
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/mikeparcewski/wicked-bus/issues"
|
|
63
|
+
}
|
|
56
64
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
name: wicked-bus:init
|
|
2
3
|
description: Initialize wicked-bus or connect to an existing instance. Use when setting up the bus for the first time, checking if it's running, or configuring a project to use it. Auto-triggered when any wicked-bus skill detects no config.
|
|
3
4
|
---
|
|
4
5
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
name: wicked-bus:query
|
|
2
3
|
description: Query and debug the wicked-bus. Use when checking bus health, inspecting events, debugging delivery issues, tracing event flow, or investigating why a subscriber isn't receiving events. Covers status, replay, and direct SQLite queries.
|
|
3
4
|
---
|
|
4
5
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wicked-bus:status
|
|
3
|
+
description: |
|
|
4
|
+
Show wicked-bus health, statistics, and diagnostics. Event counts,
|
|
5
|
+
subscriber lag, provider list, database size, and configuration.
|
|
6
|
+
|
|
7
|
+
Use when: "bus status", "is the bus healthy", "how many events",
|
|
8
|
+
"show bus stats", or when diagnosing delivery issues.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# wicked-bus:status
|
|
12
|
+
|
|
13
|
+
Show the current state of the wicked-bus.
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- User asks about bus health or status
|
|
18
|
+
- Before debugging delivery issues
|
|
19
|
+
- Checking if the bus is initialized and has data
|
|
20
|
+
- Monitoring subscriber lag
|
|
21
|
+
|
|
22
|
+
## Process
|
|
23
|
+
|
|
24
|
+
### Step 1: Check if bus is initialized
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx wicked-bus status 2>/dev/null
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If this fails, the bus isn't initialized. Suggest running `wicked-bus/init`.
|
|
31
|
+
|
|
32
|
+
### Step 2: Parse and display status
|
|
33
|
+
|
|
34
|
+
The `status` command returns JSON. Display it in a readable format:
|
|
35
|
+
|
|
36
|
+
```markdown
|
|
37
|
+
## wicked-bus Status
|
|
38
|
+
|
|
39
|
+
**Database**: {db_path}
|
|
40
|
+
**Events**: {total_events} total, {active_events} active (not expired)
|
|
41
|
+
**Providers**: {provider_count} registered
|
|
42
|
+
**Subscribers**: {subscriber_count} active
|
|
43
|
+
|
|
44
|
+
### Subscriber Lag
|
|
45
|
+
| Plugin | Filter | Cursor | Latest | Lag |
|
|
46
|
+
|--------|--------|--------|--------|-----|
|
|
47
|
+
| {plugin} | {filter} | {last_event_id} | {max_event_id} | {lag} |
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Step 3: Extended diagnostics (if requested)
|
|
51
|
+
|
|
52
|
+
If the user wants deeper diagnostics, query SQLite directly:
|
|
53
|
+
|
|
54
|
+
**Event distribution by type:**
|
|
55
|
+
```bash
|
|
56
|
+
sqlite3 ~/.something-wicked/wicked-bus/bus.db \
|
|
57
|
+
"SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 20;"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Event distribution by domain:**
|
|
61
|
+
```bash
|
|
62
|
+
sqlite3 ~/.something-wicked/wicked-bus/bus.db \
|
|
63
|
+
"SELECT domain, COUNT(*) as count FROM events GROUP BY domain ORDER BY count DESC;"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Database size:**
|
|
67
|
+
```bash
|
|
68
|
+
ls -lh ~/.something-wicked/wicked-bus/bus.db
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**WAL size (if checkpointing is lagging):**
|
|
72
|
+
```bash
|
|
73
|
+
ls -lh ~/.something-wicked/wicked-bus/bus.db-wal 2>/dev/null
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Events per hour (last 24h):**
|
|
77
|
+
```bash
|
|
78
|
+
sqlite3 ~/.something-wicked/wicked-bus/bus.db \
|
|
79
|
+
"SELECT strftime('%Y-%m-%d %H:00', emitted_at/1000, 'unixepoch') as hour, COUNT(*) as count FROM events WHERE emitted_at > (strftime('%s','now')-86400)*1000 GROUP BY hour ORDER BY hour;"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Oldest and newest events:**
|
|
83
|
+
```bash
|
|
84
|
+
sqlite3 ~/.something-wicked/wicked-bus/bus.db \
|
|
85
|
+
"SELECT 'oldest' as which, event_id, event_type, datetime(emitted_at/1000, 'unixepoch') as time FROM events ORDER BY event_id ASC LIMIT 1 UNION ALL SELECT 'newest', event_id, event_type, datetime(emitted_at/1000, 'unixepoch') FROM events ORDER BY event_id DESC LIMIT 1;"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Deregistered subscriptions:**
|
|
89
|
+
```bash
|
|
90
|
+
npx wicked-bus list --include-deregistered --json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Configuration:**
|
|
94
|
+
```bash
|
|
95
|
+
cat ~/.something-wicked/wicked-bus/config.json
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If `WICKED_BUS_DATA_DIR` is set, use that path instead of the default.
|
|
99
|
+
|
|
100
|
+
### Step 4: Health warnings
|
|
101
|
+
|
|
102
|
+
Flag these issues if detected:
|
|
103
|
+
|
|
104
|
+
- **High lag** (cursor > 100 events behind): subscriber may be failing to poll
|
|
105
|
+
- **No recent events** (nothing in last 24h): producers may have stopped
|
|
106
|
+
- **Large WAL file** (> 10 MB): checkpointing may be blocked
|
|
107
|
+
- **Deregistered subscribers with active cursors**: orphaned cursors consuming space
|
|
108
|
+
- **Events near dedup_expires_at**: about to be swept — subscribers should poll soon
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
name: wicked-bus:subscribe
|
|
2
3
|
description: Subscribe to wicked-bus events. Use when consuming events from the bus, setting up event listeners, polling for new events, or integrating as a subscriber. Covers registration, polling, acknowledgment, and filter patterns.
|
|
3
4
|
---
|
|
4
5
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wicked-bus:update
|
|
3
|
+
description: |
|
|
4
|
+
Check for and install wicked-bus updates. Compares installed version against
|
|
5
|
+
npm registry, updates skills across all detected CLIs.
|
|
6
|
+
|
|
7
|
+
Use when: "update wicked-bus", "check for bus updates", "wicked-bus update",
|
|
8
|
+
or periodically to stay current.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# wicked-bus:update
|
|
12
|
+
|
|
13
|
+
Check for and install updates to wicked-bus and its skills.
|
|
14
|
+
|
|
15
|
+
## Cross-Platform Notes
|
|
16
|
+
|
|
17
|
+
Commands work on macOS, Linux, and Windows. Use agent-native tools
|
|
18
|
+
(Read, Write, Grep, Glob) over shell commands when possible.
|
|
19
|
+
|
|
20
|
+
## When to use
|
|
21
|
+
|
|
22
|
+
- User asks to update or check for updates
|
|
23
|
+
- After encountering unexpected behavior that might be fixed in a newer version
|
|
24
|
+
- Periodically (suggest checking monthly)
|
|
25
|
+
|
|
26
|
+
## Process
|
|
27
|
+
|
|
28
|
+
### Step 1: Check current installed version
|
|
29
|
+
|
|
30
|
+
Check both global and local installations:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm list -g wicked-bus --json 2>/dev/null | python3 -c "
|
|
34
|
+
import json, sys
|
|
35
|
+
try:
|
|
36
|
+
d = json.load(sys.stdin)
|
|
37
|
+
deps = d.get('dependencies', {})
|
|
38
|
+
v = deps.get('wicked-bus', {}).get('version', 'not installed')
|
|
39
|
+
print(v)
|
|
40
|
+
except Exception:
|
|
41
|
+
print('not installed')
|
|
42
|
+
" 2>/dev/null || python -c "
|
|
43
|
+
import json, sys
|
|
44
|
+
try:
|
|
45
|
+
d = json.load(sys.stdin)
|
|
46
|
+
deps = d.get('dependencies', {})
|
|
47
|
+
v = deps.get('wicked-bus', {}).get('version', 'not installed')
|
|
48
|
+
print(v)
|
|
49
|
+
except Exception:
|
|
50
|
+
print('not installed')
|
|
51
|
+
"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Also check local (project-level) install:
|
|
55
|
+
```bash
|
|
56
|
+
npm list wicked-bus --json 2>/dev/null
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Step 2: Check latest version on npm
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm view wicked-bus version 2>/dev/null
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Step 3: Compare versions
|
|
66
|
+
|
|
67
|
+
If installed version matches latest:
|
|
68
|
+
"wicked-bus is up to date (v{version})."
|
|
69
|
+
|
|
70
|
+
If an update is available:
|
|
71
|
+
"wicked-bus v{new} is available (you have v{current}). Update now?"
|
|
72
|
+
|
|
73
|
+
### Step 4: Update (if user approves)
|
|
74
|
+
|
|
75
|
+
For global install:
|
|
76
|
+
```bash
|
|
77
|
+
npm install -g wicked-bus@latest 2>&1
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For local (project) install:
|
|
81
|
+
```bash
|
|
82
|
+
npm install wicked-bus@latest 2>&1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If `EACCES` / permission denied:
|
|
86
|
+
- macOS/Linux: `sudo npm install -g wicked-bus@latest`
|
|
87
|
+
- Windows: re-run shell as Administrator
|
|
88
|
+
- Report the failure — do NOT silently skip
|
|
89
|
+
|
|
90
|
+
### Step 5: Refresh skills in all CLIs
|
|
91
|
+
|
|
92
|
+
After updating the package, run the installer to copy updated skills:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx wicked-bus-install
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or with a specific CLI target:
|
|
99
|
+
```bash
|
|
100
|
+
npx wicked-bus-install --cli=claude
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Step 6: Verify
|
|
104
|
+
|
|
105
|
+
Re-run the Step 1 version check. Confirm the version matches latest.
|
|
106
|
+
|
|
107
|
+
If it still shows the old version:
|
|
108
|
+
1. Check `which wicked-bus` (macOS/Linux) or `where wicked-bus` (Windows)
|
|
109
|
+
2. Clear npm cache: `npm cache clean --force`
|
|
110
|
+
3. Check if nvm/fnm/volta is pinning a stale copy
|
|
111
|
+
|
|
112
|
+
### Step 7: Report
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
wicked-bus updated: v{old} → v{new}
|
|
116
|
+
Skills refreshed in {N} CLIs: {list}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Version check without updating
|
|
120
|
+
|
|
121
|
+
If the user just wants to check, stop after Step 3 and report
|
|
122
|
+
current vs. available version.
|