robodev 0.33.0 → 0.35.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.33.0",
3
+ "version": "0.35.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.2.0",
29
+ "@robodev-ai/runtime": "0.4.0",
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,25 +49,30 @@ 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 {
@@ -88,10 +95,7 @@ export function devRouteClients(input: {
88
95
  }
89
96
  },
90
97
  },
91
- sockets: {
92
- send: () => unavailable("sockets"),
93
- broadcast: () => unavailable("sockets"),
94
- },
98
+ sockets: createProjectSocketsClient(input.projectId),
95
99
  env: input.env,
96
100
  };
97
101
  }
package/src/dev-server.ts CHANGED
@@ -1,19 +1,35 @@
1
1
  import { Readable } from "node:stream";
2
2
  import cors from "@fastify/cors";
3
- import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
3
+ import websocket from "@fastify/websocket";
4
+ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
5
+ import type { WebSocket } from "ws";
4
6
  import {
7
+ SOCKET_CLOSE_POLICY,
8
+ SOCKET_IDLE_PING_MS,
9
+ bindSocketSession,
5
10
  buildOpenApi,
6
11
  handleLocalAuth,
7
12
  isLocalAuthPath,
13
+ isReservedStoragePath,
8
14
  matchProjectRoute,
15
+ matchSocketRoute,
16
+ parseLogicalKey,
17
+ resolveLocalObject,
9
18
  resolveLocalUser,
10
19
  runRoute,
11
20
  scalarHtml,
12
21
  swaggerHtml,
22
+ verifyStorageServeToken,
13
23
  type AuthQueryable,
14
24
  type RuntimeRoute,
25
+ type RuntimeSocket,
15
26
  } from "@robodev-ai/runtime";
16
- import type { JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
27
+ import type {
28
+ JobEnqueueInput,
29
+ RobodevDb,
30
+ SocketDefinition,
31
+ SocketHandlerContext,
32
+ } from "@robodev-ai/sdk";
17
33
  import type { RuntimeJob } from "@robodev-ai/runtime";
18
34
  import { devRouteClients } from "./dev-clients.js";
19
35
  import { isInspectPath, type DevInspectStore } from "./dev-inspect.js";
@@ -24,6 +40,7 @@ export type DevRuntime = {
24
40
  pool: AuthQueryable & { end: () => Promise<void> };
25
41
  routes: RuntimeRoute[];
26
42
  jobs: RuntimeJob[];
43
+ sockets: RuntimeSocket[];
27
44
  openapi: Record<string, unknown>;
28
45
  };
29
46
 
@@ -35,6 +52,11 @@ export type DevServerOptions = {
35
52
  log: (line: string) => void;
36
53
  enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
37
54
  inspect: DevInspectStore;
55
+ listenOrigin: string;
56
+ brokerBaseUrl: string;
57
+ googleClientId?: string;
58
+ googleClientSecret?: string;
59
+ storageDir: string;
38
60
  };
39
61
 
40
62
  const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
@@ -53,6 +75,238 @@ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
53
75
  return (request as FastifyRequest & { rawBody?: Buffer }).rawBody;
54
76
  }
55
77
 
