@radishbot/sdk 0.2.0 → 0.3.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 CHANGED
@@ -13,20 +13,20 @@ npm install @radishbot/sdk
13
13
  ## Quick Start
14
14
 
15
15
  ```ts
16
- import { RL, generateKey } from '@radishbot/sdk';
16
+ import { RL, generateKey } from "@radishbot/sdk";
17
17
 
18
18
  const key = generateKey(); // save this — it's your dashboard login
19
19
 
20
- const root = await RL(key);
20
+ const root = await RL(key, { release: "v1.0.0", retention: "30d" });
21
21
 
22
- await root.a('handle-request', async (req) => {
23
- console.log('GET /api/users'); // automatically captured
22
+ await root.a("handle-request", async (req) => {
23
+ console.log("GET /api/users"); // automatically captured
24
24
 
25
- const users = await req.a('db-query', async () => {
26
- return await db.query('SELECT * FROM users');
25
+ const users = await req.a("db-query", async () => {
26
+ return await db.query("SELECT * FROM users");
27
27
  });
28
28
 
29
- console.log('Done', { count: users.length });
29
+ console.log("Done", { count: users.length });
30
30
  });
31
31
 
32
32
  await root.finish();
@@ -39,12 +39,12 @@ Open the dashboard, paste your key, see everything.
39
39
  Inside `.a()` callbacks, `console.log/warn/error/debug` are automatically captured as action logs. Output still prints to the terminal — but it also gets sent to the dashboard with full context.
40
40
 
41
41
  ```ts
42
- await root.a('migrate', async () => {
43
- console.log('Starting migration'); // → info log
44
- console.warn('Deprecated column found'); // → warn log
45
- console.error('Failed to migrate users'); // → error log
46
- console.debug('SQL: ALTER TABLE ...'); // → debug log
47
- console.log('Done', { tables: 5 }); // → info log with data
42
+ await root.a("migrate", async () => {
43
+ console.log("Starting migration"); // → info log
44
+ console.warn("Deprecated column found"); // → warn log
45
+ console.error("Failed to migrate users"); // → error log
46
+ console.debug("SQL: ALTER TABLE ..."); // → debug log
47
+ console.log("Done", { tables: 5 }); // → info log with data
48
48
  });
49
49
  ```
50
50
 
@@ -57,15 +57,15 @@ Actions are nested scopes. Every `RL()` call creates a root action at `/`. Sub-a
57
57
  ```ts
58
58
  const root = await RL(key);
59
59
 
60
- await root.a('request', async (req) => {
61
- console.log('handling request');
60
+ await root.a("request", async (req) => {
61
+ console.log("handling request");
62
62
 
63
- await req.a('database', async () => {
64
- console.log('querying users');
63
+ await req.a("database", async () => {
64
+ console.log("querying users");
65
65
  }); // auto-finished
66
66
 
67
- await req.a('response', async () => {
68
- console.log('sending 200');
67
+ await req.a("response", async () => {
68
+ console.log("sending 200");
69
69
  }); // auto-finished
70
70
  }); // auto-finished
71
71
 
@@ -75,16 +75,16 @@ await root.finish();
75
75
  If your function throws, the action is marked as errored and the exception propagates:
76
76
 
77
77
  ```ts
78
- await root.a('risky-op', async () => {
79
- throw new Error('something broke');
78
+ await root.a("risky-op", async () => {
79
+ throw new Error("something broke");
80
80
  }); // action → error, rethrows
81
81
  ```
82
82
 
83
83
  Return values pass through:
84
84
 
85
85
  ```ts
86
- const users = await flow.a('db-query', async () => {
87
- return await db.query('SELECT * FROM users');
86
+ const users = await flow.a("db-query", async () => {
87
+ return await db.query("SELECT * FROM users");
88
88
  });
89
89
  // users is the query result
90
90
  ```
@@ -92,9 +92,9 @@ const users = await flow.a('db-query', async () => {
92
92
  Duplicate names are allowed — the dashboard shows them as `request:1`, `request:2`, etc:
93
93
 
94
94
  ```ts
95
- await root.a('batch', async (batch) => {
95
+ await root.a("batch", async (batch) => {
96
96
  for (const item of items) {
97
- await batch.a('request', async () => {
97
+ await batch.a("request", async () => {
98
98
  await processItem(item);
99
99
  });
100
100
  }
@@ -104,10 +104,10 @@ await root.a('batch', async (batch) => {
104
104
  For long-lived actions (websockets, streams), use the manual API:
105
105
 
106
106
  ```ts
107
- const stream = root.action('websocket');
108
- stream.info('connected');
107
+ const stream = root.action("websocket");
108
+ stream.info("connected");
109
109
  // ... hours later ...
110
- stream.info('disconnected');
110
+ stream.info("disconnected");
111
111
  await stream.finish();
112
112
  ```
113
113
 
@@ -116,10 +116,10 @@ await stream.finish();
116
116
  Outside of `.a()` callbacks, or when you want to log to a specific action:
117
117
 
118
118
  ```ts
119
- action.info('request received', { method: 'POST', path: '/api/users' });
120
- action.warn('rate limit approaching', { remaining: 3 });
121
- action.error('query failed', new Error('connection refused'));
122
- action.debug('cache hit', { key: 'user:123', ttl: 300 });
119
+ action.info("request received", { method: "POST", path: "/api/users" });
120
+ action.warn("rate limit approaching", { remaining: 3 });
121
+ action.error("query failed", new Error("connection refused"));
122
+ action.debug("cache hit", { key: "user:123", ttl: 300 });
123
123
  ```
124
124
 
125
125
  ## Cross-Context Actions
@@ -128,35 +128,74 @@ Export an action's handle to continue logging from another process, worker, or s
128
128
 
129
129
  ```ts
130
130
  // Service A
131
- const action = root.action('job');
131
+ const action = root.action("job");
132
132
  const handle = await action.exportID();
133
133
  // pass handle to service B via queue, HTTP, etc.
134
134
 
135
135
  // Service B
136
- import { restoreFlow } from '@radishbot/sdk';
136
+ import { restoreFlow } from "@radishbot/sdk";
137
137
  const action = await restoreFlow(key, handle);
138
- action.info('continuing from service B');
138
+ action.info("continuing from service B");
139
139
  await action.finish();
140
140
  ```
141
141
 
142
+ ## Release Tracking
143
+
144
+ Tag every flow with a version or commit SHA. Errors are tracked per-release, and regressions (errors reappearing after being resolved) are detected automatically.
145
+
146
+ ```ts
147
+ const root = await RL(key, { release: "v1.2.3" });
148
+ // or
149
+ const root = await RL(key, { release: process.env.GIT_SHA });
150
+ ```
151
+
152
+ Sub-flows inherit the release from the root automatically.
153
+
154
+ ## Retention & Garbage Collection
155
+
156
+ Each key has a retention period. Flows older than the retention window are automatically deleted (along with their logs and actions) by a GC job that runs every 3 hours.
157
+
158
+ ```ts
159
+ const root = await RL(key, { retention: "30d" }); // default
160
+ const root = await RL(key, { retention: "7d" }); // keep 1 week
161
+ const root = await RL(key, { retention: "90d" }); // keep 3 months
162
+ ```
163
+
164
+ Calling `RL()` with a new retention value updates the stored retention for that key.
165
+
166
+ ## Error Grouping
167
+
168
+ Errors are automatically deduplicated by normalizing the error message (stripping numbers, UUIDs) and combining it with the flow path. This means:
169
+
170
+ - `"User 123 not found"` and `"User 456 not found"` at `/request/db-query` group together
171
+ - Each group tracks: count, first/last seen, latest flow, and release
172
+ - Resolving an error group and seeing it again marks it as **regressed**
173
+ - Groups can be marked as `resolved` or `ignored` from the dashboard
174
+
142
175
  ## Configuration
143
176
 
144
177
  ```ts
145
178
  const root = await RL(key, {
146
- host: 'wss://maincloud.spacetimedb.com', // default
147
- dbName: 'radish-log', // default
148
- defaultTimeout: 100, // seconds, default 100
179
+ host: "wss://maincloud.spacetimedb.com", // default
180
+ dbName: "radish-log", // default
181
+ defaultTimeout: 100, // seconds, default 100
182
+ release: "v1.0.0", // version or commit SHA
183
+ retention: "30d", // data retention period
149
184
  });
150
185
  ```
151
186
 
152
187
  Sub-actions can have their own timeout:
153
188
 
154
189
  ```ts
155
- await root.a('quick-task', async () => {
156
- // ...
157
- }, 10); // 10 second timeout
158
-
159
- const slow = root.action('batch-job', 3600); // 1 hour
190
+ await root.a(
191
+ "quick-task",
192
+ async () => {
193
+ // ...
194
+ },
195
+ 10,
196
+ ); // 10 second timeout
197
+
198
+ const slow = root.action("batch-job", 3600); // 1 hour
160
199
  ```
161
200
 
162
201
  Actions that exceed their timeout are automatically marked as timed out.
@@ -165,7 +204,7 @@ Actions that exceed their timeout are automatically marked as timed out.
165
204
 
166
205
  ### `RL(secretKey, options?) → Promise<Flow>`
167
206
 
168
- Connect and create a root action.
207
+ Connect and create a root action. Options: `host`, `dbName`, `defaultTimeout`, `release`, `retention`.
169
208
 
170
209
  ### `generateKey() → string`
171
210
 
@@ -177,16 +216,16 @@ Restore an action from an exported handle string.
177
216
 
178
217
  ### Flow (Action)
179
218
 
180
- | Method | Description |
181
- |---|---|
182
- | `.a(name, fn, timeout?)` | Run a sub-action. Auto-finish, console capture. Returns fn result. |
183
- | `.action(name, timeout?)` | Create a sub-action manually. |
184
- | `.finish()` | Finish the action. |
185
- | `.finishWithError(err?)` | Finish the action as errored. |
186
- | `.info(msg, data?)` | Log at info level. |
187
- | `.warn(msg, data?)` | Log at warn level. |
188
- | `.error(msg, data?)` | Log at error level. |
189
- | `.debug(msg, data?)` | Log at debug level. |
190
- | `.log(msg, data?, level?)` | Log at any level. |
191
- | `.exportID()` | Export handle for cross-context restore. |
192
- | `.getId()` | Get server-assigned action ID. |
219
+ | Method | Description |
220
+ | -------------------------- | ------------------------------------------------------------------ |
221
+ | `.a(name, fn, timeout?)` | Run a sub-action. Auto-finish, console capture. Returns fn result. |
222
+ | `.action(name, timeout?)` | Create a sub-action manually. |
223
+ | `.finish()` | Finish the action. |
224
+ | `.finishWithError(err?)` | Finish the action as errored. |
225
+ | `.info(msg, data?)` | Log at info level. |
226
+ | `.warn(msg, data?)` | Log at warn level. |
227
+ | `.error(msg, data?)` | Log at error level. |
228
+ | `.debug(msg, data?)` | Log at debug level. |
229
+ | `.log(msg, data?, level?)` | Log at any level. |
230
+ | `.exportID()` | Export handle for cross-context restore. |
231
+ | `.getId()` | Get server-assigned action ID. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radishbot/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/cli.ts CHANGED
@@ -3,59 +3,92 @@
3
3
  * Radish CLI — inspect flows and logs from the terminal.
4
4
  *
5
5
  * Usage:
6
- * radish <key> List recent flows
7
- * radish <key> <flowId> Show flow detail + logs
8
- * radish <key> tail Live-tail all logs
9
- * radish <key> tree <flowId> Show flow tree
6
+ * radish keygen Generate a new secret key
7
+ * radish <key> List recent flows
8
+ * radish <key> <flowId> Show flow detail + logs
9
+ * radish <key> tree <flowId> Show flow tree
10
+ * radish <key> tail Live-tail all logs
11
+ * radish <key> errors Show error groups
12
+ * radish <key> resolve <errorGroupId> Resolve an error group
13
+ * radish <key> ignore <errorGroupId> Ignore an error group
10
14
  */
11
- import { DbConnection } from './module_bindings';
15
+ import { DbConnection } from "./module_bindings";
12
16
 
13
- const HOST = 'wss://maincloud.spacetimedb.com';
14
- const DB_NAME = 'radish-log';
17
+ const HOST = "wss://maincloud.spacetimedb.com";
18
+ const DB_NAME = "radish-log";
15
19
 
16
20
  // ── Colors ───────────────────────────────────────────────────
17
21
 
18
22
  const c = {
19
- reset: '\x1b[0m',
20
- dim: '\x1b[2m',
21
- bold: '\x1b[1m',
22
- red: '\x1b[31m',
23
- green: '\x1b[32m',
24
- yellow: '\x1b[33m',
25
- blue: '\x1b[34m',
26
- magenta: '\x1b[35m',
27
- cyan: '\x1b[36m',
28
- gray: '\x1b[90m',
23
+ reset: "\x1b[0m",
24
+ dim: "\x1b[2m",
25
+ bold: "\x1b[1m",
26
+ red: "\x1b[31m",
27
+ green: "\x1b[32m",
28
+ yellow: "\x1b[33m",
29
+ blue: "\x1b[34m",
30
+ magenta: "\x1b[35m",
31
+ cyan: "\x1b[36m",
32
+ gray: "\x1b[90m",
29
33
  };
30
34
 
31
35
  function levelColor(level: string): string {
32
36
  switch (level) {
33
- case 'info': return c.blue;
34
- case 'warn': return c.yellow;
35
- case 'error': return c.red;
36
- case 'debug': return c.gray;
37
- default: return c.dim;
37
+ case "info":
38
+ return c.blue;
39
+ case "warn":
40
+ return c.yellow;
41
+ case "error":
42
+ return c.red;
43
+ case "debug":
44
+ return c.gray;
45
+ default:
46
+ return c.dim;
38
47
  }
39
48
  }
40
49
 
41
50
  function statusColor(status: string): string {
42
51
  switch (status) {
43
- case 'open': return c.blue;
44
- case 'finished': return c.green;
45
- case 'error': return c.red;
46
- case 'timeout': return c.yellow;
47
- default: return c.dim;
52
+ case "open":
53
+ return c.blue;
54
+ case "finished":
55
+ return c.green;
56
+ case "error":
57
+ return c.red;
58
+ case "timeout":
59
+ return c.yellow;
60
+ default:
61
+ return c.dim;
62
+ }
63
+ }
64
+
65
+ function errorStatusColor(status: string): string {
66
+ switch (status) {
67
+ case "open":
68
+ return c.red;
69
+ case "regressed":
70
+ return c.yellow;
71
+ case "resolved":
72
+ return c.green;
73
+ case "ignored":
74
+ return c.gray;
75
+ default:
76
+ return c.dim;
48
77
  }
49
78
  }
50
79
 
51
80
  // ── Helpers ──────────────────────────────────────────────────
52
81
 
53
82
  async function hashKey(key: string): Promise<string> {
54
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));
55
- return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
83
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
84
+ return Array.from(new Uint8Array(buf))
85
+ .map((b) => b.toString(16).padStart(2, "0"))
86
+ .join("");
56
87
  }
57
88
 
58
- function tsMicros(ts: any): bigint { return ts.microsSinceUnixEpoch; }
89
+ function tsMicros(ts: any): bigint {
90
+ return ts.microsSinceUnixEpoch;
91
+ }
59
92
 
60
93
  function fmtTime(ts: any): string {
61
94
  return new Date(Number(tsMicros(ts) / 1000n)).toLocaleTimeString();
@@ -68,7 +101,7 @@ function fmtDateTime(ts: any): string {
68
101
 
69
102
  function fmtMs(us: number): string {
70
103
  const ms = us / 1000;
71
- if (ms < 1) return '<1ms';
104
+ if (ms < 1) return "<1ms";
72
105
  if (ms < 1000) return `${Math.round(ms)}ms`;
73
106
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
74
107
  return `${(ms / 60000).toFixed(1)}m`;
@@ -79,24 +112,31 @@ function fmtDuration(startTs: any, endMicros: bigint): string {
79
112
  }
80
113
 
81
114
  function fmtData(data: string): string {
82
- if (!data || data === '{}') return '';
115
+ if (!data || data === "{}") return "";
83
116
  try {
84
117
  const parsed = JSON.parse(data);
85
118
  const str = JSON.stringify(parsed, null, 2);
86
119
  // Indent continuation lines
87
- return str.split('\n').map((line, i) => i === 0 ? ' ' + c.dim + line + c.reset : ' ' + c.dim + line + c.reset).join('\n');
120
+ return str
121
+ .split("\n")
122
+ .map((line, i) =>
123
+ i === 0 ? " " + c.dim + line + c.reset : " " + c.dim + line + c.reset,
124
+ )
125
+ .join("\n");
88
126
  } catch {
89
- return ' ' + c.dim + data + c.reset;
127
+ return " " + c.dim + data + c.reset;
90
128
  }
91
129
  }
92
130
 
93
131
  function padRight(s: string, n: number): string {
94
- return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
132
+ return s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length);
95
133
  }
96
134
 
97
135
  // ── Connect ──────────────────────────────────────────────────
98
136
 
99
- function connect(keyHash: string): Promise<{ conn: DbConnection; flows: () => any[]; logs: () => any[] }> {
137
+ function connect(
138
+ keyHash: string,
139
+ ): Promise<{ conn: DbConnection; flows: () => any[]; logs: () => any[]; errorGroups: () => any[] }> {
100
140
  return new Promise((resolve, reject) => {
101
141
  const conn = DbConnection.builder()
102
142
  .withUri(HOST)
@@ -114,7 +154,12 @@ function connect(keyHash: string): Promise<{ conn: DbConnection; flows: () => an
114
154
  for (const l of c.db.logEntry.iter()) result.push(l);
115
155
  return result;
116
156
  };
117
- resolve({ conn: c, flows, logs });
157
+ const errorGroups = () => {
158
+ const result: any[] = [];
159
+ for (const g of c.db.errorGroup.iter()) if (g.keyHash === keyHash) result.push(g);
160
+ return result;
161
+ };
162
+ resolve({ conn: c, flows, logs, errorGroups });
118
163
  })
119
164
  .subscribeToAllTables();
120
165
  })
@@ -127,9 +172,10 @@ function connect(keyHash: string): Promise<{ conn: DbConnection; flows: () => an
127
172
 
128
173
  function cmdList(flows: any[]) {
129
174
  const roots = flows
130
- .filter(f => f.parentFlowId === 0n)
175
+ .filter((f) => f.parentFlowId === 0n)
131
176
  .sort((a, b) => {
132
- const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
177
+ const am = tsMicros(a.createdAt),
178
+ bm = tsMicros(b.createdAt);
133
179
  return am > bm ? -1 : am < bm ? 1 : 0;
134
180
  });
135
181
 
@@ -138,17 +184,29 @@ function cmdList(flows: any[]) {
138
184
  return;
139
185
  }
140
186
 
187
+ const hasRelease = roots.some((f) => f.release && f.release !== "");
188
+
141
189
  console.log(`${c.bold}Flows${c.reset} ${c.dim}(${roots.length} total)${c.reset}\n`);
142
- console.log(`${c.dim} ${'ID'.padEnd(8)} ${'STATUS'.padEnd(10)} ${'DURATION'.padEnd(10)} CREATED${c.reset}`);
143
- console.log(`${c.dim} ${'─'.repeat(8)} ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(24)}${c.reset}`);
190
+ if (hasRelease) {
191
+ console.log(
192
+ `${c.dim} ${"ID".padEnd(8)} ${"STATUS".padEnd(10)} ${"RELEASE".padEnd(10)} ${"DURATION".padEnd(10)} CREATED${c.reset}`,
193
+ );
194
+ console.log(
195
+ `${c.dim} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(24)}${c.reset}`,
196
+ );
197
+ } else {
198
+ console.log(`${c.dim} ${"ID".padEnd(8)} ${"STATUS".padEnd(10)} ${"DURATION".padEnd(10)} CREATED${c.reset}`);
199
+ console.log(`${c.dim} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(24)}${c.reset}`);
200
+ }
144
201
 
145
202
  for (const f of roots.slice(0, 30)) {
146
203
  const id = f.id.toString().padEnd(8);
147
204
  const sc = statusColor(f.status);
148
205
  const status = padRight(f.status, 10);
149
- const dur = f.finishedAt !== 0n ? padRight(fmtDuration(f.createdAt, f.finishedAt), 10) : padRight('', 10);
206
+ const dur = f.finishedAt !== 0n ? padRight(fmtDuration(f.createdAt, f.finishedAt), 10) : padRight("", 10);
150
207
  const time = fmtDateTime(f.createdAt);
151
- console.log(` ${c.dim}${id}${c.reset} ${sc}${status}${c.reset} ${dur} ${c.dim}${time}${c.reset}`);
208
+ const release = hasRelease ? padRight(f.release || "—", 10) + " " : "";
209
+ console.log(` ${c.dim}${id}${c.reset} ${sc}${status}${c.reset} ${release}${dur} ${c.dim}${time}${c.reset}`);
152
210
  }
153
211
 
154
212
  if (roots.length > 30) {
@@ -157,7 +215,7 @@ function cmdList(flows: any[]) {
157
215
  }
158
216
 
159
217
  function cmdShow(flowId: bigint, allFlows: any[], allLogs: any[]) {
160
- const flow = allFlows.find(f => f.id === flowId);
218
+ const flow = allFlows.find((f) => f.id === flowId);
161
219
  if (!flow) {
162
220
  console.error(`${c.red}Flow ${flowId} not found.${c.reset}`);
163
221
  process.exit(1);
@@ -165,18 +223,23 @@ function cmdShow(flowId: bigint, allFlows: any[], allLogs: any[]) {
165
223
 
166
224
  // Header
167
225
  const sc = statusColor(flow.status);
168
- console.log(`${c.bold}Flow #${flow.id}${c.reset} ${sc}${flow.status}${c.reset}`);
226
+ const releaseTag = flow.release ? ` ${c.dim}(${flow.release})${c.reset}` : "";
227
+ console.log(`${c.bold}Flow #${flow.id}${c.reset} ${sc}${flow.status}${c.reset}${releaseTag}`);
169
228
  console.log(`${c.dim}Created: ${fmtDateTime(flow.createdAt)}${c.reset}`);
170
229
  if (flow.finishedAt !== 0n) {
171
230
  console.log(`${c.dim}Duration: ${fmtDuration(flow.createdAt, flow.finishedAt)}${c.reset}`);
172
231
  }
232
+ if (flow.release) {
233
+ console.log(`${c.dim}Release: ${flow.release}${c.reset}`);
234
+ }
173
235
  console.log();
174
236
 
175
237
  // Child flows
176
238
  const children = allFlows
177
- .filter(f => f.parentFlowId === flowId)
239
+ .filter((f) => f.parentFlowId === flowId)
178
240
  .sort((a, b) => {
179
- const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
241
+ const am = tsMicros(a.createdAt),
242
+ bm = tsMicros(b.createdAt);
180
243
  return am < bm ? -1 : am > bm ? 1 : 0;
181
244
  });
182
245
 
@@ -184,8 +247,10 @@ function cmdShow(flowId: bigint, allFlows: any[], allLogs: any[]) {
184
247
  console.log(`${c.bold}Actions${c.reset}\n`);
185
248
  for (const ch of children) {
186
249
  const sc = statusColor(ch.status);
187
- const dur = ch.finishedAt !== 0n ? fmtDuration(ch.createdAt, ch.finishedAt) : '';
188
- console.log(` ${sc}●${c.reset} ${c.bold}${ch.name}${c.reset} ${c.dim}#${ch.id}${c.reset} ${sc}${ch.status}${c.reset} ${c.dim}${dur}${c.reset}`);
250
+ const dur = ch.finishedAt !== 0n ? fmtDuration(ch.createdAt, ch.finishedAt) : "";
251
+ console.log(
252
+ ` ${sc}●${c.reset} ${c.bold}${ch.name}${c.reset} ${c.dim}#${ch.id}${c.reset} ${sc}${ch.status}${c.reset} ${c.dim}${dur}${c.reset}`,
253
+ );
189
254
  }
190
255
  console.log();
191
256
  }
@@ -201,9 +266,10 @@ function cmdShow(flowId: bigint, allFlows: any[], allLogs: any[]) {
201
266
  collectIds(flowId);
202
267
 
203
268
  const logs = allLogs
204
- .filter(l => flowIds.has(l.flowId) && l.level !== 'flow' && l.level !== 'action')
269
+ .filter((l) => flowIds.has(l.flowId) && l.level !== "flow" && l.level !== "action")
205
270
  .sort((a, b) => {
206
- const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
271
+ const am = tsMicros(a.createdAt),
272
+ bm = tsMicros(b.createdAt);
207
273
  return am < bm ? -1 : am > bm ? 1 : 0;
208
274
  });
209
275
 
@@ -214,21 +280,21 @@ function cmdShow(flowId: bigint, allFlows: any[], allLogs: any[]) {
214
280
 
215
281
  // Find source flow name for each log
216
282
  const flowMap = new Map<bigint, string>();
217
- for (const f of allFlows) flowMap.set(f.id, f.name === '/' ? 'root' : f.name);
283
+ for (const f of allFlows) flowMap.set(f.id, f.name === "/" ? "root" : f.name);
218
284
 
219
285
  console.log(`${c.bold}Logs${c.reset}\n`);
220
286
  for (const l of logs) {
221
287
  const time = fmtTime(l.createdAt);
222
288
  const lc = levelColor(l.level);
223
289
  const lvl = padRight(l.level, 5);
224
- const source = l.flowId !== flowId ? ` ${c.cyan}[${flowMap.get(l.flowId) || '?'}]${c.reset}` : '';
290
+ const source = l.flowId !== flowId ? ` ${c.cyan}[${flowMap.get(l.flowId) || "?"}]${c.reset}` : "";
225
291
  const data = fmtData(l.data);
226
292
  console.log(` ${c.dim}${time}${c.reset} ${lc}${lvl}${c.reset}${source} ${l.message}${data}`);
227
293
  }
228
294
  }
229
295
 
230
296
  function cmdTree(flowId: bigint, allFlows: any[]) {
231
- const flow = allFlows.find(f => f.id === flowId);
297
+ const flow = allFlows.find((f) => f.id === flowId);
232
298
  if (!flow) {
233
299
  console.error(`${c.red}Flow ${flowId} not found.${c.reset}`);
234
300
  process.exit(1);
@@ -238,28 +304,31 @@ function cmdTree(flowId: bigint, allFlows: any[]) {
238
304
 
239
305
  function printNode(f: any, prefix: string, connector: string) {
240
306
  const sc = statusColor(f.status);
241
- const name = f.name === '/' ? 'root' : f.name;
242
- const dur = f.finishedAt !== 0n ? ` ${c.dim}${fmtDuration(f.createdAt, f.finishedAt)}${c.reset}` : '';
243
- console.log(`${prefix}${connector}${sc}●${c.reset} ${c.bold}${name}${c.reset} ${c.dim}#${f.id}${c.reset} ${sc}${f.status}${c.reset}${dur}`);
307
+ const name = f.name === "/" ? "root" : f.name;
308
+ const dur = f.finishedAt !== 0n ? ` ${c.dim}${fmtDuration(f.createdAt, f.finishedAt)}${c.reset}` : "";
309
+ console.log(
310
+ `${prefix}${connector}${sc}●${c.reset} ${c.bold}${name}${c.reset} ${c.dim}#${f.id}${c.reset} ${sc}${f.status}${c.reset}${dur}`,
311
+ );
244
312
  }
245
313
 
246
314
  function printChildren(parentId: bigint, prefix: string) {
247
315
  const children = allFlows
248
- .filter(ch => ch.parentFlowId === parentId)
316
+ .filter((ch) => ch.parentFlowId === parentId)
249
317
  .sort((a, b) => {
250
- const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
318
+ const am = tsMicros(a.createdAt),
319
+ bm = tsMicros(b.createdAt);
251
320
  return am < bm ? -1 : am > bm ? 1 : 0;
252
321
  });
253
322
 
254
323
  for (let i = 0; i < children.length; i++) {
255
324
  const isLast = i === children.length - 1;
256
- printNode(children[i], prefix, isLast ? '└─ ' : '├─ ');
257
- printChildren(children[i].id, prefix + (isLast ? ' ' : ''));
325
+ printNode(children[i], prefix, isLast ? "└─ " : "├─ ");
326
+ printChildren(children[i].id, prefix + (isLast ? " " : ""));
258
327
  }
259
328
  }
260
329
 
261
- printNode(flow, '', '');
262
- printChildren(flowId, '');
330
+ printNode(flow, "", "");
331
+ printChildren(flowId, "");
263
332
  }
264
333
 
265
334
  async function cmdTail(keyHash: string) {
@@ -277,7 +346,7 @@ async function cmdTail(keyHash: string) {
277
346
  .onApplied(() => {
278
347
  // Seed known flows and seen logs
279
348
  for (const f of c.db.flow.iter()) {
280
- if (f.keyHash === keyHash) flowNames.set(f.id, f.name === '/' ? 'root' : f.name);
349
+ if (f.keyHash === keyHash) flowNames.set(f.id, f.name === "/" ? "root" : f.name);
281
350
  }
282
351
  for (const l of c.db.logEntry.iter()) seen.add(l.id);
283
352
  resolve(c);
@@ -293,13 +362,13 @@ async function cmdTail(keyHash: string) {
293
362
  // Update flow names
294
363
  for (const f of conn.db.flow.iter()) {
295
364
  if (f.keyHash === keyHash && !flowNames.has(f.id)) {
296
- flowNames.set(f.id, f.name === '/' ? 'root' : f.name);
365
+ flowNames.set(f.id, f.name === "/" ? "root" : f.name);
297
366
  }
298
367
  }
299
368
 
300
369
  const newLogs: any[] = [];
301
370
  for (const l of conn.db.logEntry.iter()) {
302
- if (!seen.has(l.id) && l.level !== 'flow' && l.level !== 'action') {
371
+ if (!seen.has(l.id) && l.level !== "flow" && l.level !== "action") {
303
372
  // Only show logs belonging to this key's flows
304
373
  if (flowNames.has(l.flowId)) {
305
374
  seen.add(l.id);
@@ -309,7 +378,8 @@ async function cmdTail(keyHash: string) {
309
378
  }
310
379
 
311
380
  newLogs.sort((a, b) => {
312
- const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
381
+ const am = tsMicros(a.createdAt),
382
+ bm = tsMicros(b.createdAt);
313
383
  return am < bm ? -1 : am > bm ? 1 : 0;
314
384
  });
315
385
 
@@ -317,57 +387,139 @@ async function cmdTail(keyHash: string) {
317
387
  const time = fmtTime(l.createdAt);
318
388
  const lc = levelColor(l.level);
319
389
  const lvl = padRight(l.level, 5);
320
- const source = `${c.cyan}[${flowNames.get(l.flowId) || '?'}]${c.reset}`;
390
+ const source = `${c.cyan}[${flowNames.get(l.flowId) || "?"}]${c.reset}`;
321
391
  const data = fmtData(l.data);
322
392
  console.log(`${c.dim}${time}${c.reset} ${lc}${lvl}${c.reset} ${source} ${l.message}${data}`);
323
393
  }
324
394
  }, 500);
325
395
  }
326
396
 
397
+ function cmdErrors(errorGroups: any[]) {
398
+ const statusOrder: Record<string, number> = { open: 0, regressed: 1, resolved: 2, ignored: 3 };
399
+ const sorted = [...errorGroups].sort((a, b) => {
400
+ const ao = statusOrder[a.status] ?? 99,
401
+ bo = statusOrder[b.status] ?? 99;
402
+ if (ao !== bo) return ao - bo;
403
+ // Within same status priority, sort by count descending
404
+ return a.count > b.count ? -1 : a.count < b.count ? 1 : 0;
405
+ });
406
+
407
+ if (sorted.length === 0) {
408
+ console.log(`${c.dim}No error groups found for this key.${c.reset}`);
409
+ return;
410
+ }
411
+
412
+ console.log(`${c.bold}Errors${c.reset} ${c.dim}(${sorted.length} groups)${c.reset}\n`);
413
+ console.log(
414
+ `${c.dim} ${"ID".padEnd(8)} ${"STATUS".padEnd(10)} ${"COUNT".padEnd(6)} ${"RELEASE".padEnd(10)} ${"PATH".padEnd(23)} MESSAGE${c.reset}`,
415
+ );
416
+ console.log(
417
+ `${c.dim} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(6)} ${"─".repeat(10)} ${"─".repeat(23)} ${"─".repeat(25)}${c.reset}`,
418
+ );
419
+
420
+ for (const g of sorted) {
421
+ const id = g.id.toString().padEnd(8);
422
+ const esc = errorStatusColor(g.status);
423
+ const status = padRight(g.status, 10);
424
+ const count = g.count.toString().padEnd(6);
425
+ const release = padRight(g.release || "—", 10);
426
+ const path = padRight(g.path || "—", 23);
427
+ const message = g.message.length > 50 ? g.message.slice(0, 47) + "..." : g.message;
428
+ console.log(
429
+ ` ${c.dim}${id}${c.reset} ${esc}${status}${c.reset} ${count} ${c.dim}${release}${c.reset} ${c.dim}${path}${c.reset} ${message}`,
430
+ );
431
+ }
432
+ }
433
+
434
+ async function cmdUpdateErrorGroupStatus(conn: DbConnection, keyHash: string, errorGroupId: string, status: string) {
435
+ conn.reducers.updateErrorGroupStatus({
436
+ keyHash,
437
+ errorGroupId: BigInt(errorGroupId),
438
+ status,
439
+ });
440
+ const label = status === "resolved" ? "Resolved" : "Ignored";
441
+ console.log(`${c.green}${label} error group #${errorGroupId}.${c.reset}`);
442
+ }
443
+
327
444
  // ── Main ─────────────────────────────────────────────────────
328
445
 
329
446
  async function main() {
330
447
  const args = process.argv.slice(2);
331
448
 
332
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
449
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
333
450
  console.log(`
334
451
  ${c.bold}radish${c.reset} — inspect flows and logs
335
452
 
336
453
  ${c.bold}Usage:${c.reset}
337
- radish <key> List recent flows
338
- radish <key> <flowId> Show flow detail + logs
339
- radish <key> tree <flowId> Show flow tree
340
- radish <key> tail Live-tail all logs
454
+ radish keygen Generate a new secret key
455
+ radish <key> List recent flows
456
+ radish <key> <flowId> Show flow detail + logs
457
+ radish <key> tree <flowId> Show flow tree
458
+ radish <key> tail Live-tail all logs
459
+ radish <key> errors Show error groups
460
+ radish <key> resolve <errorGroupId> Resolve an error group
461
+ radish <key> ignore <errorGroupId> Ignore an error group
341
462
  `);
342
463
  process.exit(0);
343
464
  }
344
465
 
466
+ // keygen subcommand — no key required
467
+ if (args[0] === "keygen") {
468
+ const bytes = new Uint8Array(32);
469
+ crypto.getRandomValues(bytes);
470
+ const key =
471
+ "rl_" +
472
+ Array.from(bytes)
473
+ .map((b) => b.toString(16).padStart(2, "0"))
474
+ .join("");
475
+ console.log(`\n${c.bold}Generated key:${c.reset} ${key}\n`);
476
+ console.log(`Save this key — it's your dashboard login.`);
477
+ console.log(`Dashboard: ${c.cyan}https://radish-log.spacetimedb.com${c.reset}\n`);
478
+ process.exit(0);
479
+ }
480
+
345
481
  const secretKey = args[0];
346
482
  const keyHash = await hashKey(secretKey);
347
483
  const subcmd = args[1];
348
484
 
349
- if (subcmd === 'tail') {
485
+ if (subcmd === "tail") {
350
486
  await cmdTail(keyHash);
351
487
  return; // stays alive
352
488
  }
353
489
 
354
490
  // Connect and fetch data
355
491
  process.stderr.write(`${c.dim}Connecting...${c.reset}`);
356
- const { conn, flows, logs } = await connect(keyHash);
357
- process.stderr.write(`\r${' '.repeat(20)}\r`);
492
+ const { conn, flows, logs, errorGroups } = await connect(keyHash);
493
+ process.stderr.write(`\r${" ".repeat(20)}\r`);
358
494
 
359
495
  const allFlows = flows();
360
496
  const allLogs = logs();
361
497
 
362
498
  if (!subcmd) {
363
499
  cmdList(allFlows);
364
- } else if (subcmd === 'tree') {
365
- const flowId = BigInt(args[2] || '0');
500
+ } else if (subcmd === "tree") {
501
+ const flowId = BigInt(args[2] || "0");
366
502
  if (flowId === 0n) {
367
503
  console.error(`${c.red}Usage: radish <key> tree <flowId>${c.reset}`);
368
504
  process.exit(1);
369
505
  }
370
506
  cmdTree(flowId, allFlows);
507
+ } else if (subcmd === "errors") {
508
+ cmdErrors(errorGroups());
509
+ } else if (subcmd === "resolve") {
510
+ const errorGroupId = args[2];
511
+ if (!errorGroupId) {
512
+ console.error(`${c.red}Usage: radish <key> resolve <errorGroupId>${c.reset}`);
513
+ process.exit(1);
514
+ }
515
+ await cmdUpdateErrorGroupStatus(conn, keyHash, errorGroupId, "resolved");
516
+ } else if (subcmd === "ignore") {
517
+ const errorGroupId = args[2];
518
+ if (!errorGroupId) {
519
+ console.error(`${c.red}Usage: radish <key> ignore <errorGroupId>${c.reset}`);
520
+ process.exit(1);
521
+ }
522
+ await cmdUpdateErrorGroupStatus(conn, keyHash, errorGroupId, "ignored");
371
523
  } else {
372
524
  const flowId = BigInt(subcmd);
373
525
  cmdShow(flowId, allFlows, allLogs);
@@ -377,7 +529,7 @@ ${c.bold}Usage:${c.reset}
377
529
  process.exit(0);
378
530
  }
379
531
 
380
- main().catch(err => {
532
+ main().catch((err) => {
381
533
  console.error(`${c.red}${err.message}${c.reset}`);
382
534
  process.exit(1);
383
535
  });
package/src/index.ts CHANGED
@@ -260,6 +260,19 @@ export class Flow {
260
260
  return this._id!;
261
261
  }
262
262
 
263
+ /** Disconnect the underlying SpacetimeDB connection */
264
+ disconnect(): void {
265
+ this._sdk.disconnect();
266
+ }
267
+
268
+ /** Finish the flow and disconnect */
269
+ async finishAndDisconnect(): Promise<void> {
270
+ await this.finish();
271
+ // Small delay to allow the finish reducer to be sent
272
+ await new Promise((r) => setTimeout(r, 100));
273
+ this.disconnect();
274
+ }
275
+
263
276
  /** Schedule a drain on the next microtask — batches sync logs with ~0ms delay */
264
277
  private _scheduleFlush() {
265
278
  if (this._flushScheduled) return;
@@ -345,7 +358,11 @@ export async function RL(secretKey: string, options: RLOptions = {}): Promise<Fl
345
358
  const keyHash = await hashKey(secretKey);
346
359
  const sdk = new SdkConnection(host, dbName);
347
360
  sdk.setKeyHash(keyHash);
348
- await sdk.connect();
361
+ try {
362
+ await sdk.connect();
363
+ } catch (e) {
364
+ throw new Error(`Failed to connect to Radish (${host}/${dbName}): ${e instanceof Error ? e.message : e}`);
365
+ }
349
366
 
350
367
  // Register key (idempotent — updates retention if changed)
351
368
  try {