robodev 0.34.0 → 0.36.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.34.0",
3
+ "version": "0.36.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",
@@ -22,14 +22,16 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@fastify/cors": "^11.3.0",
25
+ "@fastify/websocket": "^11.2.0",
25
26
  "embedded-postgres": "17.10.0-beta.17",
26
27
  "fastify": "^5.12.1",
27
28
  "tsx": "^4.20.5",
28
- "@robodev-ai/runtime": "0.3.0",
29
+ "@robodev-ai/runtime": "0.4.1",
29
30
  "@robodev-ai/sdk": "0.11.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^24.3.0",
34
+ "@types/ws": "^8.18.1",
33
35
  "typescript": "^5.9.2"
34
36
  },
35
37
  "scripts": {
@@ -1,18 +1,20 @@
1
+ import {
2
+ createLocalStorageClient,
3
+ createProjectSocketsClient,
4
+ type StorageQueryable,
5
+ } from "@robodev-ai/runtime";
1
6
  import type { RouteClients } from "@robodev-ai/runtime";
2
7
  import type { EmailClient, JobEnqueueInput, LlmClient } from "@robodev-ai/sdk";
3
8
  import type { DevInspectStore } from "./dev-inspect.js";
4
9
 
5
10
  /**
6
11
  * The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
7
- * `jobs` enqueues onto the local engine; everything else that needs Starbase
8
- * infrastructure fails with a message that says so rather than silently doing nothing.
12
+ * `jobs`, `storage`, and `sockets` run locally. llm / agent / push throw hosted-style
13
+ * not-configured codes rather than silently doing nothing.
9
14
  */
10
15
 
11
- function unavailable(feature: string): never {
12
- throw Object.assign(
13
- new Error(`ctx.${feature} needs hosted Starbase. Run robodev deploy to use it.`),
14
- { statusCode: 501 },
15
- );
16
+ function notConfigured(code: string): never {
17
+ throw Object.assign(new Error(code), { statusCode: 400, code });
16
18
  }
17
19
 
18
20
  function errorMessage(error: unknown): string {
@@ -37,8 +39,8 @@ export function consoleEmailClient(
37
39
 
38
40
  function offlineLlmClient(): LlmClient {
39
41
  return {
40
- complete: () => unavailable("llm"),
41
- stream: () => unavailable("llm"),
42
+ complete: () => notConfigured("llm_not_configured"),
43
+ stream: () => notConfigured("llm_not_configured"),
42
44
  };
43
45
  }
44
46
 
@@ -47,29 +49,35 @@ export function devRouteClients(input: {
47
49
  log: (line: string) => void;
48
50
  enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
49
51
  inspect: DevInspectStore;
52
+ query: StorageQueryable["query"];
53
+ objectsDir: string;
54
+ publicBaseUrl: string;
55
+ projectId: string;
56
+ jwtSecret: string;
50
57
  }): RouteClients {
51
58
  return {
52
59
  email: consoleEmailClient(input.log, input.inspect),
53
60
  llm: offlineLlmClient(),
54
61
  agent: {
55
- createSession: () => unavailable("agent"),
56
- start: () => unavailable("agent"),
57
- events: () => unavailable("agent"),
58
- subscribe: () => unavailable("agent"),
59
- destroy: () => unavailable("agent"),
62
+ createSession: () => notConfigured("llm_not_configured"),
63
+ start: () => notConfigured("llm_not_configured"),
64
+ events: () => notConfigured("llm_not_configured"),
65
+ subscribe: () => notConfigured("llm_not_configured"),
66
+ destroy: () => notConfigured("llm_not_configured"),
60
67
  },
61
- storage: {
62
- upload: () => unavailable("storage"),
63
- get: () => unavailable("storage"),
64
- getUrl: () => unavailable("storage"),
65
- delete: () => unavailable("storage"),
66
- list: () => unavailable("storage"),
67
- },
68
- push: { send: () => unavailable("push") },
68
+ storage: createLocalStorageClient({
69
+ query: input.query,
70
+ objectsDir: input.objectsDir,
71
+ publicBaseUrl: input.publicBaseUrl,
72
+ projectId: input.projectId,
73
+ jwtSecret: input.jwtSecret,
74
+ }),
75
+ push: { send: () => notConfigured("push_not_configured") },
69
76
  jobs: {
70
77
  enqueue: async (job) => {
71
78
  try {
72
79
  const result = await input.enqueue(job);
80
+ // Enqueue is not a run; the dashboard Jobs page reads `job_run` from onJobFinished.
73
81
  input.inspect.record({
74
82
  kind: "job",
75
83
  name: job.name,
@@ -88,10 +96,7 @@ export function devRouteClients(input: {
88
96
  }
89
97
  },
90
98
  },
91
- sockets: {
92
- send: () => unavailable("sockets"),
93
- broadcast: () => unavailable("sockets"),
94
- },
99
+ sockets: createProjectSocketsClient(input.projectId),
95
100
  env: input.env,
96
101
  };
97
102
  }
@@ -1,9 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import {
4
+ buildDashboardState,
5
+ callMatchesRoute,
4
6
  createDevInspect,
5
7
  inspectHtml,
8
+ isHistogramExcludedPath,
6
9
  isInspectPath,
10
+ shouldCountRequestHistogram,
7
11
  type DevInspectAuthCodeEvent,
8
12
  type DevInspectEmailEvent,
9
13
  type DevInspectJobEvent,
@@ -11,9 +15,18 @@ import {
11
15
  type DevInspectRequestEvent,
12
16
  } from "./dev-inspect.js";
13
17
 
14
- test("isInspectPath excludes only inspect self-routes", () => {
18
+ const fileRuntime = {
19
+ routes: [{ method: "get", path: "/api/planets" }],
20
+ };
21
+
22
+ test("isInspectPath covers dashboard pages and JSON only", () => {
15
23
  assert.equal(isInspectPath("/_robodev"), true);
24
+ assert.equal(isInspectPath("/_robodev/apis"), true);
25
+ assert.equal(isInspectPath("/_robodev/jobs"), true);
26
+ assert.equal(isInspectPath("/_robodev/emails"), true);
27
+ assert.equal(isInspectPath("/_robodev/auth"), true);
16
28
  assert.equal(isInspectPath("/_robodev/logs"), true);
29
+ assert.equal(isInspectPath("/_robodev/state.json"), true);
17
30
  assert.equal(isInspectPath("/_robodev/logs.json"), true);
18
31
  assert.equal(isInspectPath("/_robodev/docs"), false);
19
32
  assert.equal(isInspectPath("/_robodev/ready"), false);
@@ -22,6 +35,23 @@ test("isInspectPath excludes only inspect self-routes", () => {
22
35
  assert.equal(isInspectPath("/api/planets"), false);
23
36
  });
24
37
 
38
+ test("shouldCountRequestHistogram counts Auth and matched file routes only", () => {
39
+ assert.equal(shouldCountRequestHistogram("/api/user/me", fileRuntime, "GET"), true);
40
+ assert.equal(shouldCountRequestHistogram("/api/user/auth/google", fileRuntime, "GET"), true);
41
+ assert.equal(shouldCountRequestHistogram("/api/planets", fileRuntime, "GET"), true);
42
+ assert.equal(shouldCountRequestHistogram("/api/planets", fileRuntime, "POST"), false);
43
+ assert.equal(shouldCountRequestHistogram("/api/unknown", fileRuntime, "GET"), false);
44
+ assert.equal(shouldCountRequestHistogram("/_robodev", fileRuntime, "GET"), false);
45
+ assert.equal(shouldCountRequestHistogram("/_robodev/apis", fileRuntime, "GET"), false);
46
+ assert.equal(shouldCountRequestHistogram("/_robodev/docs", fileRuntime, "GET"), false);
47
+ assert.equal(shouldCountRequestHistogram("/_robodev/scalar", fileRuntime, "GET"), false);
48
+ assert.equal(shouldCountRequestHistogram("/_robodev/ready", fileRuntime, "GET"), false);
49
+ assert.equal(shouldCountRequestHistogram("/openapi.json", fileRuntime, "GET"), false);
50
+ assert.equal(shouldCountRequestHistogram("/docs", fileRuntime, "GET"), false);
51
+ assert.equal(shouldCountRequestHistogram("/swagger", fileRuntime, "GET"), false);
52
+ assert.equal(isHistogramExcludedPath("/scalar"), true);
53
+ });
54
+
25
55
  test("ring buffer drops oldest events past max", () => {
26
56
  const inspect = createDevInspect({ max: 3 });
27
57
  inspect.record({ kind: "log", message: "a" });
@@ -37,6 +67,32 @@ test("ring buffer drops oldest events past max", () => {
37
67
  );
38
68
  });
39
69
 
70
+ test("request histogram survives 500-event ring rotation", () => {
71
+ const inspect = createDevInspect({ max: 2 });
72
+ inspect.incrementRequestHistogram(200);
73
+ inspect.incrementRequestHistogram(200);
74
+ inspect.incrementRequestHistogram(404);
75
+ inspect.incrementRequestHistogram(500);
76
+ inspect.record({ kind: "request", method: "GET", path: "/api/a", status: 200, durationMs: 1 });
77
+ inspect.record({ kind: "request", method: "GET", path: "/api/b", status: 200, durationMs: 1 });
78
+ inspect.record({ kind: "request", method: "GET", path: "/api/c", status: 200, durationMs: 1 });
79
+ assert.equal(inspect.list().length, 2);
80
+
81
+ const last = inspect.requestHistogram().at(-1);
82
+ assert.equal(last?.c2xx, 2);
83
+ assert.equal(last?.c4xx, 1);
84
+ assert.equal(last?.c5xx, 1);
85
+ });
86
+
87
+ test("3xx does not increment request histogram buckets", () => {
88
+ const inspect = createDevInspect();
89
+ inspect.incrementRequestHistogram(302);
90
+ const last = inspect.requestHistogram().at(-1);
91
+ assert.equal(last?.c2xx, 0);
92
+ assert.equal(last?.c4xx, 0);
93
+ assert.equal(last?.c5xx, 0);
94
+ });
95
+
40
96
  test("list is newest last and does not leak the internal buffer", () => {
41
97
  const inspect = createDevInspect({ max: 500 });
42
98
  inspect.record({ kind: "log", message: "first" });
@@ -112,12 +168,85 @@ test("record assigns id, at, and the expected fields for each kind", () => {
112
168
  assert.equal(inspect.list().length, 5);
113
169
  });
114
170
 
115
- test("inspect HTML has four lists and polls logs.json", () => {
171
+ test("callMatchesRoute uses matchPathParams and requires method", () => {
172
+ const route = { method: "GET", path: "/api/planets/:id" };
173
+ assert.equal(callMatchesRoute({ method: "GET", path: "/api/planets/123" }, route), true);
174
+ assert.equal(callMatchesRoute({ method: "GET", path: "/api/planets" }, route), false);
175
+ assert.equal(callMatchesRoute({ method: "POST", path: "/api/planets/123" }, route), false);
176
+ });
177
+
178
+ test("dashboard snapshot lists reserved Auth, filters emails, and ignores enqueue jobs", async () => {
179
+ const inspect = createDevInspect();
180
+ inspect.record({
181
+ kind: "request",
182
+ method: "GET",
183
+ path: "/api/planets",
184
+ status: 200,
185
+ durationMs: 4,
186
+ });
187
+ inspect.record({
188
+ kind: "request",
189
+ method: "GET",
190
+ path: "/_robodev/docs",
191
+ status: 200,
192
+ durationMs: 1,
193
+ });
194
+ inspect.record({ kind: "email", to: "a@b.test", subject: "Hello", text: "Body" });
195
+ inspect.record({ kind: "auth_code", type: "magic", to: "a@b.test", code: "999111" });
196
+ inspect.record({ kind: "job", name: "hourly", payload: { n: 1 }, error: "" });
197
+ inspect.record({
198
+ kind: "job_run",
199
+ name: "hourly",
200
+ status: "failed",
201
+ payload: { n: 1 },
202
+ error: "boom",
203
+ });
204
+ inspect.incrementJobHistogram("failed");
205
+
206
+ const state = await buildDashboardState({
207
+ inspect,
208
+ runtime: {
209
+ routes: [{ method: "get", path: "/api/planets", file: "api/planets.ts" }],
210
+ jobs: [{ name: "hourly", file: "jobs/hourly.ts", def: { cron: "0 * * * *" } }],
211
+ },
212
+ });
213
+
214
+ assert.equal(state.overview.apiCount, 1 + 12);
215
+ assert.equal(state.overview.jobCount, 1);
216
+ assert.equal(state.overview.google, "broker");
217
+ assert.ok(state.routes.some((route) => route.path === "/api/user/auth/google/callback"));
218
+ assert.equal(state.calls.length, 1);
219
+ assert.equal(state.calls[0]?.path, "/api/planets");
220
+ assert.equal(state.emails.length, 1);
221
+ assert.equal(state.emails[0]?.subject, "Hello");
222
+ assert.equal(state.jobRuns.length, 1);
223
+ assert.equal(state.jobRuns[0]?.status, "failed");
224
+ assert.equal(state.overview.jobHistogram.at(-1)?.failed, 1);
225
+ assert.deepEqual(state.auth.users, []);
226
+ });
227
+
228
+ test("incomplete Google credentials are not labeled override", async () => {
229
+ const state = await buildDashboardState({
230
+ inspect: createDevInspect(),
231
+ runtime: { routes: [], jobs: [] },
232
+ googleClientId: "only-id",
233
+ });
234
+ assert.equal(state.overview.google, "incomplete");
235
+ assert.equal(state.auth.google, "incomplete");
236
+ });
237
+
238
+ test("inspect HTML has nav labels and polls state.json", () => {
116
239
  const html = inspectHtml();
117
- assert.match(html, /<h2>Requests<\/h2>/);
118
- assert.match(html, /<h2>Emails<\/h2>/);
119
- assert.match(html, /<h2>Jobs<\/h2>/);
120
- assert.match(html, /<h2>Logs<\/h2>/);
121
- assert.match(html, /\/_robodev\/logs\.json/);
240
+ assert.match(html, />Overview</);
241
+ assert.match(html, />APIs</);
242
+ assert.match(html, />Jobs</);
243
+ assert.match(html, />Emails</);
244
+ assert.match(html, />Auth</);
245
+ assert.doesNotMatch(html, /<h2>Requests<\/h2>/);
246
+ assert.doesNotMatch(html, /<h2>Emails<\/h2>/);
247
+ assert.doesNotMatch(html, /<h2>Jobs<\/h2>/);
248
+ assert.doesNotMatch(html, /<h2>Logs<\/h2>/);
249
+ assert.match(html, /\/_robodev\/state\.json/);
250
+ assert.doesNotMatch(html, /fetch\("\/_robodev\/logs\.json"\)/);
122
251
  assert.match(html, /setInterval\(poll, 1000\)/);
123
252
  });