78
+ function queryRecord(query: unknown): Record<string, string> {
79
+ if (!query || typeof query !== "object") return {};
80
+ const out: Record<string, string> = {};
81
+ for (const [key, value] of Object.entries(query as Record<string, unknown>)) {
82
+ if (typeof value === "string") out[key] = value;
83
+ else if (Array.isArray(value) && typeof value[0] === "string") out[key] = value[0];
84
+ }
85
+ return out;
86
+ }
87
+
88
+ function socketAuthorization(request: FastifyRequest): string | undefined {
89
+ const header = request.headers.authorization;
90
+ if (typeof header === "string" && header.length > 0) return header;
91
+ const token = queryRecord(request.query).accessToken;
92
+ if (token) return `Bearer ${token}`;
93
+ return undefined;
94
+ }
95
+
96
+ function codedErrorBody(error: unknown): { status: number; body: Record<string, unknown> } | null {
97
+ if (!error || typeof error !== "object" || !("statusCode" in error)) return null;
98
+ const statusCode = (error as { statusCode: unknown }).statusCode;
99
+ if (typeof statusCode !== "number") return null;
100
+ const code = (error as { code?: unknown }).code;
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ return {
103
+ status: statusCode,
104
+ body: {
105
+ error: typeof code === "string" ? code : message,
106
+ message,
107
+ },
108
+ };
109
+ }
110
+
111
+ function routeClients(options: DevServerOptions, getRuntime: () => DevRuntime | null) {
112
+ return devRouteClients({
113
+ env: options.env,
114
+ log: options.log,
115
+ enqueue: options.enqueue,
116
+ inspect: options.inspect,
117
+ query: (sql, values) => {
118
+ const runtime = getRuntime();
119
+ if (!runtime) throw new Error("dev runtime not ready");
120
+ return runtime.pool.query(sql, values);
121
+ },
122
+ objectsDir: options.storageDir,
123
+ publicBaseUrl: options.listenOrigin,
124
+ projectId: options.projectId,
125
+ jwtSecret: options.jwtSecret,
126
+ });
127
+ }
128
+
129
+ async function resolveSocketUser(
130
+ options: DevServerOptions,
131
+ def: SocketDefinition,
132
+ request: FastifyRequest,
133
+ ): Promise<{ ok: true; user: SocketHandlerContext["user"] } | { ok: false }> {
134
+ const auth = def.auth ?? false;
135
+ if (auth === false) return { ok: true, user: null };
136
+ if (typeof auth === "function") {
137
+ try {
138
+ const user = await auth({ headers: request.headers });
139
+ if (!user) return { ok: false };
140
+ return { ok: true, user };
141
+ } catch {
142
+ return { ok: false };
143
+ }
144
+ }
145
+ if (auth !== "required" && auth !== "optional") return { ok: false };
146
+ const user = await resolveLocalUser(
147
+ { projectId: options.projectId, jwtSecret: options.jwtSecret },
148
+ socketAuthorization(request),
149
+ );
150
+ if (auth === "required" && !user) return { ok: false };
151
+ return { ok: true, user };
152
+ }
153
+
154
+ async function handleLocalSocket(
155
+ socket: WebSocket,
156
+ request: FastifyRequest,
157
+ options: DevServerOptions,
158
+ getRuntime: () => DevRuntime | null,
159
+ ): Promise<void> {
160
+ const runtime = getRuntime();
161
+ if (!runtime) {
162
+ socket.close(SOCKET_CLOSE_POLICY, "not_found");
163
+ return;
164
+ }
165
+ const pathname = pathnameOf(request.url);
166
+ const matched = matchSocketRoute(pathname, runtime.sockets);
167
+ if (!matched) {
168
+ socket.close(SOCKET_CLOSE_POLICY, "not_found");
169
+ return;
170
+ }
171
+
172
+ const auth = await resolveSocketUser(options, matched.socket.def, request);
173
+ if (!auth.ok) {
174
+ socket.close(SOCKET_CLOSE_POLICY, "unauthorized");
175
+ return;
176
+ }
177
+
178
+ const clients = routeClients(options, getRuntime);
179
+ const session = await bindSocketSession({
180
+ projectId: options.projectId,
181
+ name: matched.socket.name,
182
+ socket: {
183
+ send: (data) => socket.send(data),
184
+ close: (code, reason) => socket.close(code, reason),
185
+ get readyState() {
186
+ return socket.readyState;
187
+ },
188
+ },
189
+ timeoutMs: matched.socket.def.timeoutMs,
190
+ createContext: ({ send, close, room }) => ({
191
+ db: runtime.db,
192
+ email: clients.email,
193
+ llm: clients.llm,
194
+ agent: clients.agent,
195
+ storage: clients.storage,
196
+ env: clients.env,
197
+ params: matched.params,
198
+ query: queryRecord(request.query),
199
+ headers: request.headers,
200
+ user: auth.user,
201
+ push: clients.push,
202
+ jobs: clients.jobs,
203
+ sockets: clients.sockets,
204
+ send,
205
+ close,
206
+ room,
207
+ }),
208
+ onConnect: matched.socket.def.onConnect,
209
+ onMessage: matched.socket.def.onMessage,
210
+ onClose: matched.socket.def.onClose,
211
+ });
212
+ if (!session.ok) {
213
+ if (socket.readyState === socket.OPEN) {
214
+ socket.close(session.code, session.reason);
215
+ }
216
+ return;
217
+ }
218
+
219
+ let alive = true;
220
+ const pingTimer = setInterval(() => {
221
+ if (socket.readyState !== socket.OPEN) {
222
+ clearInterval(pingTimer);
223
+ return;
224
+ }
225
+ if (!alive) {
226
+ socket.close(SOCKET_CLOSE_POLICY, "idle");
227
+ return;
228
+ }
229
+ alive = false;
230
+ try {
231
+ socket.ping();
232
+ } catch {
233
+ socket.close(SOCKET_CLOSE_POLICY, "idle");
234
+ }
235
+ }, SOCKET_IDLE_PING_MS);
236
+ pingTimer.unref?.();
237
+
238
+ socket.on("pong", () => {
239
+ alive = true;
240
+ });
241
+ socket.on("message", (data, isBinary) => {
242
+ if (isBinary) {
243
+ session.onBinary();
244
+ return;
245
+ }
246
+ const text = typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8");
247
+ session.onText(text);
248
+ });
249
+ socket.on("close", () => {
250
+ clearInterval(pingTimer);
251
+ session.onClose();
252
+ });
253
+ }
254
+
255
+ async function serveLocalStorage(
256
+ request: FastifyRequest,
257
+ reply: FastifyReply,
258
+ options: DevServerOptions,
259
+ getRuntime: () => DevRuntime | null,
260
+ pathname: string,
261
+ ): Promise<void> {
262
+ const runtime = getRuntime();
263
+ if (!runtime) {
264
+ await reply.code(503).send({ error: "not_ready" });
265
+ return;
266
+ }
267
+ const encodedKey = pathname.slice("/storage/objects/".length);
268
+ let key: string;
269
+ try {
270
+ key = decodeURIComponent(encodedKey);
271
+ parseLogicalKey(key);
272
+ } catch (error) {
273
+ const mapped = codedErrorBody(error);
274
+ if (mapped) {
275
+ await reply.code(mapped.status).send(mapped.body);
276
+ return;
277
+ }
278
+ await reply.code(400).send({ error: "invalid_key" });
279
+ return;
280
+ }
281
+
282
+ const resolved = await resolveLocalObject({
283
+ query: (sql, values) => runtime.pool.query(sql, values),
284
+ objectsDir: options.storageDir,
285
+ key,
286
+ });
287
+ if (!resolved) {
288
+ await reply.code(404).send({ error: "not_found" });
289
+ return;
290
+ }
291
+
292
+ const user = await resolveLocalUser(
293
+ { projectId: options.projectId, jwtSecret: options.jwtSecret },
294
+ request.headers.authorization,
295
+ );
296
+ const signed = verifyStorageServeToken(
297
+ options.jwtSecret,
298
+ options.projectId,
299
+ key,
300
+ request.query as { exp?: unknown; sig?: unknown },
301
+ );
302
+ if (!resolved.row.public && !user && !signed) {
303
+ await reply.code(404).send({ error: "not_found" });
304
+ return;
305
+ }
306
+
307
+ await reply.type(resolved.row.content_type).send(resolved.body);
308
+ }
309
+
56
310
  /**
57
311
  * A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
58
312
  * and no static SPA serving (local Vite does that). Fastify stays `logger: false`;
@@ -70,6 +324,10 @@ export async function startDevServer(
70
324
  credentials: true,
71
325
  methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
72
326
  });
327
+ await app.register(websocket);
328
+ app.get("/sockets/*", { websocket: true }, (socket, request) => {
329
+ void handleLocalSocket(socket, request, options, getRuntime);
330
+ });
73
331
 
74
332
  app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
75
333
  if (!body) {
@@ -99,13 +357,16 @@ export async function startDevServer(
99
357
  );
100
358
 
101
359
  // `rawBody` handlers and multipart parsing need the untouched bytes.
102
- app.addHook("preParsing", async (_request, _reply, payload) => {
360
+ app.addHook("preParsing", async (request, _reply, payload) => {
361
+ if (String(request.headers.upgrade ?? "").toLowerCase() === "websocket") {
362
+ return payload;
363
+ }
103
364
  const chunks: Buffer[] = [];
104
365
  for await (const chunk of payload) {
105
366
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
106
367
  }
107
368
  const raw = Buffer.concat(chunks);
108
- (_request as FastifyRequest & { rawBody?: Buffer }).rawBody = raw;
369
+ (request as FastifyRequest & { rawBody?: Buffer }).rawBody = raw;
109
370
  return Readable.from(raw);
110
371
  });
111
372
 
@@ -196,12 +457,25 @@ export async function startDevServer(
196
457
  options.inspect.record({ kind: "auth_code", type, to, code });
197
458
  options.log(`${type} code for ${to}: ${code}`);
198
459
  },
460
+ listenOrigin: options.listenOrigin,
461
+ brokerBaseUrl: options.brokerBaseUrl,
462
+ googleClientId: options.googleClientId,
463
+ googleClientSecret: options.googleClientSecret,
199
464
  },
200
465
  );
466
+ if (result.headers?.Location) {
467
+ await reply.redirect(result.headers.Location);
468
+ return;
469
+ }
201
470
  await reply.code(result.status).send(result.body);
202
471
  return;
203
472
  }
204
473
 
474
+ if (request.method.toUpperCase() === "GET" && isReservedStoragePath(pathname)) {
475
+ await serveLocalStorage(request, reply, options, getRuntime, pathname);
476
+ return;
477
+ }
478
+
205
479
  if (!matchProjectRoute(pathname, method, runtime.routes)) {
206
480
  await reply.code(404).send({
207
481
  error: "not_found",
@@ -226,13 +500,8 @@ export async function startDevServer(
226
500
  { projectId: options.projectId, jwtSecret: options.jwtSecret },
227
501
  authorization,
228
502
  ),
229
- clients: () =>
230
- devRouteClients({
231
- env: options.env,
232
- log: options.log,
233
- enqueue: options.enqueue,
234
- inspect: options.inspect,
235
- }),
503
+ clients: () => routeClients(options, getRuntime),
504
+ mapError: (error) => codedErrorBody(error),
236
505
  });
237
506
 
238
507
  if (outcome.kind === "error") {
package/src/dev.ts CHANGED
@@ -9,9 +9,13 @@ import {
9
9
  assertJobCrons,
10
10
  buildPlan,
11
11
  compileApiTree,
12
+ closeProjectSockets,
12
13
  createLocalJobsEngine,
13
14
  ensureAuthSchema,
14
15
  ensureJobsSchema,
16
+ ensureStorageSchema,
17
+ setProjectSocketNames,
18
+ SOCKET_CLOSE_RESTART,
15
19
  isEsbuildFailure,
16
20
  loadModules,
17
21
  mapEsbuildErrors,
@@ -29,6 +33,7 @@ import {
29
33
  type DevProject,
30
34
  } from "./dev-project.js";
31
35
  import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-postgres.js";
36
+ import { starbaseUrl } from "./config.js";
32
37
  import { devRouteClients } from "./dev-clients.js";
33
38
  import { createDevInspect } from "./dev-inspect.js";
34
39
  import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
@@ -153,6 +158,8 @@ export async function main(args: string[]): Promise<void> {
153
158
  const postgres: DevPostgres = parsed.databaseUrl
154
159
  ? externalDevPostgres(parsed.databaseUrl)
155
160
  : await startDevPostgres({ dataDir: join(devDir, "pg"), port: parsed.dbPort, quiet: true });
161
+ const storageDir = join(devDir, "storage");
162
+ await mkdir(storageDir, { recursive: true });
156
163
 
157
164
  let current: DevRuntime | null = null;
158
165
  let lastSchemaSource = "";
@@ -171,7 +178,20 @@ export async function main(args: string[]): Promise<void> {
171
178
  getGeneration: () => current,
172
179
  log,
173
180
  clients: () =>
174
- devRouteClients({ env, log, inspect, enqueue: (input) => engine.enqueue(input) }),
181
+ devRouteClients({
182
+ env,
183
+ log,
184
+ inspect,
185
+ enqueue: (input) => engine.enqueue(input),
186
+ query: (sql, values) => {
187
+ if (!current) throw new Error("dev runtime not ready");
188
+ return current.pool.query(sql, values);
189
+ },
190
+ objectsDir: storageDir,
191
+ publicBaseUrl: `http://localhost:${parsed.port}`,
192
+ projectId: DEV_PROJECT_ID,
193
+ jwtSecret: DEV_JWT_SECRET,
194
+ }),
175
195
  });
176
196
 
177
197
  async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
@@ -221,15 +241,22 @@ export async function main(args: string[]): Promise<void> {
221
241
  await pool.query("SELECT 1");
222
242
  await ensureAuthSchema(pool);
223
243
  await ensureJobsSchema(pool);
244
+ await ensureStorageSchema(pool);
224
245
 
246
+ closeProjectSockets(DEV_PROJECT_ID, SOCKET_CLOSE_RESTART, "deployed");
225
247
  const previous = current;
226
248
  current = {
227
249
  db,
228
250
  pool,
229
251
  routes: loaded.routes,
230
252
  jobs: loaded.jobs,
253
+ sockets: loaded.sockets,
231
254
  openapi: buildDevOpenApi(loaded.database.name, loaded.routes),
232
255
  };
256
+ setProjectSocketNames(
257
+ DEV_PROJECT_ID,
258
+ loaded.sockets.map((socket) => socket.name),
259
+ );
233
260
  await engine.reconcile(loaded.jobs);
234
261
 
235
262
  for (const route of loaded.routes) {
@@ -242,10 +269,8 @@ export async function main(args: string[]): Promise<void> {
242
269
  log(` job ${job.name}`);
243
270
  }
244
271
  }
245
- if (loaded.sockets.length > 0) {
246
- log(
247
- ` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
248
- );
272
+ for (const socket of loaded.sockets) {
273
+ log(` WS ${socket.path}`);
249
274
  }
250
275
  if (previous) {
251
276
  await previous.pool.end().catch(() => undefined);
@@ -270,6 +295,11 @@ export async function main(args: string[]): Promise<void> {
270
295
  log,
271
296
  inspect,
272
297
  enqueue: (input) => engine.enqueue(input),
298
+ listenOrigin: `http://localhost:${parsed.port}`,
299
+ brokerBaseUrl: starbaseUrl(),
300
+ googleClientId: env.GOOGLE_CLIENT_ID,
301
+ googleClientSecret: env.GOOGLE_CLIENT_SECRET,
302
+ storageDir,
273
303
  },
274
304
  () => current,
275
305
  );
@@ -290,6 +320,7 @@ export async function main(args: string[]): Promise<void> {
290
320
  `Postgres ${postgres.host}:${postgres.port}/${postgres.database} (--database-url)`,
291
321
  );
292
322
  }
323
+ console.log(`Storage ${relative(project.root, storageDir) || storageDir}`);
293
324
  console.log(`API ${apiUrl}`);
294
325
  console.log(`Swagger ${apiUrl}/_robodev/docs`);
295
326
  console.log(`Inspect ${apiUrl}/_robodev/logs`);
@@ -347,6 +378,7 @@ export async function main(args: string[]): Promise<void> {
347
378
  async function shutdown(code: number): Promise<void> {
348
379
  if (closing) return;
349
380
  closing = true;
381
+ closeProjectSockets(DEV_PROJECT_ID);
350
382
  await engine.stop();
351
383
  for (const watcher of watchers) watcher.close();
352
384
  fe?.kill("SIGTERM");
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.33.0",
15
+ "robodev": "^0.35.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.33.0",
14
+ "robodev": "^0.35.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.33.0",
14
+ "robodev": "^0.35.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.33.0",
15
+ "robodev": "^0.35.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.33.0",
15
+ "robodev": "^0.35.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }