robodev 0.32.0 → 0.33.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.32.0",
3
+ "version": "0.33.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",
@@ -1,5 +1,6 @@
1
1
  import type { RouteClients } from "@robodev-ai/runtime";
2
2
  import type { EmailClient, JobEnqueueInput, LlmClient } from "@robodev-ai/sdk";
3
+ import type { DevInspectStore } from "./dev-inspect.js";
3
4
 
4
5
  /**
5
6
  * The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
@@ -14,13 +15,21 @@ function unavailable(feature: string): never {
14
15
  );
15
16
  }
16
17
 
18
+ function errorMessage(error: unknown): string {
19
+ return error instanceof Error ? error.message : String(error);
20
+ }
21
+
17
22
  /** Prints mail to stdout so local signup and reset flows are followable. */
18
- export function consoleEmailClient(log: (line: string) => void): EmailClient {
23
+ export function consoleEmailClient(
24
+ log: (line: string) => void,
25
+ inspect: DevInspectStore,
26
+ ): EmailClient {
19
27
  return {
20
28
  async send(input) {
21
29
  const to = Array.isArray(input.to) ? input.to.join(", ") : input.to;
22
30
  log(`email → ${to}: ${input.subject}`);
23
31
  if (input.text) log(` ${input.text}`);
32
+ inspect.record({ kind: "email", to, subject: input.subject, text: input.text });
24
33
  return { id: `local_${Date.now().toString(36)}`, status: "sent" };
25
34
  },
26
35
  };
@@ -37,9 +46,10 @@ export function devRouteClients(input: {
37
46
  env: Record<string, string>;
38
47
  log: (line: string) => void;
39
48
  enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
49
+ inspect: DevInspectStore;
40
50
  }): RouteClients {
41
51
  return {
42
- email: consoleEmailClient(input.log),
52
+ email: consoleEmailClient(input.log, input.inspect),
43
53
  llm: offlineLlmClient(),
44
54
  agent: {
45
55
  createSession: () => unavailable("agent"),
@@ -56,7 +66,28 @@ export function devRouteClients(input: {
56
66
  list: () => unavailable("storage"),
57
67
  },
58
68
  push: { send: () => unavailable("push") },
59
- jobs: { enqueue: input.enqueue },
69
+ jobs: {
70
+ enqueue: async (job) => {
71
+ try {
72
+ const result = await input.enqueue(job);
73
+ input.inspect.record({
74
+ kind: "job",
75
+ name: job.name,
76
+ payload: job.payload,
77
+ error: "",
78
+ });
79
+ return result;
80
+ } catch (error) {
81
+ input.inspect.record({
82
+ kind: "job",
83
+ name: job.name,
84
+ payload: job.payload,
85
+ error: errorMessage(error),
86
+ });
87
+ throw error;
88
+ }
89
+ },
90
+ },
60
91
  sockets: {
61
92
  send: () => unavailable("sockets"),
62
93
  broadcast: () => unavailable("sockets"),
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ createDevInspect,
5
+ inspectHtml,
6
+ isInspectPath,
7
+ type DevInspectAuthCodeEvent,
8
+ type DevInspectEmailEvent,
9
+ type DevInspectJobEvent,
10
+ type DevInspectLogEvent,
11
+ type DevInspectRequestEvent,
12
+ } from "./dev-inspect.js";
13
+
14
+ test("isInspectPath excludes only inspect self-routes", () => {
15
+ assert.equal(isInspectPath("/_robodev"), true);
16
+ assert.equal(isInspectPath("/_robodev/logs"), true);
17
+ assert.equal(isInspectPath("/_robodev/logs.json"), true);
18
+ assert.equal(isInspectPath("/_robodev/docs"), false);
19
+ assert.equal(isInspectPath("/_robodev/ready"), false);
20
+ assert.equal(isInspectPath("/_robodev/scalar"), false);
21
+ assert.equal(isInspectPath("/openapi.json"), false);
22
+ assert.equal(isInspectPath("/api/planets"), false);
23
+ });
24
+
25
+ test("ring buffer drops oldest events past max", () => {
26
+ const inspect = createDevInspect({ max: 3 });
27
+ inspect.record({ kind: "log", message: "a" });
28
+ inspect.record({ kind: "log", message: "b" });
29
+ inspect.record({ kind: "log", message: "c" });
30
+ inspect.record({ kind: "log", message: "d" });
31
+
32
+ const list = inspect.list();
33
+ assert.equal(list.length, 3);
34
+ assert.deepEqual(
35
+ list.map((event) => (event.kind === "log" ? event.message : "")),
36
+ ["b", "c", "d"],
37
+ );
38
+ });
39
+
40
+ test("list is newest last and does not leak the internal buffer", () => {
41
+ const inspect = createDevInspect({ max: 500 });
42
+ inspect.record({ kind: "log", message: "first" });
43
+ inspect.record({ kind: "log", message: "second" });
44
+
45
+ const snapshot = inspect.list();
46
+ snapshot.pop();
47
+ assert.equal(inspect.list().length, 2);
48
+ assert.equal((inspect.list()[1] as DevInspectLogEvent).message, "second");
49
+ });
50
+
51
+ test("record assigns id, at, and the expected fields for each kind", () => {
52
+ const inspect = createDevInspect();
53
+ const before = Date.now();
54
+
55
+ const request = inspect.record({
56
+ kind: "request",
57
+ method: "GET",
58
+ path: "/api/planets",
59
+ status: 200,
60
+ durationMs: 12,
61
+ }) as DevInspectRequestEvent;
62
+ const email = inspect.record({
63
+ kind: "email",
64
+ to: "a@b.test",
65
+ subject: "Hello",
66
+ text: "Body",
67
+ }) as DevInspectEmailEvent;
68
+ const auth = inspect.record({
69
+ kind: "auth_code",
70
+ type: "magic",
71
+ to: "a@b.test",
72
+ code: "123456",
73
+ }) as DevInspectAuthCodeEvent;
74
+ const job = inspect.record({
75
+ kind: "job",
76
+ name: "hourly",
77
+ payload: { n: 1 },
78
+ error: "",
79
+ }) as DevInspectJobEvent;
80
+ const log = inspect.record({ kind: "log", message: "Change detected, reloading…" });
81
+
82
+ const after = Date.now();
83
+ for (const event of [request, email, auth, job, log]) {
84
+ assert.ok(event.id);
85
+ assert.ok(event.at >= before && event.at <= after);
86
+ }
87
+
88
+ assert.equal(request.kind, "request");
89
+ assert.equal(request.method, "GET");
90
+ assert.equal(request.path, "/api/planets");
91
+ assert.equal(request.status, 200);
92
+ assert.equal(request.durationMs, 12);
93
+
94
+ assert.equal(email.kind, "email");
95
+ assert.equal(email.to, "a@b.test");
96
+ assert.equal(email.subject, "Hello");
97
+ assert.equal(email.text, "Body");
98
+
99
+ assert.equal(auth.kind, "auth_code");
100
+ assert.equal(auth.type, "magic");
101
+ assert.equal(auth.to, "a@b.test");
102
+ assert.equal(auth.code, "123456");
103
+
104
+ assert.equal(job.kind, "job");
105
+ assert.equal(job.name, "hourly");
106
+ assert.deepEqual(job.payload, { n: 1 });
107
+ assert.equal(job.error, "");
108
+
109
+ assert.equal(log.kind, "log");
110
+ assert.equal((log as DevInspectLogEvent).message, "Change detected, reloading…");
111
+
112
+ assert.equal(inspect.list().length, 5);
113
+ });
114
+
115
+ test("inspect HTML has four lists and polls logs.json", () => {
116
+ 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/);
122
+ assert.match(html, /setInterval\(poll, 1000\)/);
123
+ });
@@ -0,0 +1,230 @@
1
+ export const DEV_INSPECT_MAX = 500;
2
+
3
+ const INSPECT_PATHS = new Set(["/_robodev", "/_robodev/logs", "/_robodev/logs.json"]);
4
+
5
+ export type DevInspectKind = "request" | "email" | "auth_code" | "job" | "log";
6
+
7
+ type EventBase = {
8
+ id: string;
9
+ at: number;
10
+ };
11
+
12
+ export type DevInspectRequestEvent = EventBase & {
13
+ kind: "request";
14
+ method: string;
15
+ path: string;
16
+ status: number;
17
+ durationMs: number;
18
+ };
19
+
20
+ export type DevInspectEmailEvent = EventBase & {
21
+ kind: "email";
22
+ to: string;
23
+ subject: string;
24
+ text?: string;
25
+ };
26
+
27
+ export type DevInspectAuthCodeEvent = EventBase & {
28
+ kind: "auth_code";
29
+ type: string;
30
+ to: string;
31
+ code: string;
32
+ };
33
+
34
+ export type DevInspectJobEvent = EventBase & {
35
+ kind: "job";
36
+ name: string;
37
+ payload: unknown;
38
+ error: string;
39
+ };
40
+
41
+ export type DevInspectLogEvent = EventBase & {
42
+ kind: "log";
43
+ message: string;
44
+ };
45
+
46
+ export type DevInspectEvent =
47
+ | DevInspectRequestEvent
48
+ | DevInspectEmailEvent
49
+ | DevInspectAuthCodeEvent
50
+ | DevInspectJobEvent
51
+ | DevInspectLogEvent;
52
+
53
+ export type DevInspectRecordInput =
54
+ | Omit<DevInspectRequestEvent, "id" | "at">
55
+ | Omit<DevInspectEmailEvent, "id" | "at">
56
+ | Omit<DevInspectAuthCodeEvent, "id" | "at">
57
+ | Omit<DevInspectJobEvent, "id" | "at">
58
+ | Omit<DevInspectLogEvent, "id" | "at">;
59
+
60
+ export type DevInspectStore = {
61
+ record: (input: DevInspectRecordInput) => DevInspectEvent;
62
+ list: () => DevInspectEvent[];
63
+ html: () => string;
64
+ };
65
+
66
+ /** Inspect HTML/JSON self-routes — do not print or record these as requests. */
67
+ export function isInspectPath(pathname: string): boolean {
68
+ return INSPECT_PATHS.has(pathname);
69
+ }
70
+
71
+ export function createDevInspect(options?: { max?: number }): DevInspectStore {
72
+ const max = options?.max ?? DEV_INSPECT_MAX;
73
+ const events: DevInspectEvent[] = [];
74
+ let nextId = 1;
75
+
76
+ function record(input: DevInspectRecordInput): DevInspectEvent {
77
+ const event = { ...input, id: String(nextId++), at: Date.now() } as DevInspectEvent;
78
+ events.push(event);
79
+ if (events.length > max) {
80
+ events.splice(0, events.length - max);
81
+ }
82
+ return event;
83
+ }
84
+
85
+ function list(): DevInspectEvent[] {
86
+ return events.slice();
87
+ }
88
+
89
+ return { record, list, html: inspectHtml };
90
+ }
91
+
92
+ /** Inline inspect page — same pattern as runtime `swaggerHtml` (string template, no FE build). */
93
+ export function inspectHtml(): string {
94
+ return `<!doctype html>
95
+ <html>
96
+ <head>
97
+ <meta charset="utf-8" />
98
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
99
+ <title>Robodev Inspect</title>
100
+ <style>
101
+ :root { color-scheme: light dark; }
102
+ body {
103
+ margin: 0 auto;
104
+ max-width: 960px;
105
+ padding: 24px 20px 48px;
106
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
107
+ line-height: 1.45;
108
+ }
109
+ h1 { font-size: 1.25rem; font-weight: 650; margin: 0 0 4px; }
110
+ .lead { color: #666; margin: 0 0 24px; font-size: 0.9rem; }
111
+ h2 {
112
+ font-size: 0.75rem;
113
+ font-weight: 700;
114
+ letter-spacing: 0.06em;
115
+ text-transform: uppercase;
116
+ margin: 28px 0 8px;
117
+ }
118
+ ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid #ddd; }
119
+ li {
120
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
121
+ font-size: 12.5px;
122
+ padding: 8px 0;
123
+ border-bottom: 1px solid #eee;
124
+ white-space: pre-wrap;
125
+ word-break: break-word;
126
+ }
127
+ .empty { color: #888; font-family: inherit; }
128
+ .meta { color: #888; }
129
+ @media (prefers-color-scheme: dark) {
130
+ .lead, .empty, .meta { color: #9aa0a6; }
131
+ ul { border-top-color: #333; }
132
+ li { border-bottom-color: #2a2a2a; }
133
+ }
134
+ </style>
135
+ </head>
136
+ <body>
137
+ <h1>Inspect</h1>
138
+ <p class="lead">Local <code>robodev dev</code> events. Newest first. Polls every 1s.</p>
139
+ <h2>Requests</h2>
140
+ <ul id="requests"></ul>
141
+ <h2>Emails</h2>
142
+ <ul id="emails"></ul>
143
+ <h2>Jobs</h2>
144
+ <ul id="jobs"></ul>
145
+ <h2>Logs</h2>
146
+ <ul id="logs"></ul>
147
+ <script>
148
+ function timeLabel(at) {
149
+ return new Date(at).toLocaleTimeString();
150
+ }
151
+ function emptyRow() {
152
+ const li = document.createElement("li");
153
+ li.className = "empty";
154
+ li.textContent = "None yet";
155
+ return li;
156
+ }
157
+ function line(at, text) {
158
+ const li = document.createElement("li");
159
+ const meta = document.createElement("span");
160
+ meta.className = "meta";
161
+ meta.textContent = timeLabel(at) + " ";
162
+ li.appendChild(meta);
163
+ li.appendChild(document.createTextNode(text));
164
+ return li;
165
+ }
166
+ function fill(id, rows) {
167
+ const ul = document.getElementById(id);
168
+ ul.replaceChildren();
169
+ if (rows.length === 0) {
170
+ ul.appendChild(emptyRow());
171
+ return;
172
+ }
173
+ for (const row of rows) ul.appendChild(row);
174
+ }
175
+ function payloadText(payload) {
176
+ if (payload === undefined) return "";
177
+ try {
178
+ return " " + JSON.stringify(payload);
179
+ } catch {
180
+ return " [unserializable payload]";
181
+ }
182
+ }
183
+ function render(events) {
184
+ const newest = events.slice().reverse();
185
+ fill(
186
+ "requests",
187
+ newest
188
+ .filter((e) => e.kind === "request")
189
+ .map((e) => line(e.at, e.method + " " + e.path + " " + e.status + " " + e.durationMs + "ms")),
190
+ );
191
+ fill(
192
+ "emails",
193
+ newest
194
+ .filter((e) => e.kind === "email" || e.kind === "auth_code")
195
+ .map((e) => {
196
+ if (e.kind === "auth_code") {
197
+ return line(e.at, e.type + " " + e.to + " " + e.code);
198
+ }
199
+ const extra = e.text ? "\\n" + e.text : "";
200
+ return line(e.at, e.to + " " + e.subject + extra);
201
+ }),
202
+ );
203
+ fill(
204
+ "jobs",
205
+ newest
206
+ .filter((e) => e.kind === "job")
207
+ .map((e) => {
208
+ const err = e.error ? " " + e.error : "";
209
+ return line(e.at, e.name + payloadText(e.payload) + err);
210
+ }),
211
+ );
212
+ fill(
213
+ "logs",
214
+ newest.filter((e) => e.kind === "log").map((e) => line(e.at, e.message)),
215
+ );
216
+ }
217
+ async function poll() {
218
+ try {
219
+ const events = await fetch("/_robodev/logs.json").then((r) => r.json());
220
+ render(events);
221
+ } catch {
222
+ /* keep last render */
223
+ }
224
+ }
225
+ poll();
226
+ setInterval(poll, 1000);
227
+ </script>
228
+ </body>
229
+ </html>`;
230
+ }
package/src/dev-server.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import type { JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
17
17
  import type { RuntimeJob } from "@robodev-ai/runtime";
18
18
  import { devRouteClients } from "./dev-clients.js";
19
+ import { isInspectPath, type DevInspectStore } from "./dev-inspect.js";
19
20
 
20
21
  /** One compiled generation of the project. Swapped wholesale on reload. */
21
22
  export type DevRuntime = {
@@ -33,6 +34,7 @@ export type DevServerOptions = {
33
34
  env: Record<string, string>;
34
35
  log: (line: string) => void;
35
36
  enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
37
+ inspect: DevInspectStore;
36
38
  };
37
39
 
38
40
  const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
@@ -52,9 +54,10 @@ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
52
54
  }
53
55
 
54
56
  /**
55
- * A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup,
56
- * no static SPA serving (local Vite does that), and no request logging — the routes,
57
- * validation, and OpenAPI all come from `@robodev-ai/runtime`.
57
+ * A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
58
+ * and no static SPA serving (local Vite does that). Fastify stays `logger: false`;
59
+ * project/auth/docs requests are recorded on the process-local inspect page after reply.
60
+ * Routes, validation, and OpenAPI all come from `@robodev-ai/runtime`.
58
61
  */
59
62
  export async function startDevServer(
60
63
  options: DevServerOptions,
@@ -112,92 +115,138 @@ export async function startDevServer(
112
115
  const pathname = pathnameOf(request.url);
113
116
  const method = request.method.toLowerCase();
114
117
 
115
- if (pathname === "/_robodev/ready") {
116
- const runtime = getRuntime();
117
- if (!runtime) return reply.code(503).send({ error: "not_ready" });
118
- try {
119
- await runtime.pool.query("SELECT 1");
120
- return reply.send({ ok: true });
121
- } catch {
122
- return reply.code(503).send({ error: "not_ready" });
118
+ if (isInspectPath(pathname)) {
119
+ if (pathname === "/_robodev/logs.json") {
120
+ return reply.send(options.inspect.list());
123
121
  }
122
+ return reply.type("text/html; charset=utf-8").send(options.inspect.html());
124
123
  }
125
124
 
126
- if (SWAGGER_PATHS.has(pathname)) {
127
- return reply.type("text/html; charset=utf-8").send(swaggerHtml("/openapi.json"));
128
- }
129
- if (SCALAR_PATHS.has(pathname)) {
130
- return reply.type("text/html; charset=utf-8").send(scalarHtml("/openapi.json"));
131
- }
132
-
133
- const runtime = getRuntime();
134
- if (!runtime) {
135
- return reply.code(503).send({
136
- error: "not_ready",
137
- message: "The project is still compiling. Retry in a moment.",
125
+ const started = Date.now();
126
+ try {
127
+ await dispatchDevRequest();
128
+ } finally {
129
+ const status = reply.statusCode;
130
+ const durationMs = Date.now() - started;
131
+ options.inspect.record({
132
+ kind: "request",
133
+ method: request.method,
134
+ path: pathname,
135
+ status,
136
+ durationMs,
138
137
  });
138
+ // Print here (not via `log`) so request lines stay off the Inspect Logs list.
139
+ console.log(`${request.method} ${pathname} ${status} ${durationMs}ms`);
139
140
  }
141
+ return reply;
142
+
143
+ async function dispatchDevRequest(): Promise<void> {
144
+ if (pathname === "/_robodev/ready") {
145
+ const runtime = getRuntime();
146
+ if (!runtime) {
147
+ await reply.code(503).send({ error: "not_ready" });
148
+ return;
149
+ }
150
+ try {
151
+ await runtime.pool.query("SELECT 1");
152
+ await reply.send({ ok: true });
153
+ } catch {
154
+ await reply.code(503).send({ error: "not_ready" });
155
+ }
156
+ return;
157
+ }
140
158
 
141
- if (pathname === "/openapi.json") {
142
- return reply.send(runtime.openapi);
143
- }
159
+ if (SWAGGER_PATHS.has(pathname)) {
160
+ await reply.type("text/html; charset=utf-8").send(swaggerHtml("/openapi.json"));
161
+ return;
162
+ }
163
+ if (SCALAR_PATHS.has(pathname)) {
164
+ await reply.type("text/html; charset=utf-8").send(scalarHtml("/openapi.json"));
165
+ return;
166
+ }
144
167
 
145
- if (isLocalAuthPath(pathname)) {
146
- const result = await handleLocalAuth(
147
- {
148
- method: request.method,
149
- pathname,
150
- body: request.body,
151
- query: request.query,
152
- authorization: request.headers.authorization,
153
- },
154
- {
155
- db: runtime.pool,
156
- projectId: options.projectId,
157
- jwtSecret: options.jwtSecret,
158
- deliverCode: ({ to, type, code }) => options.log(`${type} code for ${to}: ${code}`),
159
- },
160
- );
161
- return reply.code(result.status).send(result.body);
162
- }
168
+ const runtime = getRuntime();
169
+ if (!runtime) {
170
+ await reply.code(503).send({
171
+ error: "not_ready",
172
+ message: "The project is still compiling. Retry in a moment.",
173
+ });
174
+ return;
175
+ }
176
+
177
+ if (pathname === "/openapi.json") {
178
+ await reply.send(runtime.openapi);
179
+ return;
180
+ }
163
181
 
164
- if (!matchProjectRoute(pathname, method, runtime.routes)) {
165
- return reply.code(404).send({
166
- error: "not_found",
167
- message: `${request.method} ${pathname} is not defined`,
168
- routes: runtime.routes.map((route) => `${route.method.toUpperCase()} ${route.path}`),
182
+ if (isLocalAuthPath(pathname)) {
183
+ const result = await handleLocalAuth(
184
+ {
185
+ method: request.method,
186
+ pathname,
187
+ body: request.body,
188
+ query: request.query,
189
+ authorization: request.headers.authorization,
190
+ },
191
+ {
192
+ db: runtime.pool,
193
+ projectId: options.projectId,
194
+ jwtSecret: options.jwtSecret,
195
+ deliverCode: ({ to, type, code }) => {
196
+ options.inspect.record({ kind: "auth_code", type, to, code });
197
+ options.log(`${type} code for ${to}: ${code}`);
198
+ },
199
+ },
200
+ );
201
+ await reply.code(result.status).send(result.body);
202
+ return;
203
+ }
204
+
205
+ if (!matchProjectRoute(pathname, method, runtime.routes)) {
206
+ await reply.code(404).send({
207
+ error: "not_found",
208
+ message: `${request.method} ${pathname} is not defined`,
209
+ routes: runtime.routes.map((route) => `${route.method.toUpperCase()} ${route.path}`),
210
+ });
211
+ return;
212
+ }
213
+
214
+ const outcome = await runRoute({
215
+ db: runtime.db,
216
+ routes: runtime.routes,
217
+ method,
218
+ pathname,
219
+ query: request.query ?? {},
220
+ body: request.body,
221
+ rawBody: rawBodyOf(request),
222
+ headers: request.headers as Record<string, string | string[] | undefined>,
223
+ authorization: request.headers.authorization,
224
+ resolveUser: (authorization) =>
225
+ resolveLocalUser(
226
+ { projectId: options.projectId, jwtSecret: options.jwtSecret },
227
+ authorization,
228
+ ),
229
+ clients: () =>
230
+ devRouteClients({
231
+ env: options.env,
232
+ log: options.log,
233
+ enqueue: options.enqueue,
234
+ inspect: options.inspect,
235
+ }),
169
236
  });
170
- }
171
237
 
172
- const outcome = await runRoute({
173
- db: runtime.db,
174
- routes: runtime.routes,
175
- method,
176
- pathname,
177
- query: request.query ?? {},
178
- body: request.body,
179
- rawBody: rawBodyOf(request),
180
- headers: request.headers as Record<string, string | string[] | undefined>,
181
- authorization: request.headers.authorization,
182
- resolveUser: (authorization) =>
183
- resolveLocalUser(
184
- { projectId: options.projectId, jwtSecret: options.jwtSecret },
185
- authorization,
186
- ),
187
- clients: () =>
188
- devRouteClients({ env: options.env, log: options.log, enqueue: options.enqueue }),
189
- });
190
-
191
- if (outcome.kind === "error") {
192
- return reply.code(outcome.status).send(outcome.body);
193
- }
194
- for (const [name, value] of Object.entries(outcome.encoded.headers)) {
195
- reply.header(name, value);
238
+ if (outcome.kind === "error") {
239
+ await reply.code(outcome.status).send(outcome.body);
240
+ return;
241
+ }
242
+ for (const [name, value] of Object.entries(outcome.encoded.headers)) {
243
+ reply.header(name, value);
244
+ }
245
+ await reply
246
+ .code(outcome.encoded.status)
247
+ .type(outcome.encoded.contentType)
248
+ .send(outcome.encoded.payload);
196
249
  }
197
- return reply
198
- .code(outcome.encoded.status)
199
- .type(outcome.encoded.contentType)
200
- .send(outcome.encoded.payload);
201
250
  });
202
251
 
203
252
  await app.listen({ port: options.port, host: "0.0.0.0" });
package/src/dev.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from "./dev-project.js";
31
31
  import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-postgres.js";
32
32
  import { devRouteClients } from "./dev-clients.js";
33
+ import { createDevInspect } from "./dev-inspect.js";
33
34
  import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
34
35
 
35
36
  export type DevArgs = {
@@ -127,10 +128,10 @@ async function resolveCreateDb(backendDir: string, root: string): Promise<typeof
127
128
  return fallbackCreateDb;
128
129
  }
129
130
 
130
- function printPlan(plan: SchemaPlan): void {
131
+ function printPlan(plan: SchemaPlan, log: (line: string) => void): void {
131
132
  for (const op of plan.operations) {
132
133
  const mark = op.destructive ? "!" : " ";
133
- console.log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
134
+ log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
134
135
  }
135
136
  }
136
137
 
@@ -157,7 +158,11 @@ export async function main(args: string[]): Promise<void> {
157
158
  let lastSchemaSource = "";
158
159
  let closing = false;
159
160
 
160
- const log = (line: string) => console.log(line);
161
+ const inspect = createDevInspect({ max: 500 });
162
+ const log = (line: string) => {
163
+ console.log(line);
164
+ inspect.record({ kind: "log", message: line });
165
+ };
161
166
  const engine = createLocalJobsEngine({
162
167
  query: (sql, values) => {
163
168
  if (!current) throw new Error("jobs engine not ready");
@@ -165,7 +170,8 @@ export async function main(args: string[]): Promise<void> {
165
170
  },
166
171
  getGeneration: () => current,
167
172
  log,
168
- clients: () => devRouteClients({ env, log, enqueue: (input) => engine.enqueue(input) }),
173
+ clients: () =>
174
+ devRouteClients({ env, log, inspect, enqueue: (input) => engine.enqueue(input) }),
169
175
  });
170
176
 
171
177
  async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
@@ -199,12 +205,12 @@ export async function main(args: string[]): Promise<void> {
199
205
  if (input.syncSchema || schemaSource !== lastSchemaSource) {
200
206
  const plan = await buildPlan(postgres.connectionString, loaded.database);
201
207
  if (plan.destructive && !parsed.force) {
202
- console.log("Destructive schema changes (skipped, rerun with --force):");
203
- printPlan(plan);
208
+ log("Destructive schema changes (skipped, rerun with --force):");
209
+ printPlan(plan, log);
204
210
  } else {
205
211
  if (plan.operations.length > 0) {
206
- console.log("Schema changes:");
207
- printPlan(plan);
212
+ log("Schema changes:");
213
+ printPlan(plan, log);
208
214
  }
209
215
  await applyPlan(postgres.connectionString, plan);
210
216
  lastSchemaSource = schemaSource;
@@ -227,17 +233,17 @@ export async function main(args: string[]): Promise<void> {
227
233
  await engine.reconcile(loaded.jobs);
228
234
 
229
235
  for (const route of loaded.routes) {
230
- console.log(` ${route.method.toUpperCase()} ${route.path}`);
236
+ log(` ${route.method.toUpperCase()} ${route.path}`);
231
237
  }
232
238
  for (const job of loaded.jobs) {
233
239
  if (job.def.cron) {
234
- console.log(` job ${job.name} (cron ${job.def.cron})`);
240
+ log(` job ${job.name} (cron ${job.def.cron})`);
235
241
  } else {
236
- console.log(` job ${job.name}`);
242
+ log(` job ${job.name}`);
237
243
  }
238
244
  }
239
245
  if (loaded.sockets.length > 0) {
240
- console.log(
246
+ log(
241
247
  ` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
242
248
  );
243
249
  }
@@ -262,6 +268,7 @@ export async function main(args: string[]): Promise<void> {
262
268
  jwtSecret: DEV_JWT_SECRET,
263
269
  env,
264
270
  log,
271
+ inspect,
265
272
  enqueue: (input) => engine.enqueue(input),
266
273
  },
267
274
  () => current,
@@ -285,6 +292,7 @@ export async function main(args: string[]): Promise<void> {
285
292
  }
286
293
  console.log(`API ${apiUrl}`);
287
294
  console.log(`Swagger ${apiUrl}/_robodev/docs`);
295
+ console.log(`Inspect ${apiUrl}/_robodev/logs`);
288
296
  console.log(
289
297
  `Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
290
298
  );
@@ -305,7 +313,7 @@ export async function main(args: string[]): Promise<void> {
305
313
  }
306
314
  running = true;
307
315
  try {
308
- console.log("Change detected, reloading…");
316
+ log("Change detected, reloading…");
309
317
  await compileAndActivate({ syncSchema: false });
310
318
  } catch (error) {
311
319
  console.error(error instanceof Error ? error.message : error);
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.32.0",
15
+ "robodev": "^0.33.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.32.0",
14
+ "robodev": "^0.33.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.32.0",
14
+ "robodev": "^0.33.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.32.0",
15
+ "robodev": "^0.33.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.32.0",
15
+ "robodev": "^0.33.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }