@schlessera/brain-ui-server 0.32.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -1
- package/dist/app.js.map +1 -1
- package/dist/bin/brain-ui-cron.d.ts +4 -0
- package/dist/bin/brain-ui-cron.d.ts.map +1 -0
- package/dist/bin/brain-ui-cron.js +140 -0
- package/dist/bin/brain-ui-cron.js.map +1 -0
- package/dist/brain/client.d.ts +9 -0
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +81 -4
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +24 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +44 -9
- package/dist/config/env.js.map +1 -1
- package/dist/cron/digest.d.ts +19 -0
- package/dist/cron/digest.d.ts.map +1 -0
- package/dist/cron/digest.js +26 -0
- package/dist/cron/digest.js.map +1 -0
- package/dist/cron/emit.d.ts +68 -0
- package/dist/cron/emit.d.ts.map +1 -0
- package/dist/cron/emit.js +171 -0
- package/dist/cron/emit.js.map +1 -0
- package/dist/cron/run-job.d.ts +48 -0
- package/dist/cron/run-job.d.ts.map +1 -0
- package/dist/cron/run-job.js +172 -0
- package/dist/cron/run-job.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +22 -1
- package/dist/middleware/origin.js.map +1 -1
- package/dist/routes/brain.js +2 -2
- package/dist/routes/brain.js.map +1 -1
- package/package.json +6 -3
- package/src/app.ts +2 -1
- package/src/bin/brain-ui-cron.ts +178 -0
- package/src/brain/client.ts +96 -4
- package/src/config/env.ts +87 -8
- package/src/cron/digest.ts +45 -0
- package/src/cron/emit.ts +262 -0
- package/src/cron/run-job.ts +254 -0
- package/src/index.ts +5 -1
- package/src/middleware/origin.ts +21 -1
- package/src/routes/brain.ts +2 -2
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute one container-cron job while recording its status and activity.
|
|
3
|
+
* Tracking is fail-open: database or activity failures never prevent the job
|
|
4
|
+
* from running, and the wrapper always returns the child's exit code.
|
|
5
|
+
*/
|
|
6
|
+
import { unlinkSync } from "fs";
|
|
7
|
+
|
|
8
|
+
import { ingestSpanSink } from "../activity/span-sink.js";
|
|
9
|
+
import { createActivityStore } from "../activity/store.js";
|
|
10
|
+
import { createUiDb } from "../db/client.js";
|
|
11
|
+
import { recordCronRun } from "./scheduler.js";
|
|
12
|
+
|
|
13
|
+
/** Tail of stderr retained for the cron_runs error row. */
|
|
14
|
+
export const STDERR_TAIL_CHARS = 2_000;
|
|
15
|
+
/** Tail of combined stdout/stderr retained as the job_output activity event. */
|
|
16
|
+
export const OUTPUT_TAIL_CHARS = 8_000;
|
|
17
|
+
/** Heartbeat cadence, comfortably inside the activity store's stale threshold. */
|
|
18
|
+
export const HEARTBEAT_MS = 30_000;
|
|
19
|
+
/** Child-visible path where span-sink JSONL may be appended. */
|
|
20
|
+
export const SPAN_SINK_ENV = "BRAIN_ACTIVITY_SPAN_SINK";
|
|
21
|
+
|
|
22
|
+
interface TextSink {
|
|
23
|
+
write(text: string): unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SpawnedJob {
|
|
27
|
+
stdout: ReadableStream<Uint8Array>;
|
|
28
|
+
stderr: ReadableStream<Uint8Array>;
|
|
29
|
+
exited: Promise<number>;
|
|
30
|
+
signalCode: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface RunRecorder {
|
|
34
|
+
finish(error: string | undefined, output: string, exitCode: number): void;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RunJobOptions {
|
|
39
|
+
jobName: string;
|
|
40
|
+
command: string[];
|
|
41
|
+
dbPath: string;
|
|
42
|
+
childEnv: Record<string, string | undefined>;
|
|
43
|
+
stdout?: TextSink;
|
|
44
|
+
stderr?: TextSink;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RunJobDependencies {
|
|
48
|
+
spawn?: (
|
|
49
|
+
command: string[],
|
|
50
|
+
options: {
|
|
51
|
+
stdin: "inherit";
|
|
52
|
+
stdout: "pipe";
|
|
53
|
+
stderr: "pipe";
|
|
54
|
+
env: Record<string, string | undefined>;
|
|
55
|
+
}
|
|
56
|
+
) => SpawnedJob;
|
|
57
|
+
sinkPath?: string;
|
|
58
|
+
startRecord?: (
|
|
59
|
+
name: string,
|
|
60
|
+
sinkPath: string,
|
|
61
|
+
command: string[],
|
|
62
|
+
dbPath: string,
|
|
63
|
+
stderr: TextSink
|
|
64
|
+
) => Promise<RunRecorder | null>;
|
|
65
|
+
removeSink?: (path: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function messageOf(error: unknown): string {
|
|
69
|
+
return error instanceof Error ? error.message : String(error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeLine(sink: TextSink, text: string): void {
|
|
73
|
+
sink.write(`${text}\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Forward a stream verbatim while exposing decoded chunks for tail capture. */
|
|
77
|
+
export async function teeStream(
|
|
78
|
+
stream: ReadableStream<Uint8Array>,
|
|
79
|
+
sink: TextSink,
|
|
80
|
+
onText: (text: string) => void
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const decoder = new TextDecoder();
|
|
83
|
+
for await (const chunk of stream) {
|
|
84
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
85
|
+
sink.write(text);
|
|
86
|
+
onText(text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Keep the last `cap` characters, never the head. */
|
|
91
|
+
export function appendTail(current: string, text: string, cap: number): string {
|
|
92
|
+
return (current + text).slice(-cap);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function startRecord(
|
|
96
|
+
name: string,
|
|
97
|
+
sinkPath: string,
|
|
98
|
+
command: string[],
|
|
99
|
+
dbPath: string,
|
|
100
|
+
stderr: TextSink
|
|
101
|
+
): Promise<RunRecorder | null> {
|
|
102
|
+
try {
|
|
103
|
+
const db = createUiDb(dbPath);
|
|
104
|
+
const record = recordCronRun(db, name);
|
|
105
|
+
const store = createActivityStore(db, { writer: `cron:${process.pid}` });
|
|
106
|
+
const runId = `cron-${name}-${Date.now()}`;
|
|
107
|
+
const rootSpanId = `${runId}:root`;
|
|
108
|
+
store.startSpan({
|
|
109
|
+
spanId: rootSpanId,
|
|
110
|
+
runId,
|
|
111
|
+
name: `cron ${name}`,
|
|
112
|
+
kind: "cron",
|
|
113
|
+
origin: "cron",
|
|
114
|
+
jobName: name,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let sinkOffset = 0;
|
|
118
|
+
const ingest = () => {
|
|
119
|
+
try {
|
|
120
|
+
const result = ingestSpanSink(
|
|
121
|
+
store,
|
|
122
|
+
sinkPath,
|
|
123
|
+
{ runId, rootSpanId, jobName: name },
|
|
124
|
+
sinkOffset
|
|
125
|
+
);
|
|
126
|
+
sinkOffset = result.offset;
|
|
127
|
+
} catch {
|
|
128
|
+
// Sink lines are best-effort enrichment.
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const heartbeat = setInterval(() => {
|
|
133
|
+
try {
|
|
134
|
+
store.heartbeat(rootSpanId);
|
|
135
|
+
} catch {
|
|
136
|
+
// Tracking remains fail-open after startup too.
|
|
137
|
+
}
|
|
138
|
+
ingest();
|
|
139
|
+
}, HEARTBEAT_MS);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
finish(error, output, exitCode) {
|
|
143
|
+
record.finish(error);
|
|
144
|
+
try {
|
|
145
|
+
ingest();
|
|
146
|
+
if (output) {
|
|
147
|
+
try {
|
|
148
|
+
store.appendEvent(rootSpanId, "job_output", output, undefined, OUTPUT_TAIL_CHARS);
|
|
149
|
+
} catch {
|
|
150
|
+
// Output capture is enrichment; never fail a run over it.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
store.endSpan(rootSpanId, {
|
|
154
|
+
outcome: error === undefined ? "success" : "error",
|
|
155
|
+
reason: error,
|
|
156
|
+
attrs: {
|
|
157
|
+
"cron.command": command.join(" "),
|
|
158
|
+
"cron.exit_code": exitCode,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
store.cascadeClose(runId, "cancelled", "job finished");
|
|
162
|
+
store.rollupRun(runId);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
writeLine(stderr, `[cron-run] failed to close activity span: ${messageOf(error)}`);
|
|
165
|
+
} finally {
|
|
166
|
+
clearInterval(heartbeat);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
close() {
|
|
170
|
+
clearInterval(heartbeat);
|
|
171
|
+
db.close();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
writeLine(
|
|
176
|
+
stderr,
|
|
177
|
+
`[cron-run] tracking unavailable (job runs anyway): ${messageOf(error)}`
|
|
178
|
+
);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Run a job as argv (never through a shell) and return its exact exit code. */
|
|
184
|
+
export async function runJob(
|
|
185
|
+
options: RunJobOptions,
|
|
186
|
+
dependencies: RunJobDependencies = {}
|
|
187
|
+
): Promise<number> {
|
|
188
|
+
const stdout = options.stdout ?? process.stdout;
|
|
189
|
+
const stderr = options.stderr ?? process.stderr;
|
|
190
|
+
const sinkPath =
|
|
191
|
+
dependencies.sinkPath ??
|
|
192
|
+
`/tmp/brain-activity-sink-${process.pid}-${Date.now()}.jsonl`;
|
|
193
|
+
const beginRecord = dependencies.startRecord ?? startRecord;
|
|
194
|
+
const recorder = await beginRecord(
|
|
195
|
+
options.jobName,
|
|
196
|
+
sinkPath,
|
|
197
|
+
options.command,
|
|
198
|
+
options.dbPath,
|
|
199
|
+
stderr
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
let exitCode: number;
|
|
203
|
+
let errorMessage: string | undefined;
|
|
204
|
+
let outputTail = "";
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const spawn = dependencies.spawn ?? ((command, spawnOptions) => Bun.spawn(command, spawnOptions));
|
|
208
|
+
const proc = spawn(options.command, {
|
|
209
|
+
stdin: "inherit",
|
|
210
|
+
stdout: "pipe",
|
|
211
|
+
stderr: "pipe",
|
|
212
|
+
env: { ...options.childEnv, [SPAN_SINK_ENV]: sinkPath },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
let stderrTail = "";
|
|
216
|
+
await Promise.all([
|
|
217
|
+
teeStream(proc.stdout, stdout, (text) => {
|
|
218
|
+
outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
|
|
219
|
+
}),
|
|
220
|
+
teeStream(proc.stderr, stderr, (text) => {
|
|
221
|
+
stderrTail = appendTail(stderrTail, text, STDERR_TAIL_CHARS);
|
|
222
|
+
outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
|
|
223
|
+
}),
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
exitCode = await proc.exited;
|
|
227
|
+
if (exitCode !== 0) {
|
|
228
|
+
const cause = proc.signalCode ? `killed by ${proc.signalCode}` : `exit code ${exitCode}`;
|
|
229
|
+
const trimmedTail = stderrTail.trim();
|
|
230
|
+
errorMessage = trimmedTail ? `${cause}; stderr tail: ${trimmedTail}` : cause;
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
exitCode = 127;
|
|
234
|
+
errorMessage = messageOf(error);
|
|
235
|
+
writeLine(stderr, `[cron-run] failed to start job command: ${errorMessage}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (recorder) {
|
|
239
|
+
try {
|
|
240
|
+
recorder.finish(errorMessage, outputTail.trim(), exitCode);
|
|
241
|
+
recorder.close();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
writeLine(stderr, `[cron-run] failed to record run outcome: ${messageOf(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
(dependencies.removeSink ?? unlinkSync)(sinkPath);
|
|
249
|
+
} catch {
|
|
250
|
+
// Never emitted, or already gone.
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return exitCode;
|
|
254
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -76,7 +76,11 @@ export {
|
|
|
76
76
|
|
|
77
77
|
// Brain repo access (spawned CLI wrapper) — useful for embedders that add
|
|
78
78
|
// their own routes on top.
|
|
79
|
-
export {
|
|
79
|
+
export {
|
|
80
|
+
createBrainClient,
|
|
81
|
+
MIN_BRAIN_CLI_VERSION,
|
|
82
|
+
type BrainClient,
|
|
83
|
+
} from "./brain/client.js";
|
|
80
84
|
|
|
81
85
|
// Cron run history. Scheduling belongs to the deployment (container crontab);
|
|
82
86
|
// an external scheduler's wrapper records each run here so /api/status's
|
package/src/middleware/origin.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The scheme a browser would put in an `Origin` header for a connection this
|
|
5
|
+
* proxy describes.
|
|
6
|
+
*
|
|
7
|
+
* A browser's Origin is always http/https — never ws/wss, even for a
|
|
8
|
+
* WebSocket. Reverse proxies do not agree on that: several report the
|
|
9
|
+
* *connection* scheme in `X-Forwarded-Proto` and send `ws` / `wss` on an
|
|
10
|
+
* upgrade while sending `https` on ordinary requests. Comparing that verbatim
|
|
11
|
+
* builds an expected origin of `wss://host`, which no browser can ever match,
|
|
12
|
+
* so every WebSocket handshake that falls back to the Origin comparison is
|
|
13
|
+
* refused while HTTP keeps working. Browsers that send `Sec-Fetch-Site` on the
|
|
14
|
+
* handshake never reach this path, which is what made it look like a
|
|
15
|
+
* client-specific failure.
|
|
16
|
+
*/
|
|
17
|
+
function webOriginProtocol(forwarded: string): string {
|
|
18
|
+
if (forwarded === "wss") return "https:";
|
|
19
|
+
if (forwarded === "ws") return "http:";
|
|
20
|
+
return `${forwarded}:`;
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
/**
|
|
4
24
|
* Compare the browser's Origin with the request's externally visible origin.
|
|
5
25
|
*
|
|
@@ -20,7 +40,7 @@ function originMatchesRequest(
|
|
|
20
40
|
|
|
21
41
|
const forwarded = c.req.header("x-forwarded-proto")?.split(",", 1)[0]?.trim();
|
|
22
42
|
const protocol = forwarded
|
|
23
|
-
?
|
|
43
|
+
? webOriginProtocol(forwarded.toLowerCase())
|
|
24
44
|
: new URL(c.req.url).protocol;
|
|
25
45
|
|
|
26
46
|
return parsedOrigin.origin === new URL(`${protocol}//${host}`).origin;
|
package/src/routes/brain.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
|
|
|
117
117
|
cwd: brainPath,
|
|
118
118
|
stdout: "pipe",
|
|
119
119
|
stderr: "pipe",
|
|
120
|
-
env: subprocessEnv({ NO_COLOR: "1" }),
|
|
120
|
+
env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
// Start draining stderr NOW, not after the process exits.
|
|
@@ -233,7 +233,7 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
|
|
|
233
233
|
cwd: brainPath,
|
|
234
234
|
stdout: "pipe",
|
|
235
235
|
stderr: "pipe",
|
|
236
|
-
env: subprocessEnv({ NO_COLOR: "1" }),
|
|
236
|
+
env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
|
|
237
237
|
}
|
|
238
238
|
);
|
|
239
239
|
|