robodev 0.36.0 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.36.0",
3
+ "version": "0.37.1",
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",
@@ -26,8 +26,8 @@
26
26
  "embedded-postgres": "17.10.0-beta.17",
27
27
  "fastify": "^5.12.1",
28
28
  "tsx": "^4.20.5",
29
- "@robodev-ai/runtime": "0.4.1",
30
- "@robodev-ai/sdk": "0.11.0"
29
+ "@robodev-ai/runtime": "0.4.2",
30
+ "@robodev-ai/sdk": "0.11.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.3.0",
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { consoleEmailClient } from "./dev-clients.js";
7
+ import { createDevInspect } from "./dev-inspect.js";
8
+
9
+ test("consoleEmailClient renders a local robodev/emails catalog file", async () => {
10
+ const root = await mkdtemp(join(tmpdir(), "robodev-dev-email-"));
11
+ await mkdir(join(root, "robodev", "emails"), { recursive: true });
12
+ await writeFile(join(root, "robodev", "emails", "welcome.html"), "<p>Hi {{name}}</p>", "utf8");
13
+ await writeFile(
14
+ join(root, "robodev", "emails", "welcome.json"),
15
+ JSON.stringify({ subject: "Hello {{name}}", text: "Hi {{name}}" }),
16
+ "utf8",
17
+ );
18
+ const lines: string[] = [];
19
+ const inspect = createDevInspect({ max: 20 });
20
+ const email = consoleEmailClient((line) => lines.push(line), inspect, root);
21
+ const result = await email.send({
22
+ to: "ada@example.com",
23
+ template: "welcome",
24
+ vars: { name: "Ada" },
25
+ });
26
+ assert.equal(result.status, "sent");
27
+ assert.match(lines[0] ?? "", /Hello Ada/);
28
+ assert.match(lines[1] ?? "", /Hi Ada/);
29
+ });
30
+
31
+ test("consoleEmailClient throws template_not_found without a local file or body", async () => {
32
+ const inspect = createDevInspect({ max: 20 });
33
+ const email = consoleEmailClient(() => undefined, inspect, await mkdtemp(join(tmpdir(), "robodev-dev-email-")));
34
+ await assert.rejects(
35
+ () => email.send({ to: "ada@example.com", template: "missing" }),
36
+ /template_not_found/,
37
+ );
38
+ });
@@ -1,11 +1,23 @@
1
1
  import {
2
2
  createLocalStorageClient,
3
3
  createProjectSocketsClient,
4
+ loadLocalEmailTemplate,
5
+ resolveEmailSendInput,
4
6
  type StorageQueryable,
5
7
  } from "@robodev-ai/runtime";
6
8
  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";
9
+ import type {
10
+ EmailClient,
11
+ JobEnqueueInput,
12
+ LlmClient,
13
+ SocketsClient,
14
+ StorageClient,
15
+ } from "@robodev-ai/sdk";
16
+ import {
17
+ truncateInspectPreview,
18
+ type DevInspectStore,
19
+ type StorageInspectOp,
20
+ } from "./dev-inspect.js";
9
21
 
10
22
  /**
11
23
  * The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
@@ -21,17 +33,90 @@ function errorMessage(error: unknown): string {
21
33
  return error instanceof Error ? error.message : String(error);
22
34
  }
23
35
 
36
+ function inspectStorageClient(client: StorageClient, inspect: DevInspectStore): StorageClient {
37
+ async function tracked<T>(op: StorageInspectOp, key: string, run: () => Promise<T>): Promise<T> {
38
+ try {
39
+ const result = await run();
40
+ inspect.record({ kind: "storage", op, key, ok: true });
41
+ return result;
42
+ } catch (error) {
43
+ inspect.record({
44
+ kind: "storage",
45
+ op,
46
+ key,
47
+ ok: false,
48
+ error: errorMessage(error),
49
+ });
50
+ throw error;
51
+ }
52
+ }
53
+ return {
54
+ upload: (key, data, options) => tracked("upload", key, () => client.upload(key, data, options)),
55
+ get: (key) => tracked("get", key, () => client.get(key)),
56
+ getUrl: (key, options) => tracked("getUrl", key, () => client.getUrl(key, options)),
57
+ delete: (key) => tracked("delete", key, () => client.delete(key)),
58
+ list: (options) => tracked("list", options?.prefix ?? "", () => client.list(options)),
59
+ };
60
+ }
61
+
62
+ function inspectSocketsClient(client: SocketsClient, inspect: DevInspectStore): SocketsClient {
63
+ function tracked(
64
+ socketKind: "send" | "broadcast",
65
+ name: string,
66
+ payload: unknown,
67
+ run: () => void,
68
+ ) {
69
+ try {
70
+ run();
71
+ inspect.record({
72
+ kind: "socket",
73
+ socketKind,
74
+ name,
75
+ preview: truncateInspectPreview(payload),
76
+ });
77
+ } catch (error) {
78
+ inspect.record({
79
+ kind: "socket",
80
+ socketKind,
81
+ name,
82
+ error: errorMessage(error),
83
+ preview: truncateInspectPreview(payload),
84
+ });
85
+ throw error;
86
+ }
87
+ }
88
+ return {
89
+ send(input) {
90
+ tracked("send", input.name, input.payload, () => client.send(input));
91
+ },
92
+ broadcast(input) {
93
+ tracked("broadcast", input.name, input.payload, () => client.broadcast(input));
94
+ },
95
+ };
96
+ }
97
+
24
98
  /** Prints mail to stdout so local signup and reset flows are followable. */
25
99
  export function consoleEmailClient(
26
100
  log: (line: string) => void,
27
101
  inspect: DevInspectStore,
102
+ projectRoot?: string,
28
103
  ): EmailClient {
29
104
  return {
30
105
  async send(input) {
31
- const to = Array.isArray(input.to) ? input.to.join(", ") : input.to;
32
- log(`email → ${to}: ${input.subject}`);
33
- if (input.text) log(` ${input.text}`);
34
- inspect.record({ kind: "email", to, subject: input.subject, text: input.text });
106
+ const catalog =
107
+ input.template && projectRoot
108
+ ? await loadLocalEmailTemplate(projectRoot, input.template)
109
+ : null;
110
+ const resolved = resolveEmailSendInput(input, catalog);
111
+ const to = Array.isArray(resolved.to) ? resolved.to.join(", ") : resolved.to;
112
+ log(`email → ${to}: ${resolved.subject ?? ""}`);
113
+ if (resolved.text) log(` ${resolved.text}`);
114
+ inspect.record({
115
+ kind: "email",
116
+ to,
117
+ subject: resolved.subject ?? "",
118
+ text: resolved.text,
119
+ });
35
120
  return { id: `local_${Date.now().toString(36)}`, status: "sent" };
36
121
  },
37
122
  };
@@ -54,9 +139,10 @@ export function devRouteClients(input: {
54
139
  publicBaseUrl: string;
55
140
  projectId: string;
56
141
  jwtSecret: string;
142
+ projectRoot?: string;
57
143
  }): RouteClients {
58
144
  return {
59
- email: consoleEmailClient(input.log, input.inspect),
145
+ email: consoleEmailClient(input.log, input.inspect, input.projectRoot),
60
146
  llm: offlineLlmClient(),
61
147
  agent: {
62
148
  createSession: () => notConfigured("llm_not_configured"),
@@ -65,13 +151,16 @@ export function devRouteClients(input: {
65
151
  subscribe: () => notConfigured("llm_not_configured"),
66
152
  destroy: () => notConfigured("llm_not_configured"),
67
153
  },
68
- storage: createLocalStorageClient({
69
- query: input.query,
70
- objectsDir: input.objectsDir,
71
- publicBaseUrl: input.publicBaseUrl,
72
- projectId: input.projectId,
73
- jwtSecret: input.jwtSecret,
74
- }),
154
+ storage: inspectStorageClient(
155
+ createLocalStorageClient({
156
+ query: input.query,
157
+ objectsDir: input.objectsDir,
158
+ publicBaseUrl: input.publicBaseUrl,
159
+ projectId: input.projectId,
160
+ jwtSecret: input.jwtSecret,
161
+ }),
162
+ input.inspect,
163
+ ),
75
164
  push: { send: () => notConfigured("push_not_configured") },
76
165
  jobs: {
77
166
  enqueue: async (job) => {
@@ -96,7 +185,7 @@ export function devRouteClients(input: {
96
185
  }
97
186
  },
98
187
  },
99
- sockets: createProjectSocketsClient(input.projectId),
188
+ sockets: inspectSocketsClient(createProjectSocketsClient(input.projectId), input.inspect),
100
189
  env: input.env,
101
190
  };
102
191
  }
@@ -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
 
@@ -62,6 +63,7 @@ export type DevServerOptions = {
62
63
  googleClientId?: string;
63
64
  googleClientSecret?: string;
64
65
  storageDir: string;
66
+ projectRoot?: string;
65
67
  };
66
68
 
67
69
  const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
@@ -128,6 +130,7 @@ function routeClients(options: DevServerOptions, getRuntime: () => DevRuntime |
128
130
  publicBaseUrl: options.listenOrigin,
129
131
  projectId: options.projectId,
130
132
  jwtSecret: options.jwtSecret,
133
+ projectRoot: options.projectRoot,
131
134
  });
132
135
  }
133
136
 
@@ -156,26 +159,52 @@ async function resolveSocketUser(
156
159
  return { ok: true, user };
157
160
  }
158
161
 
162
+ function recordSocketHandshake(
163
+ inspect: DevInspectStore,
164
+ input: {
165
+ path: string;
166
+ name?: string;
167
+ userId?: string | null;
168
+ error?: string;
169
+ },
170
+ ): void {
171
+ inspect.record({
172
+ kind: "socket",
173
+ socketKind: "connect",
174
+ path: input.path,
175
+ name: input.name,
176
+ userId: input.userId,
177
+ error: input.error,
178
+ });
179
+ }
180
+
159
181
  async function handleLocalSocket(
160
182
  socket: WebSocket,
161
183
  request: FastifyRequest,
162
184
  options: DevServerOptions,
163
185
  getRuntime: () => DevRuntime | null,
164
186
  ): Promise<void> {
187
+ const pathname = pathnameOf(request.url);
165
188
  const runtime = getRuntime();
166
189
  if (!runtime) {
190
+ recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
167
191
  socket.close(SOCKET_CLOSE_POLICY, "not_found");
168
192
  return;
169
193
  }
170
- const pathname = pathnameOf(request.url);
171
194
  const matched = matchSocketRoute(pathname, runtime.sockets);
172
195
  if (!matched) {
196
+ recordSocketHandshake(options.inspect, { path: pathname, error: "not_found" });
173
197
  socket.close(SOCKET_CLOSE_POLICY, "not_found");
174
198
  return;
175
199
  }
176
200
 
177
201
  const auth = await resolveSocketUser(options, matched.socket.def, request);
178
202
  if (!auth.ok) {
203
+ recordSocketHandshake(options.inspect, {
204
+ path: pathname,
205
+ name: matched.socket.name,
206
+ error: "unauthorized",
207
+ });
179
208
  socket.close(SOCKET_CLOSE_POLICY, "unauthorized");
180
209
  return;
181
210
  }
@@ -215,12 +244,24 @@ async function handleLocalSocket(
215
244
  onClose: matched.socket.def.onClose,
216
245
  });
217
246
  if (!session.ok) {
247
+ recordSocketHandshake(options.inspect, {
248
+ path: pathname,
249
+ name: matched.socket.name,
250
+ userId: auth.user?.id ?? null,
251
+ error: session.reason,
252
+ });
218
253
  if (socket.readyState === socket.OPEN) {
219
254
  socket.close(session.code, session.reason);
220
255
  }
221
256
  return;
222
257
  }
223
258
 
259
+ recordSocketHandshake(options.inspect, {
260
+ path: pathname,
261
+ name: matched.socket.name,
262
+ userId: auth.user?.id ?? null,
263
+ });
264
+
224
265
  let alive = true;
225
266
  const pingTimer = setInterval(() => {
226
267
  if (socket.readyState !== socket.OPEN) {
@@ -245,18 +286,52 @@ async function handleLocalSocket(
245
286
  });
246
287
  socket.on("message", (data, isBinary) => {
247
288
  if (isBinary) {
289
+ options.inspect.record({
290
+ kind: "socket",
291
+ socketKind: "message",
292
+ path: pathname,
293
+ name: matched.socket.name,
294
+ userId: auth.user?.id ?? null,
295
+ error: "binary",
296
+ });
248
297
  session.onBinary();
249
298
  return;
250
299
  }
251
300
  const text = typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8");
301
+ options.inspect.record({
302
+ kind: "socket",
303
+ socketKind: "message",
304
+ path: pathname,
305
+ name: matched.socket.name,
306
+ userId: auth.user?.id ?? null,
307
+ preview: truncateInspectPreview(text),
308
+ });
252
309
  session.onText(text);
253
310
  });
254
- socket.on("close", () => {
311
+ socket.on("close", (code, reason) => {
255
312
  clearInterval(pingTimer);
313
+ options.inspect.record({
314
+ kind: "socket",
315
+ socketKind: "close",
316
+ path: pathname,
317
+ name: matched.socket.name,
318
+ userId: auth.user?.id ?? null,
319
+ code,
320
+ reason: reason?.toString() || undefined,
321
+ });
256
322
  session.onClose();
257
323
  });
258
324
  }
259
325
 
326
+ function recordStorageServe(
327
+ inspect: DevInspectStore,
328
+ key: string,
329
+ ok: boolean,
330
+ error?: string,
331
+ ): void {
332
+ inspect.record({ kind: "storage", op: "serve", key, ok, error });
333
+ }
334
+
260
335
  async function serveLocalStorage(
261
336
  request: FastifyRequest,
262
337
  reply: FastifyReply,
@@ -264,12 +339,13 @@ async function serveLocalStorage(
264
339
  getRuntime: () => DevRuntime | null,
265
340
  pathname: string,
266
341
  ): Promise<void> {
342
+ const encodedKey = pathname.slice("/storage/objects/".length);
267
343
  const runtime = getRuntime();
268
344
  if (!runtime) {
269
345
  await reply.code(503).send({ error: "not_ready" });
346
+ recordStorageServe(options.inspect, encodedKey, false, "not_ready");
270
347
  return;
271
348
  }
272
- const encodedKey = pathname.slice("/storage/objects/".length);
273
349
  let key: string;
274
350
  try {
275
351
  key = decodeURIComponent(encodedKey);
@@ -278,9 +354,12 @@ async function serveLocalStorage(
278
354
  const mapped = codedErrorBody(error);
279
355
  if (mapped) {
280
356
  await reply.code(mapped.status).send(mapped.body);
357
+ const mappedError = typeof mapped.body.error === "string" ? mapped.body.error : "invalid_key";
358
+ recordStorageServe(options.inspect, encodedKey, false, mappedError);
281
359
  return;
282
360
  }
283
361
  await reply.code(400).send({ error: "invalid_key" });
362
+ recordStorageServe(options.inspect, encodedKey, false, "invalid_key");
284
363
  return;
285
364
  }
286
365
 
@@ -291,6 +370,7 @@ async function serveLocalStorage(
291
370
  });
292
371
  if (!resolved) {
293
372
  await reply.code(404).send({ error: "not_found" });
373
+ recordStorageServe(options.inspect, key, false, "not_found");
294
374
  return;
295
375
  }
296
376
 
@@ -306,10 +386,12 @@ async function serveLocalStorage(
306
386
  );
307
387
  if (!resolved.row.public && !user && !signed) {
308
388
  await reply.code(404).send({ error: "not_found" });
389
+ recordStorageServe(options.inspect, key, false, "not_found");
309
390
  return;
310
391
  }
311
392
 
312
393
  await reply.type(resolved.row.content_type).send(resolved.body);
394
+ recordStorageServe(options.inspect, key, true);
313
395
  }
314
396
 
315
397
  /**
@@ -390,6 +472,7 @@ export async function startDevServer(
390
472
  await buildDashboardState({
391
473
  inspect: options.inspect,
392
474
  runtime: getRuntime(),
475
+ projectId: options.projectId,
393
476
  googleClientId: options.googleClientId,
394
477
  googleClientSecret: options.googleClientSecret,
395
478
  }),
package/src/dev.ts CHANGED
@@ -191,6 +191,7 @@ export async function main(args: string[]): Promise<void> {
191
191
  publicBaseUrl: `http://localhost:${parsed.port}`,
192
192
  projectId: DEV_PROJECT_ID,
193
193
  jwtSecret: DEV_JWT_SECRET,
194
+ projectRoot: project.root,
194
195
  }),
195
196
  onJobFinished: (event) => {
196
197
  inspect.record({
@@ -310,6 +311,7 @@ export async function main(args: string[]): Promise<void> {
310
311
  googleClientId: env.GOOGLE_CLIENT_ID,
311
312
  googleClientSecret: env.GOOGLE_CLIENT_SECRET,
312
313
  storageDir,
314
+ projectRoot: project.root,
313
315
  },
314
316
  () => current,
315
317
  );
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.11.0"
12
+ "@robodev-ai/sdk": "^0.11.1"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.36.0",
15
+ "robodev": "^0.37.1",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -8,10 +8,10 @@
8
8
  "deploy": "robodev deploy"
9
9
  },
10
10
  "dependencies": {
11
- "@robodev-ai/sdk": "^0.11.0"
11
+ "@robodev-ai/sdk": "^0.11.1"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.36.0",
14
+ "robodev": "^0.37.1",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -8,10 +8,10 @@
8
8
  "deploy": "robodev deploy"
9
9
  },
10
10
  "dependencies": {
11
- "@robodev-ai/sdk": "^0.11.0"
11
+ "@robodev-ai/sdk": "^0.11.1"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.36.0",
14
+ "robodev": "^0.37.1",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.11.0"
12
+ "@robodev-ai/sdk": "^0.11.1"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.36.0",
15
+ "robodev": "^0.37.1",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.11.0"
12
+ "@robodev-ai/sdk": "^0.11.1"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.36.0",
15
+ "robodev": "^0.37.1",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }