@schlessera/brain-ui-server 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/README.md +58 -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 +123 -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 +80 -3
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +16 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +17 -2
- 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 +66 -0
- package/dist/cron/emit.d.ts.map +1 -0
- package/dist/cron/emit.js +152 -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/package.json +6 -3
- package/src/app.ts +2 -1
- package/src/bin/brain-ui-cron.ts +152 -0
- package/src/brain/client.ts +95 -3
- package/src/config/env.ts +29 -2
- package/src/cron/digest.ts +45 -0
- package/src/cron/emit.ts +230 -0
- package/src/cron/run-job.ts +254 -0
- package/src/index.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schlessera/brain-ui-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "brain-kit chat-UI server: Hono app factory, WebSocket turn coordinator, auth (password/passkeys/tailscale/proxy), session catalog, and brain/files/voice routes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"type": "module",
|
|
30
|
+
"bin": {
|
|
31
|
+
"brain-ui-cron": "./dist/bin/brain-ui-cron.js"
|
|
32
|
+
},
|
|
30
33
|
"exports": {
|
|
31
34
|
".": {
|
|
32
35
|
"bun": "./src/index.ts",
|
|
@@ -51,8 +54,8 @@
|
|
|
51
54
|
"dependencies": {
|
|
52
55
|
"@opentelemetry/api": "^1.9.1",
|
|
53
56
|
"@opentelemetry/api-logs": "0.221.0",
|
|
54
|
-
"@schlessera/brain-render-template": "0.
|
|
55
|
-
"@schlessera/brain-ui-sdk": "0.
|
|
57
|
+
"@schlessera/brain-render-template": "0.33.0",
|
|
58
|
+
"@schlessera/brain-ui-sdk": "0.33.0",
|
|
56
59
|
"@simplewebauthn/server": "^13.3.2",
|
|
57
60
|
"fflate": "^0.8.2",
|
|
58
61
|
"gray-matter": "^4.0.3",
|
package/src/app.ts
CHANGED
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
createBackendRegistry,
|
|
54
54
|
type BackendRegistry,
|
|
55
55
|
} from "./agent/backend.js";
|
|
56
|
-
import { createBrainClient } from "./brain/client.js";
|
|
56
|
+
import { createBrainClient, probeBrainCliVersion } from "./brain/client.js";
|
|
57
57
|
import { createCronScheduler } from "./cron/scheduler.js";
|
|
58
58
|
import { WsHost } from "./ws/host.js";
|
|
59
59
|
import { createWsUpgrade, websocket } from "./ws/connection.js";
|
|
@@ -177,6 +177,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
177
177
|
// Per-instance state: the app's own database, the brain CLI wrapper, the
|
|
178
178
|
// backend registry, and the WebSocket host. No module-level singletons —
|
|
179
179
|
// two apps with different configuration coexist in one process.
|
|
180
|
+
probeBrainCliVersion(config.brainPath, observability.logger("brain"));
|
|
180
181
|
const dbLog = observability.logger("db");
|
|
181
182
|
const db = createUiDb(config.dbPath, { log: dbLog });
|
|
182
183
|
const brain = createBrainClient({ brainPath: config.brainPath });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Thin argv dispatcher for the ui-server's container cron jobs. */
|
|
3
|
+
|
|
4
|
+
import { statSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
import { resolveCronConfig } from "../config/env.js";
|
|
8
|
+
import { runDigest } from "../cron/digest.js";
|
|
9
|
+
import {
|
|
10
|
+
emitCrontab,
|
|
11
|
+
emitEnvironment,
|
|
12
|
+
type BrainModuleListPayload,
|
|
13
|
+
} from "../cron/emit.js";
|
|
14
|
+
import { runJob } from "../cron/run-job.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The bash this replaced tested `[ -f … ]`, which is true only for a regular
|
|
18
|
+
* file (following symlinks). `existsSync` would also accept a directory, and a
|
|
19
|
+
* directory at that path would put a root cron line in the crontab that the
|
|
20
|
+
* shell has always skipped.
|
|
21
|
+
*/
|
|
22
|
+
function isRegularFile(path: string): boolean {
|
|
23
|
+
try {
|
|
24
|
+
return statSync(path).isFile();
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const USAGE = `usage: brain-ui-cron run <job-name> -- <command...>
|
|
31
|
+
brain-ui-cron digest
|
|
32
|
+
brain-ui-cron crontab [--wrapper-command <command>] [--digest-command <command>] [--path-line <line>] [--user <user>]
|
|
33
|
+
brain-ui-cron environment`;
|
|
34
|
+
|
|
35
|
+
const DEFAULT_WRAPPER_COMMAND = "bun /opt/brain-ui/server/scripts/cron-run.ts";
|
|
36
|
+
const DEFAULT_DIGEST_COMMAND = "bun /opt/brain-ui/server/scripts/brain-digest.ts";
|
|
37
|
+
const DEFAULT_PATH_LINE =
|
|
38
|
+
"PATH=/root/.local/bin:/root/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
39
|
+
const DEFAULT_USER = "root";
|
|
40
|
+
|
|
41
|
+
function usage(): never {
|
|
42
|
+
process.stderr.write(`${USAGE}\n`);
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function crontabOptions(args: string[]): {
|
|
47
|
+
wrapperCommand: string;
|
|
48
|
+
digestCommand: string;
|
|
49
|
+
pathLine: string;
|
|
50
|
+
user: string;
|
|
51
|
+
} {
|
|
52
|
+
const values = new Map<string, string>();
|
|
53
|
+
const accepted = new Set([
|
|
54
|
+
"--wrapper-command",
|
|
55
|
+
"--digest-command",
|
|
56
|
+
"--path-line",
|
|
57
|
+
"--user",
|
|
58
|
+
]);
|
|
59
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
60
|
+
const flag = args[i];
|
|
61
|
+
const value = args[i + 1];
|
|
62
|
+
if (!accepted.has(flag) || value === undefined || value === "" || value.startsWith("--")) {
|
|
63
|
+
usage();
|
|
64
|
+
}
|
|
65
|
+
values.set(flag, value);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
wrapperCommand: values.get("--wrapper-command") ?? DEFAULT_WRAPPER_COMMAND,
|
|
69
|
+
digestCommand: values.get("--digest-command") ?? DEFAULT_DIGEST_COMMAND,
|
|
70
|
+
pathLine: values.get("--path-line") ?? DEFAULT_PATH_LINE,
|
|
71
|
+
user: values.get("--user") ?? DEFAULT_USER,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readModuleList(
|
|
76
|
+
brainPath: string,
|
|
77
|
+
env: Record<string, string | undefined>
|
|
78
|
+
): Promise<BrainModuleListPayload | null> {
|
|
79
|
+
try {
|
|
80
|
+
const proc = Bun.spawn(["brain", "module", "list", "--json"], {
|
|
81
|
+
cwd: brainPath,
|
|
82
|
+
env,
|
|
83
|
+
stdout: "pipe",
|
|
84
|
+
stderr: "ignore",
|
|
85
|
+
});
|
|
86
|
+
const [exitCode, stdout] = await Promise.all([
|
|
87
|
+
proc.exited,
|
|
88
|
+
new Response(proc.stdout).text(),
|
|
89
|
+
]);
|
|
90
|
+
if (exitCode !== 0 || stdout.trim() === "") return null;
|
|
91
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
92
|
+
if (
|
|
93
|
+
!parsed ||
|
|
94
|
+
typeof parsed !== "object" ||
|
|
95
|
+
!Array.isArray((parsed as Partial<BrainModuleListPayload>).enabled) ||
|
|
96
|
+
!Array.isArray((parsed as Partial<BrainModuleListPayload>).available)
|
|
97
|
+
) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return parsed as BrainModuleListPayload;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const [subcommand, ...args] = process.argv.slice(2);
|
|
107
|
+
const config = resolveCronConfig();
|
|
108
|
+
|
|
109
|
+
if (subcommand === "run") {
|
|
110
|
+
const [jobName, separator, ...command] = args;
|
|
111
|
+
if (!jobName || separator !== "--" || command.length === 0) usage();
|
|
112
|
+
process.exit(
|
|
113
|
+
await runJob({
|
|
114
|
+
jobName,
|
|
115
|
+
command,
|
|
116
|
+
dbPath: config.dbPath,
|
|
117
|
+
childEnv: config.childEnv,
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (subcommand === "digest" && args.length === 0) {
|
|
123
|
+
process.exit(runDigest({ dbPath: config.dbPath }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (subcommand === "crontab") {
|
|
127
|
+
const options = crontabOptions(args);
|
|
128
|
+
const modules = await readModuleList(config.brainPath, config.childEnv);
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
emitCrontab({
|
|
131
|
+
...options,
|
|
132
|
+
modules,
|
|
133
|
+
legacyScraperPresent: isRegularFile(
|
|
134
|
+
join(config.brainPath, "scripts", "jobs", "scrape-all.ts")
|
|
135
|
+
),
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (subcommand === "environment" && args.length === 0) {
|
|
142
|
+
try {
|
|
143
|
+
process.stdout.write(emitEnvironment(config.childEnv));
|
|
144
|
+
process.exit(0);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
process.stderr.write(`[brain-ui-cron] ${message}\n`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
usage();
|
package/src/brain/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
3
4
|
import { subprocessEnv } from "../config/env.js";
|
|
4
5
|
import type {
|
|
5
6
|
BrainSearchResult,
|
|
@@ -48,6 +49,13 @@ interface ExecResult {
|
|
|
48
49
|
exitCode: number;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* First core release whose parser understands `--`. The ui-server inserts the
|
|
54
|
+
* separator before user input, so an older brain repo pin would lose every
|
|
55
|
+
* positional silently.
|
|
56
|
+
*/
|
|
57
|
+
export const MIN_BRAIN_CLI_VERSION = "0.33.0";
|
|
58
|
+
|
|
51
59
|
/**
|
|
52
60
|
* argv prefix for invoking the brain CLI inside `brainPath`.
|
|
53
61
|
*
|
|
@@ -67,6 +75,88 @@ export function brainCliCommand(brainPath: string): string[] {
|
|
|
67
75
|
return ["bun", "scripts/brain-cli.ts"];
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
interface ParsedVersion {
|
|
79
|
+
major: number;
|
|
80
|
+
minor: number;
|
|
81
|
+
patch: number;
|
|
82
|
+
prerelease: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseVersion(value: string): ParsedVersion | null {
|
|
86
|
+
const match = value.match(
|
|
87
|
+
// Full SemVer 2.0.0 grammar: prerelease and build metadata are
|
|
88
|
+
// dot-separated NON-EMPTY identifiers. A loose `[0-9A-Za-z.-]+` would
|
|
89
|
+
// accept "0.32.9-.." and treat garbage as a real prerelease, which the
|
|
90
|
+
// comparison below would then refuse to boot on instead of warning.
|
|
91
|
+
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
|
92
|
+
);
|
|
93
|
+
if (!match) return null;
|
|
94
|
+
return {
|
|
95
|
+
major: Number(match[1]),
|
|
96
|
+
minor: Number(match[2]),
|
|
97
|
+
patch: Number(match[3]),
|
|
98
|
+
prerelease: match[4] !== undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isBelowMinimum(found: ParsedVersion, minimum: ParsedVersion): boolean {
|
|
103
|
+
for (const key of ["major", "minor", "patch"] as const) {
|
|
104
|
+
if (found[key] !== minimum[key]) return found[key] < minimum[key];
|
|
105
|
+
}
|
|
106
|
+
return found.prerelease && !minimum.prerelease;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Probe the brain repo's own CLI pin, refusing only known-incompatible versions. */
|
|
110
|
+
export function probeBrainCliVersion(brainPath: string, log: Logger): void {
|
|
111
|
+
let result: ReturnType<typeof Bun.spawnSync>;
|
|
112
|
+
try {
|
|
113
|
+
result = Bun.spawnSync([...brainCliCommand(brainPath), "--version"], {
|
|
114
|
+
cwd: brainPath,
|
|
115
|
+
stdout: "pipe",
|
|
116
|
+
stderr: "pipe",
|
|
117
|
+
env: subprocessEnv({ NO_COLOR: "1" }),
|
|
118
|
+
timeout: 5_000,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
log.emit({
|
|
122
|
+
severityText: "WARN",
|
|
123
|
+
body: "brain CLI version probe failed; continuing",
|
|
124
|
+
attributes: {
|
|
125
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (result.exitCode !== 0) {
|
|
132
|
+
log.emit({
|
|
133
|
+
severityText: "WARN",
|
|
134
|
+
body: "brain CLI version probe failed; continuing",
|
|
135
|
+
attributes: { "exit.code": result.exitCode },
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const foundText = new TextDecoder().decode(result.stdout).trim();
|
|
141
|
+
const found = parseVersion(foundText);
|
|
142
|
+
const minimum = parseVersion(MIN_BRAIN_CLI_VERSION)!;
|
|
143
|
+
if (!found) {
|
|
144
|
+
log.emit({
|
|
145
|
+
severityText: "WARN",
|
|
146
|
+
body: "brain CLI returned an unparseable version; continuing",
|
|
147
|
+
attributes: { version: foundText },
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (isBelowMinimum(found, minimum)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`brain CLI version ${foundText} is incompatible: version ${MIN_BRAIN_CLI_VERSION} ` +
|
|
155
|
+
`or newer is required; bump the brain repo's @schlessera/brain pin before deploying.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
70
160
|
export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
71
161
|
const { brainPath } = opts;
|
|
72
162
|
|
|
@@ -108,11 +198,12 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
|
108
198
|
},
|
|
109
199
|
|
|
110
200
|
async search(query, opts) {
|
|
111
|
-
const args = ["search"
|
|
201
|
+
const args = ["search"];
|
|
112
202
|
if (opts?.type) args.push("--type", opts.type);
|
|
113
203
|
if (opts?.tag) args.push("--tag", opts.tag);
|
|
114
204
|
if (opts?.limit) args.push("--limit", String(opts.limit));
|
|
115
205
|
if (opts?.mode) args.push("--mode", opts.mode);
|
|
206
|
+
args.push("--", query);
|
|
116
207
|
|
|
117
208
|
const result = await execBrain(args);
|
|
118
209
|
const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
|
|
@@ -146,7 +237,7 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
|
146
237
|
},
|
|
147
238
|
|
|
148
239
|
async read(path) {
|
|
149
|
-
const result = await execBrain(["read", path]);
|
|
240
|
+
const result = await execBrain(["read", "--", path]);
|
|
150
241
|
if (result.exitCode !== 0) {
|
|
151
242
|
throw new Error(`brain read failed: ${result.stderr}`);
|
|
152
243
|
}
|
|
@@ -178,10 +269,11 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
|
178
269
|
},
|
|
179
270
|
|
|
180
271
|
async add(content, opts) {
|
|
181
|
-
const args = ["add"
|
|
272
|
+
const args = ["add"];
|
|
182
273
|
if (opts?.type) args.push("--type", opts.type);
|
|
183
274
|
if (opts?.title) args.push("--title", opts.title);
|
|
184
275
|
if (opts?.tags) args.push("--tags", opts.tags.join(","));
|
|
276
|
+
args.push("--", content);
|
|
185
277
|
|
|
186
278
|
const result = await execBrain(args);
|
|
187
279
|
if (result.exitCode !== 0) {
|
package/src/config/env.ts
CHANGED
|
@@ -105,8 +105,11 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
name: "DB_PATH",
|
|
108
|
-
description:
|
|
109
|
-
|
|
108
|
+
description:
|
|
109
|
+
"SQLite file for the UI's own database (sessions, passkeys, settings). " +
|
|
110
|
+
"The server factory defaults to ./brain-ui.db; brain-ui-cron defaults " +
|
|
111
|
+
"to the container path /data/db/brain-ui.db.",
|
|
112
|
+
default: "./brain-ui.db (server); /data/db/brain-ui.db (brain-ui-cron)",
|
|
110
113
|
required: false,
|
|
111
114
|
},
|
|
112
115
|
{
|
|
@@ -494,6 +497,16 @@ export interface ServerConfig {
|
|
|
494
497
|
|
|
495
498
|
type EnvRecord = Record<string, string | undefined>;
|
|
496
499
|
|
|
500
|
+
/** Configuration resolved specifically for the standalone cron bin. */
|
|
501
|
+
export interface CronConfig {
|
|
502
|
+
/** Brain repository inspected by the crontab emitter. */
|
|
503
|
+
brainPath: string;
|
|
504
|
+
/** The deployment database; unlike createApp(), the bin defaults to the container path. */
|
|
505
|
+
dbPath: string;
|
|
506
|
+
/** Exact inherited environment for the scheduled child, before the span sink is added. */
|
|
507
|
+
childEnv: EnvRecord;
|
|
508
|
+
}
|
|
509
|
+
|
|
497
510
|
function list(raw: string | undefined): string[] {
|
|
498
511
|
return (raw ?? "")
|
|
499
512
|
.split(",")
|
|
@@ -662,6 +675,20 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
662
675
|
};
|
|
663
676
|
}
|
|
664
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Resolve the standalone cron bin's environment without applying the server's
|
|
680
|
+
* subprocess secret filter. Scheduled jobs inherit the same environment they
|
|
681
|
+
* did before this behavior moved out of the deployment shell; narrowing that
|
|
682
|
+
* environment belongs to the later least-privilege release.
|
|
683
|
+
*/
|
|
684
|
+
export function resolveCronConfig(env: EnvRecord = process.env): CronConfig {
|
|
685
|
+
return {
|
|
686
|
+
brainPath: env.BRAIN_PATH || "/data/brain",
|
|
687
|
+
dbPath: env.DB_PATH || "/data/db/brain-ui.db",
|
|
688
|
+
childEnv: { ...env },
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
665
692
|
/**
|
|
666
693
|
* The filtered parent environment for spawned subprocesses (brain CLI,
|
|
667
694
|
* whatsup), plus explicit overrides. The shared descriptor strips server-only
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The standalone daily activity-digest job. Failures are deliberately loud. */
|
|
2
|
+
import { generateActivityDigest } from "../activity/digest.js";
|
|
3
|
+
import { createUiDb } from "../db/client.js";
|
|
4
|
+
|
|
5
|
+
interface TextSink {
|
|
6
|
+
write(text: string): unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DigestOptions {
|
|
10
|
+
dbPath: string;
|
|
11
|
+
stdout?: TextSink;
|
|
12
|
+
stderr?: TextSink;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DigestDependencies {
|
|
16
|
+
createDb?: typeof createUiDb;
|
|
17
|
+
generateDigest?: typeof generateActivityDigest;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Generate and persist one digest, returning a process-compatible status. */
|
|
21
|
+
export function runDigest(
|
|
22
|
+
options: DigestOptions,
|
|
23
|
+
dependencies: DigestDependencies = {}
|
|
24
|
+
): number {
|
|
25
|
+
const stdout = options.stdout ?? process.stdout;
|
|
26
|
+
const stderr = options.stderr ?? process.stderr;
|
|
27
|
+
const openDb = dependencies.createDb ?? createUiDb;
|
|
28
|
+
const generate = dependencies.generateDigest ?? generateActivityDigest;
|
|
29
|
+
let db: ReturnType<typeof createUiDb> | undefined;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
db = openDb(options.dbPath);
|
|
33
|
+
const digest = generate(db);
|
|
34
|
+
stdout.write(
|
|
35
|
+
`[brain-digest] generated: ${digest.runs} runs, ${digest.failures} failures, $${digest.costUsd.toFixed(2)}\n`
|
|
36
|
+
);
|
|
37
|
+
return 0;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
stderr.write(`[brain-digest] failed: ${message}\n`);
|
|
41
|
+
return 1;
|
|
42
|
+
} finally {
|
|
43
|
+
db?.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cron/emit.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SUBPROCESS_ENV,
|
|
3
|
+
type SubprocessEnvAudience,
|
|
4
|
+
} from "@schlessera/brain-ui-sdk/server";
|
|
5
|
+
|
|
6
|
+
/** One cron manifest entry in `brain module list --json`. */
|
|
7
|
+
export interface BrainModuleListCronEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
schedule: string;
|
|
10
|
+
command: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One enabled module in the core CLI's module-list payload. */
|
|
14
|
+
export interface BrainModuleListEnabledModule {
|
|
15
|
+
name: string;
|
|
16
|
+
key: string;
|
|
17
|
+
description: string | null;
|
|
18
|
+
types: string[];
|
|
19
|
+
commands: string[];
|
|
20
|
+
cron: BrainModuleListCronEntry[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One disabled-but-available module in the core CLI's module-list payload. */
|
|
24
|
+
export interface BrainModuleListAvailableModule {
|
|
25
|
+
key: string;
|
|
26
|
+
description: string | null;
|
|
27
|
+
enabled: false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The payload emitted by `brain module list --json`. */
|
|
31
|
+
export interface BrainModuleListPayload {
|
|
32
|
+
enabled: BrainModuleListEnabledModule[];
|
|
33
|
+
available: BrainModuleListAvailableModule[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EmitCrontabOptions {
|
|
37
|
+
/** Null means the core CLI was unavailable or did not return JSON. */
|
|
38
|
+
modules: BrainModuleListPayload | null;
|
|
39
|
+
/** Command prefix that records and runs one job. */
|
|
40
|
+
wrapperCommand: string;
|
|
41
|
+
/** Command executed inside the wrapper's `digest` job. */
|
|
42
|
+
digestCommand: string;
|
|
43
|
+
/** Complete `PATH=...` crontab line. */
|
|
44
|
+
pathLine: string;
|
|
45
|
+
/** System crontab user field. */
|
|
46
|
+
user: string;
|
|
47
|
+
/** Whether `scripts/jobs/scrape-all.ts` exists in the brain repository. */
|
|
48
|
+
legacyScraperPresent: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MODULE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
52
|
+
const CRON_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
53
|
+
const CRON_SCHEDULE = /^[-0-9*,/ ]{1,100}$/;
|
|
54
|
+
const CRON_COMMAND = /^[A-Za-z0-9 _.:=@,/-]{1,200}$/;
|
|
55
|
+
|
|
56
|
+
// These use ^...$ without the `m` flag deliberately. In JavaScript `$`
|
|
57
|
+
// matches only at the true end of the string, so these are the correct
|
|
58
|
+
// translation of the bash jq/Oniguruma guards, which needed \A...\z because
|
|
59
|
+
// Oniguruma's `$` can match before a trailing newline.
|
|
60
|
+
function validModuleName(value: unknown): value is string {
|
|
61
|
+
return typeof value === "string" && MODULE_NAME.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validCronEntry(value: unknown): value is BrainModuleListCronEntry {
|
|
65
|
+
if (!value || typeof value !== "object") return false;
|
|
66
|
+
const entry = value as Partial<BrainModuleListCronEntry>;
|
|
67
|
+
return (
|
|
68
|
+
typeof entry.name === "string" &&
|
|
69
|
+
CRON_NAME.test(entry.name) &&
|
|
70
|
+
typeof entry.schedule === "string" &&
|
|
71
|
+
CRON_SCHEDULE.test(entry.schedule) &&
|
|
72
|
+
typeof entry.command === "string" &&
|
|
73
|
+
CRON_COMMAND.test(entry.command)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertSingleLine(name: string, value: string): void {
|
|
78
|
+
if (/[\0\r\n]/.test(value)) {
|
|
79
|
+
throw new Error(`${name} must be a single line`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Emit `/etc/cron.d/brain-ui` with the same byte layout as the deployment
|
|
85
|
+
* shell's historical `generate_crontab` function.
|
|
86
|
+
*/
|
|
87
|
+
export function emitCrontab(options: EmitCrontabOptions): string {
|
|
88
|
+
const {
|
|
89
|
+
modules,
|
|
90
|
+
wrapperCommand,
|
|
91
|
+
digestCommand,
|
|
92
|
+
pathLine,
|
|
93
|
+
user,
|
|
94
|
+
legacyScraperPresent,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
assertSingleLine("wrapperCommand", wrapperCommand);
|
|
98
|
+
assertSingleLine("digestCommand", digestCommand);
|
|
99
|
+
assertSingleLine("pathLine", pathLine);
|
|
100
|
+
assertSingleLine("user", user);
|
|
101
|
+
|
|
102
|
+
const lines = [
|
|
103
|
+
"# Generated by entrypoint.sh — regenerated on each container start.",
|
|
104
|
+
pathLine,
|
|
105
|
+
"",
|
|
106
|
+
"# Base jobs",
|
|
107
|
+
`0 2 * * * ${user} cd /data/brain && ${wrapperCommand} sync -- sh -c 'brain sync && brain index' 2>&1 | logger -t brain-sync`,
|
|
108
|
+
`0 3 * * * ${user} cd /data/brain && ${wrapperCommand} validate -- brain validate 2>&1 | logger -t brain-validate`,
|
|
109
|
+
`0 7 * * * ${user} cd /data/brain && ${wrapperCommand} maintain -- brain maintain 2>&1 | logger -t brain-maintain`,
|
|
110
|
+
`30 7 * * * ${user} cd /data/brain && ${wrapperCommand} digest -- ${digestCommand} 2>&1 | logger -t brain-digest`,
|
|
111
|
+
"",
|
|
112
|
+
"# Module jobs (from enabled modules' cron manifests, when exposed)",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const enabled = modules && Array.isArray(modules.enabled) ? modules.enabled : [];
|
|
116
|
+
if (modules === null) {
|
|
117
|
+
lines.push("# (brain module list unavailable — module cron skipped)");
|
|
118
|
+
} else {
|
|
119
|
+
for (const module of enabled) {
|
|
120
|
+
if (!module || typeof module !== "object" || !validModuleName(module.name)) continue;
|
|
121
|
+
const entries = Array.isArray(module.cron) ? module.cron : [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!validCronEntry(entry)) continue;
|
|
124
|
+
const jobName = `${module.name}-${entry.name}`;
|
|
125
|
+
lines.push(
|
|
126
|
+
`${entry.schedule} ${user} cd /data/brain && ${wrapperCommand} ${jobName} -- brain ${entry.command} 2>&1 | logger -t brain-${jobName}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const jobsModuleEnabled = enabled.some(
|
|
133
|
+
(module) =>
|
|
134
|
+
module &&
|
|
135
|
+
typeof module === "object" &&
|
|
136
|
+
Array.isArray(module.commands) &&
|
|
137
|
+
module.commands.includes("jobs")
|
|
138
|
+
);
|
|
139
|
+
// Keep the historical standalone scraper fallback for brain repositories
|
|
140
|
+
// that have not moved their jobs workflow to an enabled module.
|
|
141
|
+
if (!jobsModuleEnabled && legacyScraperPresent) {
|
|
142
|
+
lines.push(
|
|
143
|
+
`0 4 * * * ${user} cd /data/brain && ${wrapperCommand} jobs -- bun scripts/jobs/scrape-all.ts --api-only 2>&1 | logger -t brain-jobs`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push("");
|
|
148
|
+
return `${lines.join("\n")}\n`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* NODE_ENV is in the descriptor because Bun uses it to choose `.env.<mode>` in
|
|
153
|
+
* every child spawned by the server. Scheduled jobs take a different path:
|
|
154
|
+
* they historically receive their environment through pam_env without
|
|
155
|
+
* NODE_ENV, and adding it would change which brain-repo env file their Bun
|
|
156
|
+
* commands load. Revisit this explicit subtraction in the 0.33.1
|
|
157
|
+
* subprocess-environment allowlist unit.
|
|
158
|
+
*/
|
|
159
|
+
export const CRON_ENV_EXCLUSIONS: ReadonlySet<string> = new Set(["NODE_ENV"]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The order the shell emitted `/etc/environment` in. pam_env does not care,
|
|
163
|
+
* but the historical golden is byte-exact and this is what it is compared
|
|
164
|
+
* against.
|
|
165
|
+
*
|
|
166
|
+
* Order lives HERE, not in the SDK descriptor: the SET is owned by
|
|
167
|
+
* `SUBPROCESS_ENV`'s cron audience (see {@link CRON_ENV_NAMES}, which fails
|
|
168
|
+
* loudly if the two ever disagree), and making an unrelated file's declaration
|
|
169
|
+
* order load-bearing for a cosmetic property would be a trap for whoever next
|
|
170
|
+
* tidies it.
|
|
171
|
+
*/
|
|
172
|
+
const CRON_ENV_ORDER: readonly string[] = [
|
|
173
|
+
"PATH",
|
|
174
|
+
"BRAIN_PATH",
|
|
175
|
+
"TZ",
|
|
176
|
+
"DB_PATH",
|
|
177
|
+
"GEMINI_API_KEY",
|
|
178
|
+
"OPENAI_API_KEY",
|
|
179
|
+
"GITHUB_TOKEN",
|
|
180
|
+
"BRAIN_UI_SYNC_GITHUB_TOKEN",
|
|
181
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
182
|
+
"BRAIN_UI_PRICING_DISCOVERY",
|
|
183
|
+
"BRAIN_UI_PRICING_TTL_HOURS",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Names emitted into `/etc/environment`: the SDK's cron audience minus the
|
|
188
|
+
* explicit exclusions, in {@link CRON_ENV_ORDER}.
|
|
189
|
+
*
|
|
190
|
+
* A variable that joins or leaves the cron audience and is not accounted for
|
|
191
|
+
* here throws at module load rather than silently changing what scheduled jobs
|
|
192
|
+
* can see.
|
|
193
|
+
*/
|
|
194
|
+
export const CRON_ENV_NAMES = Object.freeze(
|
|
195
|
+
(() => {
|
|
196
|
+
const audience = Object.entries(SUBPROCESS_ENV)
|
|
197
|
+
.filter(([, audiences]) =>
|
|
198
|
+
(audiences as readonly SubprocessEnvAudience[]).includes("cron")
|
|
199
|
+
)
|
|
200
|
+
.map(([name]) => name)
|
|
201
|
+
.filter((name) => !CRON_ENV_EXCLUSIONS.has(name));
|
|
202
|
+
const ordered = new Set(CRON_ENV_ORDER);
|
|
203
|
+
const missing = audience.filter((name) => !ordered.has(name));
|
|
204
|
+
const extra = CRON_ENV_ORDER.filter((name) => !audience.includes(name));
|
|
205
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"cron environment list drifted from SUBPROCESS_ENV's cron audience: " +
|
|
208
|
+
`unordered=[${missing.join(", ")}] unknown=[${extra.join(", ")}]`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return CRON_ENV_ORDER.slice();
|
|
212
|
+
})()
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
/** Emit the pam_env input consumed by scheduled jobs. */
|
|
216
|
+
export function emitEnvironment(env: Record<string, string | undefined>): string {
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
for (const name of CRON_ENV_NAMES) {
|
|
219
|
+
const value = env[name];
|
|
220
|
+
// Match the shell's `[ -n ]`: both unset and empty values are omitted.
|
|
221
|
+
if (!value) continue;
|
|
222
|
+
// /etc/environment has no shell-safe interpolation layer. Reject anything
|
|
223
|
+
// that could terminate/escape the quoted value or create another line.
|
|
224
|
+
if (/[\0"\\\r\n]/.test(value)) {
|
|
225
|
+
throw new Error(`cannot emit unsafe /etc/environment value for ${name}`);
|
|
226
|
+
}
|
|
227
|
+
lines.push(`${name}="${value}"`);
|
|
228
|
+
}
|
|
229
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
230
|
+
}
|