robodev 0.31.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 +3 -3
- package/src/dev-clients.ts +38 -6
- package/src/dev-inspect.test.ts +123 -0
- package/src/dev-inspect.ts +230 -0
- package/src/dev-server.ts +131 -78
- package/src/dev.ts +54 -18
- package/templates/auth-chat/package.json +2 -2
- package/templates/backend/package.json +2 -2
- package/templates/empty/package.json +2 -2
- package/templates/marketplace/package.json +2 -2
- package/templates/space/package.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "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",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"embedded-postgres": "17.10.0-beta.17",
|
|
26
26
|
"fastify": "^5.12.1",
|
|
27
27
|
"tsx": "^4.20.5",
|
|
28
|
-
"@robodev-ai/runtime": "0.
|
|
29
|
-
"@robodev-ai/sdk": "0.
|
|
28
|
+
"@robodev-ai/runtime": "0.2.0",
|
|
29
|
+
"@robodev-ai/sdk": "0.11.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^24.3.0",
|
package/src/dev-clients.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { RouteClients } from "@robodev-ai/runtime";
|
|
2
|
-
import type { EmailClient, LlmClient } from "@robodev-ai/sdk";
|
|
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;
|
|
6
|
-
*
|
|
7
|
-
* than silently doing nothing.
|
|
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.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
function unavailable(feature: string): never {
|
|
@@ -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(
|
|
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
|
};
|
|
@@ -36,9 +45,11 @@ function offlineLlmClient(): LlmClient {
|
|
|
36
45
|
export function devRouteClients(input: {
|
|
37
46
|
env: Record<string, string>;
|
|
38
47
|
log: (line: string) => void;
|
|
48
|
+
enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
|
|
49
|
+
inspect: DevInspectStore;
|
|
39
50
|
}): RouteClients {
|
|
40
51
|
return {
|
|
41
|
-
email: consoleEmailClient(input.log),
|
|
52
|
+
email: consoleEmailClient(input.log, input.inspect),
|
|
42
53
|
llm: offlineLlmClient(),
|
|
43
54
|
agent: {
|
|
44
55
|
createSession: () => unavailable("agent"),
|
|
@@ -55,7 +66,28 @@ export function devRouteClients(input: {
|
|
|
55
66
|
list: () => unavailable("storage"),
|
|
56
67
|
},
|
|
57
68
|
push: { send: () => unavailable("push") },
|
|
58
|
-
jobs: {
|
|
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
|
+
},
|
|
59
91
|
sockets: {
|
|
60
92
|
send: () => unavailable("sockets"),
|
|
61
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
|
@@ -13,14 +13,17 @@ import {
|
|
|
13
13
|
type AuthQueryable,
|
|
14
14
|
type RuntimeRoute,
|
|
15
15
|
} from "@robodev-ai/runtime";
|
|
16
|
-
import type { RobodevDb } from "@robodev-ai/sdk";
|
|
16
|
+
import type { JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
|
|
17
|
+
import type { RuntimeJob } from "@robodev-ai/runtime";
|
|
17
18
|
import { devRouteClients } from "./dev-clients.js";
|
|
19
|
+
import { isInspectPath, type DevInspectStore } from "./dev-inspect.js";
|
|
18
20
|
|
|
19
21
|
/** One compiled generation of the project. Swapped wholesale on reload. */
|
|
20
22
|
export type DevRuntime = {
|
|
21
23
|
db: RobodevDb;
|
|
22
24
|
pool: AuthQueryable & { end: () => Promise<void> };
|
|
23
25
|
routes: RuntimeRoute[];
|
|
26
|
+
jobs: RuntimeJob[];
|
|
24
27
|
openapi: Record<string, unknown>;
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -30,6 +33,8 @@ export type DevServerOptions = {
|
|
|
30
33
|
jwtSecret: string;
|
|
31
34
|
env: Record<string, string>;
|
|
32
35
|
log: (line: string) => void;
|
|
36
|
+
enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
|
|
37
|
+
inspect: DevInspectStore;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
|
|
@@ -49,9 +54,10 @@ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
|
-
* A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
|
|
53
|
-
* no static SPA serving (local Vite does that)
|
|
54
|
-
*
|
|
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`.
|
|
55
61
|
*/
|
|
56
62
|
export async function startDevServer(
|
|
57
63
|
options: DevServerOptions,
|
|
@@ -109,91 +115,138 @@ export async function startDevServer(
|
|
|
109
115
|
const pathname = pathnameOf(request.url);
|
|
110
116
|
const method = request.method.toLowerCase();
|
|
111
117
|
|
|
112
|
-
if (pathname
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
await runtime.pool.query("SELECT 1");
|
|
117
|
-
return reply.send({ ok: true });
|
|
118
|
-
} catch {
|
|
119
|
-
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());
|
|
120
121
|
}
|
|
122
|
+
return reply.type("text/html; charset=utf-8").send(options.inspect.html());
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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,
|
|
135
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`);
|
|
136
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
|
+
}
|
|
137
158
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
167
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
deliverCode: ({ to, type, code }) => options.log(`${type} code for ${to}: ${code}`),
|
|
156
|
-
},
|
|
157
|
-
);
|
|
158
|
-
return reply.code(result.status).send(result.body);
|
|
159
|
-
}
|
|
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
|
+
}
|
|
160
181
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}),
|
|
166
236
|
});
|
|
167
|
-
}
|
|
168
237
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
resolveLocalUser(
|
|
181
|
-
{ projectId: options.projectId, jwtSecret: options.jwtSecret },
|
|
182
|
-
authorization,
|
|
183
|
-
),
|
|
184
|
-
clients: () => devRouteClients({ env: options.env, log: options.log }),
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
if (outcome.kind === "error") {
|
|
188
|
-
return reply.code(outcome.status).send(outcome.body);
|
|
189
|
-
}
|
|
190
|
-
for (const [name, value] of Object.entries(outcome.encoded.headers)) {
|
|
191
|
-
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);
|
|
192
249
|
}
|
|
193
|
-
return reply
|
|
194
|
-
.code(outcome.encoded.status)
|
|
195
|
-
.type(outcome.encoded.contentType)
|
|
196
|
-
.send(outcome.encoded.payload);
|
|
197
250
|
});
|
|
198
251
|
|
|
199
252
|
await app.listen({ port: options.port, host: "0.0.0.0" });
|
package/src/dev.ts
CHANGED
|
@@ -6,9 +6,12 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
6
6
|
import {
|
|
7
7
|
apiBundleExternals,
|
|
8
8
|
applyPlan,
|
|
9
|
+
assertJobCrons,
|
|
9
10
|
buildPlan,
|
|
10
11
|
compileApiTree,
|
|
12
|
+
createLocalJobsEngine,
|
|
11
13
|
ensureAuthSchema,
|
|
14
|
+
ensureJobsSchema,
|
|
12
15
|
isEsbuildFailure,
|
|
13
16
|
loadModules,
|
|
14
17
|
mapEsbuildErrors,
|
|
@@ -26,6 +29,8 @@ import {
|
|
|
26
29
|
type DevProject,
|
|
27
30
|
} from "./dev-project.js";
|
|
28
31
|
import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-postgres.js";
|
|
32
|
+
import { devRouteClients } from "./dev-clients.js";
|
|
33
|
+
import { createDevInspect } from "./dev-inspect.js";
|
|
29
34
|
import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
|
|
30
35
|
|
|
31
36
|
export type DevArgs = {
|
|
@@ -123,10 +128,10 @@ async function resolveCreateDb(backendDir: string, root: string): Promise<typeof
|
|
|
123
128
|
return fallbackCreateDb;
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
function printPlan(plan: SchemaPlan): void {
|
|
131
|
+
function printPlan(plan: SchemaPlan, log: (line: string) => void): void {
|
|
127
132
|
for (const op of plan.operations) {
|
|
128
133
|
const mark = op.destructive ? "!" : " ";
|
|
129
|
-
|
|
134
|
+
log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
|
|
130
135
|
}
|
|
131
136
|
}
|
|
132
137
|
|
|
@@ -153,7 +158,21 @@ export async function main(args: string[]): Promise<void> {
|
|
|
153
158
|
let lastSchemaSource = "";
|
|
154
159
|
let closing = false;
|
|
155
160
|
|
|
156
|
-
const
|
|
161
|
+
const inspect = createDevInspect({ max: 500 });
|
|
162
|
+
const log = (line: string) => {
|
|
163
|
+
console.log(line);
|
|
164
|
+
inspect.record({ kind: "log", message: line });
|
|
165
|
+
};
|
|
166
|
+
const engine = createLocalJobsEngine({
|
|
167
|
+
query: (sql, values) => {
|
|
168
|
+
if (!current) throw new Error("jobs engine not ready");
|
|
169
|
+
return current.pool.query(sql, values);
|
|
170
|
+
},
|
|
171
|
+
getGeneration: () => current,
|
|
172
|
+
log,
|
|
173
|
+
clients: () =>
|
|
174
|
+
devRouteClients({ env, log, inspect, enqueue: (input) => engine.enqueue(input) }),
|
|
175
|
+
});
|
|
157
176
|
|
|
158
177
|
async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
|
|
159
178
|
const files = await collectBackendFiles(project.backendDir);
|
|
@@ -179,18 +198,19 @@ export async function main(args: string[]): Promise<void> {
|
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
const loaded = await loadModules(distDir, files);
|
|
201
|
+
assertJobCrons(loaded.jobs);
|
|
182
202
|
// Any edit to database.ts can change columns, not just tables, so compare its source.
|
|
183
203
|
const schemaSource = files.find((file) => file.path === "database.ts")?.content ?? "";
|
|
184
204
|
|
|
185
205
|
if (input.syncSchema || schemaSource !== lastSchemaSource) {
|
|
186
206
|
const plan = await buildPlan(postgres.connectionString, loaded.database);
|
|
187
207
|
if (plan.destructive && !parsed.force) {
|
|
188
|
-
|
|
189
|
-
printPlan(plan);
|
|
208
|
+
log("Destructive schema changes (skipped, rerun with --force):");
|
|
209
|
+
printPlan(plan, log);
|
|
190
210
|
} else {
|
|
191
211
|
if (plan.operations.length > 0) {
|
|
192
|
-
|
|
193
|
-
printPlan(plan);
|
|
212
|
+
log("Schema changes:");
|
|
213
|
+
printPlan(plan, log);
|
|
194
214
|
}
|
|
195
215
|
await applyPlan(postgres.connectionString, plan);
|
|
196
216
|
lastSchemaSource = schemaSource;
|
|
@@ -200,31 +220,36 @@ export async function main(args: string[]): Promise<void> {
|
|
|
200
220
|
const { db, pool } = createDb(postgres.connectionString, loaded.database.tables);
|
|
201
221
|
await pool.query("SELECT 1");
|
|
202
222
|
await ensureAuthSchema(pool);
|
|
223
|
+
await ensureJobsSchema(pool);
|
|
203
224
|
|
|
204
225
|
const previous = current;
|
|
205
226
|
current = {
|
|
206
227
|
db,
|
|
207
228
|
pool,
|
|
208
229
|
routes: loaded.routes,
|
|
230
|
+
jobs: loaded.jobs,
|
|
209
231
|
openapi: buildDevOpenApi(loaded.database.name, loaded.routes),
|
|
210
232
|
};
|
|
211
|
-
|
|
212
|
-
await previous.pool.end().catch(() => undefined);
|
|
213
|
-
}
|
|
233
|
+
await engine.reconcile(loaded.jobs);
|
|
214
234
|
|
|
215
235
|
for (const route of loaded.routes) {
|
|
216
|
-
|
|
236
|
+
log(` ${route.method.toUpperCase()} ${route.path}`);
|
|
217
237
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
` ${
|
|
221
|
-
|
|
238
|
+
for (const job of loaded.jobs) {
|
|
239
|
+
if (job.def.cron) {
|
|
240
|
+
log(` job ${job.name} (cron ${job.def.cron})`);
|
|
241
|
+
} else {
|
|
242
|
+
log(` job ${job.name}`);
|
|
243
|
+
}
|
|
222
244
|
}
|
|
223
245
|
if (loaded.sockets.length > 0) {
|
|
224
|
-
|
|
246
|
+
log(
|
|
225
247
|
` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
|
|
226
248
|
);
|
|
227
249
|
}
|
|
250
|
+
if (previous) {
|
|
251
|
+
await previous.pool.end().catch(() => undefined);
|
|
252
|
+
}
|
|
228
253
|
}
|
|
229
254
|
|
|
230
255
|
async function releaseDatabase(): Promise<void> {
|
|
@@ -235,8 +260,17 @@ export async function main(args: string[]): Promise<void> {
|
|
|
235
260
|
let server: Awaited<ReturnType<typeof startDevServer>>;
|
|
236
261
|
try {
|
|
237
262
|
await compileAndActivate({ syncSchema: true });
|
|
263
|
+
await engine.start();
|
|
238
264
|
server = await startDevServer(
|
|
239
|
-
{
|
|
265
|
+
{
|
|
266
|
+
port: parsed.port,
|
|
267
|
+
projectId: DEV_PROJECT_ID,
|
|
268
|
+
jwtSecret: DEV_JWT_SECRET,
|
|
269
|
+
env,
|
|
270
|
+
log,
|
|
271
|
+
inspect,
|
|
272
|
+
enqueue: (input) => engine.enqueue(input),
|
|
273
|
+
},
|
|
240
274
|
() => current,
|
|
241
275
|
);
|
|
242
276
|
} catch (error) {
|
|
@@ -258,6 +292,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
258
292
|
}
|
|
259
293
|
console.log(`API ${apiUrl}`);
|
|
260
294
|
console.log(`Swagger ${apiUrl}/_robodev/docs`);
|
|
295
|
+
console.log(`Inspect ${apiUrl}/_robodev/logs`);
|
|
261
296
|
console.log(
|
|
262
297
|
`Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
|
|
263
298
|
);
|
|
@@ -278,7 +313,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
278
313
|
}
|
|
279
314
|
running = true;
|
|
280
315
|
try {
|
|
281
|
-
|
|
316
|
+
log("Change detected, reloading…");
|
|
282
317
|
await compileAndActivate({ syncSchema: false });
|
|
283
318
|
} catch (error) {
|
|
284
319
|
console.error(error instanceof Error ? error.message : error);
|
|
@@ -312,6 +347,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
312
347
|
async function shutdown(code: number): Promise<void> {
|
|
313
348
|
if (closing) return;
|
|
314
349
|
closing = true;
|
|
350
|
+
await engine.stop();
|
|
315
351
|
for (const watcher of watchers) watcher.close();
|
|
316
352
|
fe?.kill("SIGTERM");
|
|
317
353
|
await server.close().catch(() => undefined);
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"rules:gen": "rulesync generate"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@robodev-ai/sdk": "^0.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.33.0",
|
|
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.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.33.0",
|
|
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.
|
|
12
|
+
"@robodev-ai/sdk": "^0.11.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"robodev": "^0.
|
|
15
|
+
"robodev": "^0.33.0",
|
|
16
16
|
"rulesync": "^8.18.0",
|
|
17
17
|
"typescript": "^5.9.2"
|
|
18
18
|
}
|