robodev 0.36.0 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,8 +4,18 @@ import {
4
4
  type StorageQueryable,
5
5
  } from "@robodev-ai/runtime";
6
6
  import type { RouteClients } from "@robodev-ai/runtime";
7
- import type { EmailClient, JobEnqueueInput, LlmClient } from "@robodev-ai/sdk";
8
- import type { DevInspectStore } from "./dev-inspect.js";
7
+ import type {
8
+ EmailClient,
9
+ JobEnqueueInput,
10
+ LlmClient,
11
+ SocketsClient,
12
+ StorageClient,
13
+ } from "@robodev-ai/sdk";
14
+ import {
15
+ truncateInspectPreview,
16
+ type DevInspectStore,
17
+ type StorageInspectOp,
18
+ } from "./dev-inspect.js";
9
19
 
10
20
  /**
11
21
  * The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
@@ -21,6 +31,68 @@ function errorMessage(error: unknown): string {
21
31
  return error instanceof Error ? error.message : String(error);
22
32
  }
23
33
 
34
+ function inspectStorageClient(client: StorageClient, inspect: DevInspectStore): StorageClient {
35
+ async function tracked<T>(op: StorageInspectOp, key: string, run: () => Promise<T>): Promise<T> {
36
+ try {
37
+ const result = await run();
38
+ inspect.record({ kind: "storage", op, key, ok: true });
39
+ return result;
40
+ } catch (error) {
41
+ inspect.record({
42
+ kind: "storage",
43
+ op,
44
+ key,
45
+ ok: false,
46
+ error: errorMessage(error),
47
+ });
48
+ throw error;
49
+ }
50
+ }
51
+ return {
52
+ upload: (key, data, options) => tracked("upload", key, () => client.upload(key, data, options)),
53
+ get: (key) => tracked("get", key, () => client.get(key)),
54
+ getUrl: (key, options) => tracked("getUrl", key, () => client.getUrl(key, options)),
55
+ delete: (key) => tracked("delete", key, () => client.delete(key)),
56
+ list: (options) => tracked("list", options?.prefix ?? "", () => client.list(options)),
57
+ };
58
+ }
59
+
60
+ function inspectSocketsClient(client: SocketsClient, inspect: DevInspectStore): SocketsClient {
61
+ function tracked(
62
+ socketKind: "send" | "broadcast",
63
+ name: string,
64
+ payload: unknown,
65
+ run: () => void,
66
+ ) {
67
+ try {
68
+ run();
69
+ inspect.record({
70
+ kind: "socket",
71
+ socketKind,
72
+ name,
73
+ preview: truncateInspectPreview(payload),
74
+ });
75
+ } catch (error) {
76
+ inspect.record({
77
+ kind: "socket",
78
+ socketKind,
79
+ name,
80
+ error: errorMessage(error),
81
+ preview: truncateInspectPreview(payload),
82
+ });
83
+ throw error;
84
+ }
85
+ }
86
+ return {
87
+ send(input) {
88
+ tracked("send", input.name, input.payload, () => client.send(input));
89
+ },
90
+ broadcast(input) {
91
+ tracked("broadcast", input.name, input.payload, () => client.broadcast(input));
92
+ },
93
+ };
94
+ }
95
+
24
96
  /** Prints mail to stdout so local signup and reset flows are followable. */
25
97
  export function consoleEmailClient(
26
98
  log: (line: string) => void,
@@ -65,13 +137,16 @@ export function devRouteClients(input: {
65
137
  subscribe: () => notConfigured("llm_not_configured"),
66
138
  destroy: () => notConfigured("llm_not_configured"),
67
139
  },
68
- storage: createLocalStorageClient({
69
- query: input.query,
70
- objectsDir: input.objectsDir,
71
- publicBaseUrl: input.publicBaseUrl,
72
- projectId: input.projectId,
73
- jwtSecret: input.jwtSecret,
74
- }),
140
+ storage: inspectStorageClient(
141
+ createLocalStorageClient({
142
+ query: input.query,
143
+ objectsDir: input.objectsDir,
144
+ publicBaseUrl: input.publicBaseUrl,
145
+ projectId: input.projectId,
146
+ jwtSecret: input.jwtSecret,
147
+ }),
148
+ input.inspect,
149
+ ),
75
150
  push: { send: () => notConfigured("push_not_configured") },
76
151
  jobs: {
77
152
  enqueue: async (job) => {
@@ -96,7 +171,7 @@ export function devRouteClients(input: {
96
171
  }
97
172
  },
98
173
  },
99
- sockets: createProjectSocketsClient(input.projectId),
174
+ sockets: inspectSocketsClient(createProjectSocketsClient(input.projectId), input.inspect),
100
175
  env: input.env,
101
176
  };
102
177
  }
@@ -8,6 +8,9 @@ import {
8
8
  isHistogramExcludedPath,
9
9
  isInspectPath,
10
10
  shouldCountRequestHistogram,
11
+ socketEventMatchesDef,
12
+ storageActivityMatchesKey,
13
+ truncateInspectPreview,
11
14
  type DevInspectAuthCodeEvent,
12
15
  type DevInspectEmailEvent,
13
16
  type DevInspectJobEvent,
@@ -25,7 +28,10 @@ test("isInspectPath covers dashboard pages and JSON only", () => {
25
28
  assert.equal(isInspectPath("/_robodev/jobs"), true);
26
29
  assert.equal(isInspectPath("/_robodev/emails"), true);
27
30
  assert.equal(isInspectPath("/_robodev/auth"), true);
31
+ assert.equal(isInspectPath("/_robodev/storage"), true);
32
+ assert.equal(isInspectPath("/_robodev/sockets"), true);
28
33
  assert.equal(isInspectPath("/_robodev/logs"), true);
34
+ assert.equal(isInspectPath("/storage/objects/avatar.png"), false);
29
35
  assert.equal(isInspectPath("/_robodev/state.json"), true);
30
36
  assert.equal(isInspectPath("/_robodev/logs.json"), true);
31
37
  assert.equal(isInspectPath("/_robodev/docs"), false);
@@ -43,6 +49,9 @@ test("shouldCountRequestHistogram counts Auth and matched file routes only", ()
43
49
  assert.equal(shouldCountRequestHistogram("/api/unknown", fileRuntime, "GET"), false);
44
50
  assert.equal(shouldCountRequestHistogram("/_robodev", fileRuntime, "GET"), false);
45
51
  assert.equal(shouldCountRequestHistogram("/_robodev/apis", fileRuntime, "GET"), false);
52
+ assert.equal(shouldCountRequestHistogram("/_robodev/storage", fileRuntime, "GET"), false);
53
+ assert.equal(shouldCountRequestHistogram("/_robodev/sockets", fileRuntime, "GET"), false);
54
+ assert.equal(shouldCountRequestHistogram("/storage/objects/a.png", fileRuntime, "GET"), false);
46
55
  assert.equal(shouldCountRequestHistogram("/_robodev/docs", fileRuntime, "GET"), false);
47
56
  assert.equal(shouldCountRequestHistogram("/_robodev/scalar", fileRuntime, "GET"), false);
48
57
  assert.equal(shouldCountRequestHistogram("/_robodev/ready", fileRuntime, "GET"), false);
@@ -223,6 +232,10 @@ test("dashboard snapshot lists reserved Auth, filters emails, and ignores enqueu
223
232
  assert.equal(state.jobRuns[0]?.status, "failed");
224
233
  assert.equal(state.overview.jobHistogram.at(-1)?.failed, 1);
225
234
  assert.deepEqual(state.auth.users, []);
235
+ assert.equal(state.overview.storageObjectCount, 0);
236
+ assert.equal(state.overview.socketCount, 0);
237
+ assert.deepEqual(state.storage, { objects: [], activity: [] });
238
+ assert.deepEqual(state.sockets, { connected: 0, defs: [], events: [] });
226
239
  });
227
240
 
228
241
  test("incomplete Google credentials are not labeled override", async () => {
@@ -242,6 +255,10 @@ test("inspect HTML has nav labels and polls state.json", () => {
242
255
  assert.match(html, />Jobs</);
243
256
  assert.match(html, />Emails</);
244
257
  assert.match(html, />Auth</);
258
+ assert.match(html, />Storage</);
259
+ assert.match(html, />Sockets</);
260
+ assert.match(html, /\/_robodev\/storage/);
261
+ assert.match(html, /\/_robodev\/sockets/);
245
262
  assert.doesNotMatch(html, /<h2>Requests<\/h2>/);
246
263
  assert.doesNotMatch(html, /<h2>Emails<\/h2>/);
247
264
  assert.doesNotMatch(html, /<h2>Jobs<\/h2>/);
@@ -250,3 +267,119 @@ test("inspect HTML has nav labels and polls state.json", () => {
250
267
  assert.doesNotMatch(html, /fetch\("\/_robodev\/logs\.json"\)/);
251
268
  assert.match(html, /setInterval\(poll, 1000\)/);
252
269
  });
270
+
271
+ test("storageActivityMatchesKey uses exact key except list prefixes", () => {
272
+ assert.equal(storageActivityMatchesKey({ op: "upload", key: "a/b" }, "a/b"), true);
273
+ assert.equal(storageActivityMatchesKey({ op: "get", key: "a/b" }, "a/c"), false);
274
+ assert.equal(storageActivityMatchesKey({ op: "getUrl", key: "a/b" }, "a/b"), true);
275
+ assert.equal(storageActivityMatchesKey({ op: "delete", key: "a/b" }, "a/b"), true);
276
+ assert.equal(storageActivityMatchesKey({ op: "serve", key: "a/b" }, "a/b"), true);
277
+ assert.equal(storageActivityMatchesKey({ op: "list", key: "" }, "a/b"), true);
278
+ assert.equal(storageActivityMatchesKey({ op: "list", key: "a/" }, "a/b"), true);
279
+ assert.equal(storageActivityMatchesKey({ op: "list", key: "a/" }, "a/"), true);
280
+ assert.equal(storageActivityMatchesKey({ op: "list", key: "b" }, "a/b"), false);
281
+ });
282
+
283
+ test("socketEventMatchesDef matches path params or name", () => {
284
+ const chat = { path: "/sockets/chat", name: "chat" };
285
+ const room = { path: "/sockets/room/:id", name: "room" };
286
+ assert.equal(socketEventMatchesDef({ path: "/sockets/chat", name: "chat" }, chat), true);
287
+ assert.equal(socketEventMatchesDef({ name: "chat" }, chat), true);
288
+ assert.equal(socketEventMatchesDef({ path: "/sockets/other" }, chat), false);
289
+ assert.equal(socketEventMatchesDef({ path: "/sockets/room/abc" }, room), true);
290
+ assert.equal(socketEventMatchesDef({ name: "room" }, room), true);
291
+ });
292
+
293
+ test("truncateInspectPreview caps text and JSON at 200", () => {
294
+ assert.equal(truncateInspectPreview("short"), "short");
295
+ assert.equal(truncateInspectPreview({ a: 1 }), '{"a":1}');
296
+ const long = "x".repeat(220);
297
+ const preview = truncateInspectPreview(long);
298
+ assert.equal(preview.length, 201);
299
+ assert.equal(preview.endsWith("…"), true);
300
+ });
301
+
302
+ test("dashboard snapshot includes storage objects, activity, and socket defs", async () => {
303
+ const inspect = createDevInspect();
304
+ inspect.record({ kind: "storage", op: "upload", key: "a.png", ok: true });
305
+ inspect.record({ kind: "storage", op: "list", key: "", ok: true });
306
+ inspect.record({ kind: "storage", op: "serve", key: "missing", ok: false, error: "not_found" });
307
+ inspect.record({
308
+ kind: "socket",
309
+ socketKind: "connect",
310
+ path: "/sockets/chat",
311
+ name: "chat",
312
+ userId: "u1",
313
+ });
314
+ inspect.record({
315
+ kind: "socket",
316
+ socketKind: "send",
317
+ name: "chat",
318
+ preview: '{"hi":1}',
319
+ });
320
+
321
+ const pool = {
322
+ query: async <R extends Record<string, unknown>>(sql: string) => {
323
+ if (sql.includes("robodev_auth.users")) return { rows: [] as unknown as R[] };
324
+ if (sql.includes("count(*)") && sql.includes("robodev_storage.objects")) {
325
+ return { rows: [{ count: "2" }] as unknown as R[] };
326
+ }
327
+ if (sql.includes("robodev_storage.objects")) {
328
+ return {
329
+ rows: [
330
+ {
331
+ key: "a.png",
332
+ public: true,
333
+ size_bytes: 12,
334
+ content_type: "image/png",
335
+ created_at: new Date("2026-01-01T00:00:00.000Z"),
336
+ },
337
+ ] as unknown as R[],
338
+ };
339
+ }
340
+ return { rows: [] as unknown as R[] };
341
+ },
342
+ };
343
+
344
+ const state = await buildDashboardState({
345
+ inspect,
346
+ projectId: "local",
347
+ runtime: {
348
+ routes: [],
349
+ jobs: [],
350
+ sockets: [
351
+ { name: "chat", path: "/sockets/chat", file: "sockets/chat.ts", def: { auth: false } },
352
+ ],
353
+ pool,
354
+ },
355
+ });
356
+
357
+ assert.equal(state.overview.storageObjectCount, 2);
358
+ assert.equal(state.overview.socketCount, 1);
359
+ assert.equal(state.storage.objects.length, 1);
360
+ assert.equal(state.storage.objects[0]?.url, "/storage/objects/a.png");
361
+ assert.equal(state.storage.objects[0]?.sizeBytes, 12);
362
+ assert.equal(state.storage.activity.length, 3);
363
+ assert.equal(state.storage.activity[0]?.op, "serve");
364
+ assert.equal(state.sockets.defs[0]?.auth, "public");
365
+ assert.equal(state.sockets.connected, 0);
366
+ assert.equal(state.sockets.events.length, 2);
367
+ assert.equal(state.sockets.events[0]?.socketKind, "send");
368
+ });
369
+
370
+ test("storage object count is 0 when the query fails", async () => {
371
+ const state = await buildDashboardState({
372
+ inspect: createDevInspect(),
373
+ runtime: {
374
+ routes: [],
375
+ jobs: [],
376
+ pool: {
377
+ query: async () => {
378
+ throw new Error("missing schema");
379
+ },
380
+ },
381
+ },
382
+ });
383
+ assert.equal(state.overview.storageObjectCount, 0);
384
+ assert.deepEqual(state.storage.objects, []);
385
+ });
@@ -3,7 +3,10 @@ import {
3
3
  matchPathParams,
4
4
  matchProjectRoute,
5
5
  projectGoogleOverrideMode,
6
+ projectSocketCount,
7
+ socketAuthDisplay,
6
8
  } from "@robodev-ai/runtime";
9
+ import type { ApiAuth } from "@robodev-ai/sdk";
7
10
 
8
11
  export const DEV_INSPECT_MAX = 500;
9
12
  export const HISTOGRAM_MINUTES = 120;
@@ -14,6 +17,8 @@ const DASHBOARD_HTML_PATHS = new Set([
14
17
  "/_robodev/jobs",
15
18
  "/_robodev/emails",
16
19
  "/_robodev/auth",
20
+ "/_robodev/storage",
21
+ "/_robodev/sockets",
17
22
  "/_robodev/logs",
18
23
  ]);
19
24
 
@@ -47,7 +52,13 @@ export const RESERVED_AUTH_ROUTES: Array<{ method: string; path: string; file: s
47
52
  { method: "PUT", path: "/api/user/me", file: "reserved" },
48
53
  ];
49
54
 
50
- export type DevInspectKind = "request" | "email" | "auth_code" | "job" | "job_run" | "log";
55
+ export const SOCKET_PREVIEW_MAX = 200;
56
+
57
+ export type DevInspectKind =
58
+ "request" | "email" | "auth_code" | "job" | "job_run" | "log" | "storage" | "socket";
59
+
60
+ export type StorageInspectOp = "upload" | "get" | "getUrl" | "delete" | "list" | "serve";
61
+ export type SocketInspectKind = "connect" | "message" | "close" | "send" | "broadcast";
51
62
 
52
63
  type EventBase = {
53
64
  id: string;
@@ -96,13 +107,35 @@ export type DevInspectLogEvent = EventBase & {
96
107
  message: string;
97
108
  };
98
109
 
110
+ export type DevInspectStorageEvent = EventBase & {
111
+ kind: "storage";
112
+ op: StorageInspectOp;
113
+ key: string;
114
+ ok: boolean;
115
+ error?: string;
116
+ };
117
+
118
+ export type DevInspectSocketEvent = EventBase & {
119
+ kind: "socket";
120
+ socketKind: SocketInspectKind;
121
+ path?: string;
122
+ name?: string;
123
+ userId?: string | null;
124
+ code?: number;
125
+ reason?: string;
126
+ error?: string;
127
+ preview?: string;
128
+ };
129
+
99
130
  export type DevInspectEvent =
100
131
  | DevInspectRequestEvent
101
132
  | DevInspectEmailEvent
102
133
  | DevInspectAuthCodeEvent
103
134
  | DevInspectJobEvent
104
135
  | DevInspectJobRunEvent
105
- | DevInspectLogEvent;
136
+ | DevInspectLogEvent
137
+ | DevInspectStorageEvent
138
+ | DevInspectSocketEvent;
106
139
 
107
140
  export type DevInspectRecordInput =
108
141
  | Omit<DevInspectRequestEvent, "id" | "at">
@@ -110,17 +143,57 @@ export type DevInspectRecordInput =
110
143
  | Omit<DevInspectAuthCodeEvent, "id" | "at">
111
144
  | Omit<DevInspectJobEvent, "id" | "at">
112
145
  | Omit<DevInspectJobRunEvent, "id" | "at">
113
- | Omit<DevInspectLogEvent, "id" | "at">;
146
+ | Omit<DevInspectLogEvent, "id" | "at">
147
+ | Omit<DevInspectStorageEvent, "id" | "at">
148
+ | Omit<DevInspectSocketEvent, "id" | "at">;
114
149
 
115
150
  export type DashboardGoogle = "broker" | "local_override" | "incomplete";
116
151
 
117
152
  export type RequestHistogramBucket = { t: number; c2xx: number; c4xx: number; c5xx: number };
118
153
  export type JobHistogramBucket = { t: number; succeeded: number; failed: number };
119
154
 
155
+ export type DashboardStorageObject = {
156
+ key: string;
157
+ public: boolean;
158
+ sizeBytes: number;
159
+ contentType: string;
160
+ createdAt: string;
161
+ url: string;
162
+ };
163
+
164
+ export type DashboardStorageActivity = {
165
+ at: number;
166
+ op: StorageInspectOp;
167
+ key: string;
168
+ ok: boolean;
169
+ error?: string;
170
+ };
171
+
172
+ export type DashboardSocketDef = {
173
+ path: string;
174
+ file: string;
175
+ auth: "required" | "public";
176
+ name: string;
177
+ };
178
+
179
+ export type DashboardSocketEvent = {
180
+ at: number;
181
+ socketKind: SocketInspectKind;
182
+ path?: string;
183
+ name?: string;
184
+ userId?: string | null;
185
+ code?: number;
186
+ reason?: string;
187
+ error?: string;
188
+ preview?: string;
189
+ };
190
+
120
191
  export type DashboardState = {
121
192
  overview: {
122
193
  apiCount: number;
123
194
  jobCount: number;
195
+ storageObjectCount: number;
196
+ socketCount: number;
124
197
  google: DashboardGoogle;
125
198
  requestHistogram: RequestHistogramBucket[];
126
199
  jobHistogram: JobHistogramBucket[];
@@ -141,11 +214,21 @@ export type DashboardState = {
141
214
  google: DashboardGoogle;
142
215
  users: Array<{ id: string; email: string; name: string | null; createdAt: string }>;
143
216
  };
217
+ storage: {
218
+ objects: DashboardStorageObject[];
219
+ activity: DashboardStorageActivity[];
220
+ };
221
+ sockets: {
222
+ connected: number;
223
+ defs: DashboardSocketDef[];
224
+ events: DashboardSocketEvent[];
225
+ };
144
226
  };
145
227
 
146
228
  export type DashboardRuntime = {
147
229
  routes: Array<{ method: string; path: string; file: string }>;
148
230
  jobs: Array<{ name: string; file: string; def: { cron?: string } }>;
231
+ sockets?: Array<{ name: string; path: string; file: string; def: { auth?: ApiAuth } }>;
149
232
  pool?: {
150
233
  query: <R extends Record<string, unknown> = Record<string, unknown>>(
151
234
  sql: string,
@@ -196,6 +279,41 @@ export function callMatchesRoute(
196
279
  return matchPathParams(prefixed, call.path) !== null;
197
280
  }
198
281
 
282
+ /** Exact key for object ops; list matches empty prefix, the prefix itself, or a key under it. */
283
+ export function storageActivityMatchesKey(
284
+ event: { op: StorageInspectOp; key: string },
285
+ selectedKey: string,
286
+ ): boolean {
287
+ if (event.op === "list") {
288
+ const prefix = event.key;
289
+ return prefix === "" || selectedKey === prefix || selectedKey.startsWith(prefix);
290
+ }
291
+ return event.key === selectedKey;
292
+ }
293
+
294
+ export function socketEventMatchesDef(
295
+ event: { path?: string; name?: string },
296
+ socket: { path: string; name: string },
297
+ ): boolean {
298
+ if (event.path && matchPathParams(socket.path, event.path)) return true;
299
+ return event.name === socket.name;
300
+ }
301
+
302
+ export function truncateInspectPreview(value: unknown, max = SOCKET_PREVIEW_MAX): string {
303
+ let text: string;
304
+ if (typeof value === "string") {
305
+ text = value;
306
+ } else {
307
+ try {
308
+ text = JSON.stringify(value) ?? "";
309
+ } catch {
310
+ text = "[unserializable]";
311
+ }
312
+ }
313
+ if (text.length <= max) return text;
314
+ return `${text.slice(0, max)}…`;
315
+ }
316
+
199
317
  export function googleDashboardStatus(mode: "both" | "one" | "none"): DashboardGoogle {
200
318
  if (mode === "both") return "local_override";
201
319
  if (mode === "one") return "incomplete";
@@ -305,6 +423,11 @@ export function createDevInspect(options?: { max?: number }): DevInspectStore {
305
423
  };
306
424
  }
307
425
 
426
+ function isoTimestamp(value: Date | string | undefined): string {
427
+ if (value instanceof Date) return value.toISOString();
428
+ return String(value ?? "");
429
+ }
430
+
308
431
  async function loadAuthUsers(
309
432
  pool: NonNullable<DashboardRuntime>["pool"],
310
433
  ): Promise<DashboardState["auth"]["users"]> {
@@ -320,10 +443,50 @@ async function loadAuthUsers(
320
443
  id: row.id,
321
444
  email: row.email,
322
445
  name: row.name ?? null,
323
- createdAt:
324
- row.created_at instanceof Date
325
- ? row.created_at.toISOString()
326
- : String(row.created_at ?? ""),
446
+ createdAt: isoTimestamp(row.created_at),
447
+ }));
448
+ } catch {
449
+ return [];
450
+ }
451
+ }
452
+
453
+ async function loadStorageObjectCount(
454
+ pool: NonNullable<DashboardRuntime>["pool"],
455
+ ): Promise<number> {
456
+ if (!pool) return 0;
457
+ try {
458
+ const result = await pool.query<{ count: string | number }>(
459
+ `SELECT count(*) FROM robodev_storage.objects`,
460
+ );
461
+ return Number(result.rows[0]?.count ?? 0);
462
+ } catch {
463
+ return 0;
464
+ }
465
+ }
466
+
467
+ async function loadStorageObjects(
468
+ pool: NonNullable<DashboardRuntime>["pool"],
469
+ ): Promise<DashboardStorageObject[]> {
470
+ if (!pool) return [];
471
+ try {
472
+ const result = await pool.query<{
473
+ key: string;
474
+ public: boolean;
475
+ size_bytes: string | number;
476
+ content_type: string;
477
+ created_at: Date | string;
478
+ }>(
479
+ `SELECT key, public, size_bytes, content_type, created_at
480
+ FROM robodev_storage.objects
481
+ ORDER BY created_at DESC`,
482
+ );
483
+ return result.rows.map((row) => ({
484
+ key: row.key,
485
+ public: Boolean(row.public),
486
+ sizeBytes: Number(row.size_bytes),
487
+ contentType: row.content_type,
488
+ createdAt: isoTimestamp(row.created_at),
489
+ url: `/storage/objects/${row.key}`,
327
490
  }));
328
491
  } catch {
329
492
  return [];
@@ -333,6 +496,7 @@ async function loadAuthUsers(
333
496
  export async function buildDashboardState(input: {
334
497
  inspect: DevInspectStore;
335
498
  runtime: DashboardRuntime;
499
+ projectId?: string;
336
500
  googleClientId?: string;
337
501
  googleClientSecret?: string;
338
502
  }): Promise<DashboardState> {
@@ -378,11 +542,41 @@ export async function buildDashboardState(input: {
378
542
  cron: job.def.cron ?? null,
379
543
  file: job.file,
380
544
  }));
545
+ const activity = newest
546
+ .filter((event): event is DevInspectStorageEvent => event.kind === "storage")
547
+ .map((event) => ({
548
+ at: event.at,
549
+ op: event.op,
550
+ key: event.key,
551
+ ok: event.ok,
552
+ error: event.error,
553
+ }));
554
+ const socketEvents = newest
555
+ .filter((event): event is DevInspectSocketEvent => event.kind === "socket")
556
+ .map((event) => ({
557
+ at: event.at,
558
+ socketKind: event.socketKind,
559
+ path: event.path,
560
+ name: event.name,
561
+ userId: event.userId,
562
+ code: event.code,
563
+ reason: event.reason,
564
+ error: event.error,
565
+ preview: event.preview,
566
+ }));
567
+ const socketDefs = (input.runtime?.sockets ?? []).map((socket) => ({
568
+ path: socket.path,
569
+ file: socket.file,
570
+ auth: socketAuthDisplay(socket.def.auth ?? false),
571
+ name: socket.name,
572
+ }));
381
573
 
382
574
  return {
383
575
  overview: {
384
576
  apiCount: routes.length,
385
577
  jobCount: jobs.length,
578
+ storageObjectCount: await loadStorageObjectCount(input.runtime?.pool),
579
+ socketCount: socketDefs.length,
386
580
  google,
387
581
  requestHistogram: input.inspect.requestHistogram(),
388
582
  jobHistogram: input.inspect.jobHistogram(),
@@ -397,6 +591,15 @@ export async function buildDashboardState(input: {
397
591
  google,
398
592
  users: await loadAuthUsers(input.runtime?.pool),
399
593
  },
594
+ storage: {
595
+ objects: await loadStorageObjects(input.runtime?.pool),
596
+ activity,
597
+ },
598
+ sockets: {
599
+ connected: input.projectId ? projectSocketCount(input.projectId) : 0,
600
+ defs: socketDefs,
601
+ events: socketEvents,
602
+ },
400
603
  };
401
604
  }
402
605
 
@@ -488,6 +691,8 @@ export function inspectHtml(): string {
488
691
  <a href="/_robodev/jobs" data-nav="jobs">Jobs</a>
489
692
  <a href="/_robodev/emails" data-nav="emails">Emails</a>
490
693
  <a href="/_robodev/auth" data-nav="auth">Auth</a>
694
+ <a href="/_robodev/storage" data-nav="storage">Storage</a>
695
+ <a href="/_robodev/sockets" data-nav="sockets">Sockets</a>
491
696
  </nav>
492
697
  </header>
493
698
  <p class="lead">Local dashboard. Readonly. Polls every 1s.</p>
@@ -496,6 +701,8 @@ export function inspectHtml(): string {
496
701
  <div class="cards">
497
702
  <div class="card"><div class="label">APIs</div><div class="value" id="api-count">0</div></div>
498
703
  <div class="card"><div class="label">Jobs</div><div class="value" id="job-count">0</div></div>
704
+ <div class="card"><div class="label">Storage</div><div class="value" id="storage-count">0</div></div>
705
+ <div class="card"><div class="label">Sockets</div><div class="value" id="socket-count">0</div></div>
499
706
  <div class="card"><div class="label">Auth</div><div class="note" id="google-line"></div></div>
500
707
  </div>
501
708
  <div class="links" id="doc-links"></div>
@@ -544,16 +751,43 @@ export function inspectHtml(): string {
544
751
  <div id="auth-users"></div>
545
752
  </section>
546
753
 
754
+ <section id="page-storage" class="page">
755
+ <div class="tabs">
756
+ <button type="button" data-storage-tab="objects">Objects</button>
757
+ <button type="button" data-storage-tab="activity">Activity</button>
758
+ </div>
759
+ <div id="storage-filter" class="filter" hidden></div>
760
+ <div id="storage-objects"></div>
761
+ <div id="storage-activity" hidden></div>
762
+ </section>
763
+
764
+ <section id="page-sockets" class="page">
765
+ <p class="lead" id="sockets-connected">0 connected</p>
766
+ <div class="tabs">
767
+ <button type="button" data-socket-tab="sockets">Sockets</button>
768
+ <button type="button" data-socket-tab="events">Events</button>
769
+ </div>
770
+ <div id="sockets-filter" class="filter" hidden></div>
771
+ <div id="sockets-defs"></div>
772
+ <div id="sockets-events" hidden></div>
773
+ </section>
774
+
547
775
  <script>
548
776
  var PAGES = {
549
777
  "/_robodev": "overview",
550
778
  "/_robodev/apis": "apis",
551
779
  "/_robodev/jobs": "jobs",
552
780
  "/_robodev/emails": "emails",
553
- "/_robodev/auth": "auth"
781
+ "/_robodev/auth": "auth",
782
+ "/_robodev/storage": "storage",
783
+ "/_robodev/sockets": "sockets"
554
784
  };
555
785
  var apisTab = "routes";
786
+ var storageTab = "objects";
787
+ var socketTab = "sockets";
556
788
  var callFilter = null;
789
+ var storageFilter = null;
790
+ var socketFilter = null;
557
791
  var lastState = null;
558
792
 
559
793
  function pagePath() {
@@ -586,6 +820,17 @@ export function inspectHtml(): string {
586
820
  var prefixed = route.path === "/" ? "/api" : "/api" + route.path;
587
821
  return matchPathParams(prefixed, call.path) !== null;
588
822
  }
823
+ function storageActivityMatches(event, selectedKey) {
824
+ if (event.op === "list") {
825
+ var prefix = event.key;
826
+ return prefix === "" || selectedKey === prefix || selectedKey.indexOf(prefix) === 0;
827
+ }
828
+ return event.key === selectedKey;
829
+ }
830
+ function socketEventMatches(event, socket) {
831
+ if (event.path && matchPathParams(socket.path, event.path)) return true;
832
+ return event.name === socket.name;
833
+ }
589
834
  function timeLabel(at) {
590
835
  return new Date(at).toLocaleTimeString();
591
836
  }
@@ -682,11 +927,45 @@ export function inspectHtml(): string {
682
927
  document.getElementById("apis-routes").hidden = tab !== "routes";
683
928
  document.getElementById("apis-calls").hidden = tab !== "calls";
684
929
  }
930
+ function setStorageTab(tab) {
931
+ storageTab = tab;
932
+ document.querySelector('[data-storage-tab="objects"]').className = tab === "objects" ? "active" : "";
933
+ document.querySelector('[data-storage-tab="activity"]').className = tab === "activity" ? "active" : "";
934
+ document.getElementById("storage-objects").hidden = tab !== "objects";
935
+ document.getElementById("storage-activity").hidden = tab !== "activity";
936
+ }
937
+ function setSocketTab(tab) {
938
+ socketTab = tab;
939
+ document.querySelector('[data-socket-tab="sockets"]').className = tab === "sockets" ? "active" : "";
940
+ document.querySelector('[data-socket-tab="events"]').className = tab === "events" ? "active" : "";
941
+ document.getElementById("sockets-defs").hidden = tab !== "sockets";
942
+ document.getElementById("sockets-events").hidden = tab !== "events";
943
+ }
944
+ function renderFilter(el, labelText, onClear) {
945
+ if (!labelText) {
946
+ el.hidden = true;
947
+ el.replaceChildren();
948
+ return;
949
+ }
950
+ el.hidden = false;
951
+ el.replaceChildren();
952
+ var label = document.createElement("span");
953
+ label.textContent = labelText;
954
+ var clear = document.createElement("button");
955
+ clear.type = "button";
956
+ clear.className = "clear";
957
+ clear.textContent = "Clear filter";
958
+ clear.addEventListener("click", onClear);
959
+ el.appendChild(label);
960
+ el.appendChild(clear);
961
+ }
685
962
  function render(state) {
686
963
  lastState = state;
687
964
  markNav();
688
965
  document.getElementById("api-count").textContent = String(state.overview.apiCount);
689
966
  document.getElementById("job-count").textContent = String(state.overview.jobCount);
967
+ document.getElementById("storage-count").textContent = String(state.overview.storageObjectCount);
968
+ document.getElementById("socket-count").textContent = String(state.overview.socketCount);
690
969
  document.getElementById("google-line").textContent = googleLabel(state.overview.google);
691
970
  var links = document.getElementById("doc-links");
692
971
  links.replaceChildren();
@@ -784,6 +1063,88 @@ export function inspectHtml(): string {
784
1063
  return { cells: [user.id, user.email, user.name || "", user.createdAt] };
785
1064
  })
786
1065
  ));
1066
+
1067
+ fill(document.getElementById("storage-objects"), table(
1068
+ ["Key", "Public", "Size", "Type", "Created", "URL"],
1069
+ state.storage.objects.map(function (object) {
1070
+ return { cells: [object.key, object.public ? "public" : "private", object.sizeBytes, object.contentType, object.createdAt, object.url], object: object };
1071
+ }),
1072
+ function (row) {
1073
+ storageFilter = row.object.key;
1074
+ setStorageTab("activity");
1075
+ render(lastState);
1076
+ }
1077
+ ));
1078
+ var storageEvents = state.storage.activity;
1079
+ if (storageFilter) {
1080
+ storageEvents = state.storage.activity.filter(function (event) {
1081
+ return storageActivityMatches(event, storageFilter);
1082
+ });
1083
+ }
1084
+ renderFilter(
1085
+ document.getElementById("storage-filter"),
1086
+ storageFilter ? "Filtered to " + storageFilter : null,
1087
+ function () {
1088
+ storageFilter = null;
1089
+ render(lastState);
1090
+ }
1091
+ );
1092
+ fill(document.getElementById("storage-activity"), table(
1093
+ ["Time", "Op", "Key", "Status"],
1094
+ storageEvents.map(function (event) {
1095
+ var status = event.ok
1096
+ ? { html: '<span class="ok-text">ok</span>' }
1097
+ : { html: '<span class="bad-text">' + (event.error || "error") + "</span>" };
1098
+ return { cells: [timeLabel(event.at), event.op, event.key, status] };
1099
+ })
1100
+ ));
1101
+ setStorageTab(storageTab);
1102
+
1103
+ document.getElementById("sockets-connected").textContent = String(state.sockets.connected) + " connected";
1104
+ fill(document.getElementById("sockets-defs"), table(
1105
+ ["Path", "File", "Auth"],
1106
+ state.sockets.defs.map(function (socket) {
1107
+ return { cells: [socket.path, socket.file, socket.auth], socket: socket };
1108
+ }),
1109
+ function (row) {
1110
+ socketFilter = { path: row.socket.path, name: row.socket.name };
1111
+ setSocketTab("events");
1112
+ render(lastState);
1113
+ }
1114
+ ));
1115
+ var socketEvents = state.sockets.events;
1116
+ if (socketFilter) {
1117
+ socketEvents = state.sockets.events.filter(function (event) {
1118
+ return socketEventMatches(event, socketFilter);
1119
+ });
1120
+ }
1121
+ renderFilter(
1122
+ document.getElementById("sockets-filter"),
1123
+ socketFilter ? "Filtered to " + socketFilter.path : null,
1124
+ function () {
1125
+ socketFilter = null;
1126
+ render(lastState);
1127
+ }
1128
+ );
1129
+ fill(document.getElementById("sockets-events"), table(
1130
+ ["Time", "Kind", "Path / name", "User", "Detail", "Preview"],
1131
+ socketEvents.map(function (event) {
1132
+ var detail = event.error
1133
+ ? { html: '<span class="bad-text">' + event.error + "</span>" }
1134
+ : [event.code, event.reason].filter(function (part) { return part !== undefined && part !== ""; }).join(" ");
1135
+ return {
1136
+ cells: [
1137
+ timeLabel(event.at),
1138
+ event.socketKind,
1139
+ event.path || event.name || "",
1140
+ event.userId || "",
1141
+ detail,
1142
+ event.preview || ""
1143
+ ]
1144
+ };
1145
+ })
1146
+ ));
1147
+ setSocketTab(socketTab);
787
1148
  }
788
1149
  document.querySelector('[data-tab="routes"]').addEventListener("click", function () {
789
1150
  setApisTab("routes");
@@ -793,6 +1154,22 @@ export function inspectHtml(): string {
793
1154
  setApisTab("calls");
794
1155
  if (lastState) render(lastState);
795
1156
  });
1157
+ document.querySelector('[data-storage-tab="objects"]').addEventListener("click", function () {
1158
+ setStorageTab("objects");
1159
+ });
1160
+ document.querySelector('[data-storage-tab="activity"]').addEventListener("click", function () {
1161
+ storageFilter = null;
1162
+ setStorageTab("activity");
1163
+ if (lastState) render(lastState);
1164
+ });
1165
+ document.querySelector('[data-socket-tab="sockets"]').addEventListener("click", function () {
1166
+ setSocketTab("sockets");
1167
+ });
1168
+ document.querySelector('[data-socket-tab="events"]').addEventListener("click", function () {
1169
+ socketFilter = null;
1170
+ setSocketTab("events");
1171
+ if (lastState) render(lastState);
1172
+ });
796
1173
  async function poll() {
797
1174
  try {
798
1175
  var state = await fetch("/_robodev/state.json").then(function (r) { return r.json(); });
@@ -801,6 +1178,8 @@ export function inspectHtml(): string {
801
1178
  }
802
1179
  markNav();
803
1180
  setApisTab("routes");
1181
+ setStorageTab("objects");
1182
+ setSocketTab("sockets");
804
1183
  poll();
805
1184
  setInterval(poll, 1000);
806
1185
  </script>
package/src/dev-server.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  buildDashboardState,
37
37
  isInspectPath,
38
38
  shouldCountRequestHistogram,
39
+ truncateInspectPreview,
39
40
  type DevInspectStore,
40
41
  } from "./dev-inspect.js";
41
42
 
@@ -156,26 +157,52 @@ async function resolveSocketUser(
156
157
  return { ok: true, user };
157
158
  }
158
159
 
160
+ function recordSocketHandshake(
161
+ inspect: DevInspectStore,
162
+ input: {
163
+ path: string;
164
+ name?: string;
165
+ userId?: string | null;
166
+ error?: string;
167
+ },
168
+ ): void {
169
+ inspect.record({
170
+ kind: "socket",
171
+ socketKind: "connect",
172
+ path: input.path,
173
+ name: input.name,
174
+ userId: input.userId,
175
+ error: input.error,
176
+ });
177
+ }
178
+
159
179
  async function handleLocalSocket(
160
180
  socket: WebSocket,
161
181
  request: FastifyRequest,
162
182
  options: DevServerOptions,
163
183
  getRuntime: () => DevRuntime | null,
164
184
  ): Promise<void> {
185
+ const pathname = pathnameOf(request.url);
165
186
  const runtime = getRuntime();
166
187
  if (!runtime) {
188
+ recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
167
189
  socket.close(SOCKET_CLOSE_POLICY, "not_found");
168
190
  return;
169
191
  }
170
- const pathname = pathnameOf(request.url);
171
192
  const matched = matchSocketRoute(pathname, runtime.sockets);
172
193
  if (!matched) {
194
+ recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
173
195
  socket.close(SOCKET_CLOSE_POLICY, "not_found");
174
196
  return;
175
197
  }
176
198
 
177
199
  const auth = await resolveSocketUser(options, matched.socket.def, request);
178
200
  if (!auth.ok) {
201
+ recordSocketHandshake(options.inspect, {
202
+ path: pathname,
203
+ name: matched.socket.name,
204
+ error: "unauthorized",
205
+ });
179
206
  socket.close(SOCKET_CLOSE_POLICY, "unauthorized");
180
207
  return;
181
208
  }
@@ -215,12 +242,24 @@ async function handleLocalSocket(
215
242
  onClose: matched.socket.def.onClose,
216
243
  });
217
244
  if (!session.ok) {
245
+ recordSocketHandshake(options.inspect, {
246
+ path: pathname,
247
+ name: matched.socket.name,
248
+ userId: auth.user?.id ?? null,
249
+ error: session.reason,
250
+ });
218
251
  if (socket.readyState === socket.OPEN) {
219
252
  socket.close(session.code, session.reason);
220
253
  }
221
254
  return;
222
255
  }
223
256
 
257
+ recordSocketHandshake(options.inspect, {
258
+ path: pathname,
259
+ name: matched.socket.name,
260
+ userId: auth.user?.id ?? null,
261
+ });
262
+
224
263
  let alive = true;
225
264
  const pingTimer = setInterval(() => {
226
265
  if (socket.readyState !== socket.OPEN) {
@@ -245,18 +284,52 @@ async function handleLocalSocket(
245
284
  });
246
285
  socket.on("message", (data, isBinary) => {
247
286
  if (isBinary) {
287
+ options.inspect.record({
288
+ kind: "socket",
289
+ socketKind: "message",
290
+ path: pathname,
291
+ name: matched.socket.name,
292
+ userId: auth.user?.id ?? null,
293
+ error: "binary",
294
+ });
248
295
  session.onBinary();
249
296
  return;
250
297
  }
251
298
  const text = typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8");
299
+ options.inspect.record({
300
+ kind: "socket",
301
+ socketKind: "message",
302
+ path: pathname,
303
+ name: matched.socket.name,
304
+ userId: auth.user?.id ?? null,
305
+ preview: truncateInspectPreview(text),
306
+ });
252
307
  session.onText(text);
253
308
  });
254
- socket.on("close", () => {
309
+ socket.on("close", (code, reason) => {
255
310
  clearInterval(pingTimer);
311
+ options.inspect.record({
312
+ kind: "socket",
313
+ socketKind: "close",
314
+ path: pathname,
315
+ name: matched.socket.name,
316
+ userId: auth.user?.id ?? null,
317
+ code,
318
+ reason: reason?.toString() || undefined,
319
+ });
256
320
  session.onClose();
257
321
  });
258
322
  }
259
323
 
324
+ function recordStorageServe(
325
+ inspect: DevInspectStore,
326
+ key: string,
327
+ ok: boolean,
328
+ error?: string,
329
+ ): void {
330
+ inspect.record({ kind: "storage", op: "serve", key, ok, error });
331
+ }
332
+
260
333
  async function serveLocalStorage(
261
334
  request: FastifyRequest,
262
335
  reply: FastifyReply,
@@ -264,12 +337,13 @@ async function serveLocalStorage(
264
337
  getRuntime: () => DevRuntime | null,
265
338
  pathname: string,
266
339
  ): Promise<void> {
340
+ const encodedKey = pathname.slice("/storage/objects/".length);
267
341
  const runtime = getRuntime();
268
342
  if (!runtime) {
269
343
  await reply.code(503).send({ error: "not_ready" });
344
+ recordStorageServe(options.inspect, encodedKey, false, "not_ready");
270
345
  return;
271
346
  }
272
- const encodedKey = pathname.slice("/storage/objects/".length);
273
347
  let key: string;
274
348
  try {
275
349
  key = decodeURIComponent(encodedKey);
@@ -278,9 +352,12 @@ async function serveLocalStorage(
278
352
  const mapped = codedErrorBody(error);
279
353
  if (mapped) {
280
354
  await reply.code(mapped.status).send(mapped.body);
355
+ const mappedError = typeof mapped.body.error === "string" ? mapped.body.error : "invalid_key";
356
+ recordStorageServe(options.inspect, encodedKey, false, mappedError);
281
357
  return;
282
358
  }
283
359
  await reply.code(400).send({ error: "invalid_key" });
360
+ recordStorageServe(options.inspect, encodedKey, false, "invalid_key");
284
361
  return;
285
362
  }
286
363
 
@@ -291,6 +368,7 @@ async function serveLocalStorage(
291
368
  });
292
369
  if (!resolved) {
293
370
  await reply.code(404).send({ error: "not_found" });
371
+ recordStorageServe(options.inspect, key, false, "not_found");
294
372
  return;
295
373
  }
296
374
 
@@ -306,10 +384,12 @@ async function serveLocalStorage(
306
384
  );
307
385
  if (!resolved.row.public && !user && !signed) {
308
386
  await reply.code(404).send({ error: "not_found" });
387
+ recordStorageServe(options.inspect, key, false, "not_found");
309
388
  return;
310
389
  }
311
390
 
312
391
  await reply.type(resolved.row.content_type).send(resolved.body);
392
+ recordStorageServe(options.inspect, key, true);
313
393
  }
314
394
 
315
395
  /**
@@ -390,6 +470,7 @@ export async function startDevServer(
390
470
  await buildDashboardState({
391
471
  inspect: options.inspect,
392
472
  runtime: getRuntime(),
473
+ projectId: options.projectId,
393
474
  googleClientId: options.googleClientId,
394
475
  googleClientSecret: options.googleClientSecret,
395
476
  }),
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.36.0",
15
+ "robodev": "^0.37.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.11.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.36.0",
14
+ "robodev": "^0.37.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.11.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.36.0",
14
+ "robodev": "^0.37.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.36.0",
15
+ "robodev": "^0.37.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.36.0",
15
+ "robodev": "^0.37.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }