@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
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("brainCli", { 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
|
|
|
@@ -76,7 +166,7 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
|
76
166
|
stdout: "pipe",
|
|
77
167
|
stderr: "pipe",
|
|
78
168
|
// Force JSON output when not a TTY
|
|
79
|
-
env: subprocessEnv({ NO_COLOR: "1" }),
|
|
169
|
+
env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
|
|
80
170
|
});
|
|
81
171
|
|
|
82
172
|
const [stdout, stderr] = await Promise.all([
|
|
@@ -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
|
@@ -21,6 +21,8 @@ import { SEVERITIES, type Severity } from "../observability/types.js";
|
|
|
21
21
|
import { envFlag } from "./env-core.js";
|
|
22
22
|
import {
|
|
23
23
|
filterSubprocessEnv,
|
|
24
|
+
parseSubprocessEnvExtra,
|
|
25
|
+
type SubprocessEnvAudience,
|
|
24
26
|
WEB_SEARCH_PROVIDERS,
|
|
25
27
|
} from "@schlessera/brain-ui-sdk/server";
|
|
26
28
|
|
|
@@ -69,6 +71,16 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
69
71
|
default: "/root",
|
|
70
72
|
required: false,
|
|
71
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: "BRAIN_UI_SUBPROCESS_ENV_EXTRA",
|
|
76
|
+
description:
|
|
77
|
+
"Comma-separated environment variable names to admit to every child " +
|
|
78
|
+
"audience when an operator integration needs a variable outside the " +
|
|
79
|
+
"shipped allowlist. Names are trimmed; malformed entries are ignored; " +
|
|
80
|
+
"the control variable itself is never forwarded.",
|
|
81
|
+
default: "(empty)",
|
|
82
|
+
required: false,
|
|
83
|
+
},
|
|
72
84
|
{
|
|
73
85
|
name: "PI_CODING_AGENT_DIR",
|
|
74
86
|
description:
|
|
@@ -105,8 +117,11 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
105
117
|
},
|
|
106
118
|
{
|
|
107
119
|
name: "DB_PATH",
|
|
108
|
-
description:
|
|
109
|
-
|
|
120
|
+
description:
|
|
121
|
+
"SQLite file for the UI's own database (sessions, passkeys, settings). " +
|
|
122
|
+
"The server factory defaults to ./brain-ui.db; brain-ui-cron defaults " +
|
|
123
|
+
"to the container path /data/db/brain-ui.db.",
|
|
124
|
+
default: "./brain-ui.db (server); /data/db/brain-ui.db (brain-ui-cron)",
|
|
110
125
|
required: false,
|
|
111
126
|
},
|
|
112
127
|
{
|
|
@@ -494,6 +509,20 @@ export interface ServerConfig {
|
|
|
494
509
|
|
|
495
510
|
type EnvRecord = Record<string, string | undefined>;
|
|
496
511
|
|
|
512
|
+
/** Configuration resolved specifically for the standalone cron bin. */
|
|
513
|
+
export interface CronConfig {
|
|
514
|
+
/** Brain repository inspected by the crontab emitter. */
|
|
515
|
+
brainPath: string;
|
|
516
|
+
/** The deployment database; unlike createApp(), the bin defaults to the container path. */
|
|
517
|
+
dbPath: string;
|
|
518
|
+
/** Allowlisted brain CLI environment used only to discover module cron entries. */
|
|
519
|
+
moduleDiscoveryEnv: EnvRecord;
|
|
520
|
+
/** Allowlisted environment for the scheduled child, before the span sink is added. */
|
|
521
|
+
childEnv: EnvRecord;
|
|
522
|
+
/** Valid operator-added names, also used when emitting /etc/environment. */
|
|
523
|
+
subprocessEnvExtraNames: string[];
|
|
524
|
+
}
|
|
525
|
+
|
|
497
526
|
function list(raw: string | undefined): string[] {
|
|
498
527
|
return (raw ?? "")
|
|
499
528
|
.split(",")
|
|
@@ -663,13 +692,63 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
663
692
|
}
|
|
664
693
|
|
|
665
694
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
|
|
695
|
+
* Resolve the standalone cron bin's paths and allowlisted scheduled-job
|
|
696
|
+
* environment. The escape hatch is parsed here, at this package's sole env
|
|
697
|
+
* chokepoint, and its control variable is held back from the child.
|
|
698
|
+
*/
|
|
699
|
+
export function resolveCronConfig(
|
|
700
|
+
env: EnvRecord = process.env,
|
|
701
|
+
admittedNames: readonly string[] = []
|
|
702
|
+
): CronConfig {
|
|
703
|
+
const subprocessEnvExtraNames = parseSubprocessEnvExtra(
|
|
704
|
+
[env.BRAIN_UI_SUBPROCESS_ENV_EXTRA, ...admittedNames].join(",")
|
|
705
|
+
);
|
|
706
|
+
return {
|
|
707
|
+
brainPath: env.BRAIN_PATH || "/data/brain",
|
|
708
|
+
dbPath: env.DB_PATH || "/data/db/brain-ui.db",
|
|
709
|
+
moduleDiscoveryEnv: filterPackageSubprocessEnv(
|
|
710
|
+
env,
|
|
711
|
+
"brainCli",
|
|
712
|
+
subprocessEnvExtraNames
|
|
713
|
+
),
|
|
714
|
+
childEnv: filterPackageSubprocessEnv(
|
|
715
|
+
env,
|
|
716
|
+
"cron",
|
|
717
|
+
subprocessEnvExtraNames
|
|
718
|
+
),
|
|
719
|
+
subprocessEnvExtraNames,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Build a package-owned child environment from an environment value.
|
|
725
|
+
*/
|
|
726
|
+
function filterPackageSubprocessEnv(
|
|
727
|
+
env: EnvRecord,
|
|
728
|
+
audience: SubprocessEnvAudience,
|
|
729
|
+
extraNames: readonly string[] = [],
|
|
730
|
+
extra: Record<string, string> = {}
|
|
731
|
+
): EnvRecord {
|
|
732
|
+
const operatorNames = parseSubprocessEnvExtra(
|
|
733
|
+
env.BRAIN_UI_SUBPROCESS_ENV_EXTRA
|
|
734
|
+
);
|
|
735
|
+
return {
|
|
736
|
+
...filterSubprocessEnv(env, audience, [...operatorNames, ...extraNames]),
|
|
737
|
+
...extra,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* The allowlisted parent environment for a spawned subprocess, plus explicit
|
|
743
|
+
* overrides. The agent default preserves this exported helper's historical
|
|
744
|
+
* no-argument use; every ui-server spawn names its actual audience.
|
|
670
745
|
*/
|
|
671
|
-
export function subprocessEnv(
|
|
672
|
-
|
|
746
|
+
export function subprocessEnv(
|
|
747
|
+
audience: SubprocessEnvAudience = "agent",
|
|
748
|
+
extra: Record<string, string> = {},
|
|
749
|
+
extraNames: readonly string[] = []
|
|
750
|
+
): EnvRecord {
|
|
751
|
+
return filterPackageSubprocessEnv(process.env, audience, extraNames, extra);
|
|
673
752
|
}
|
|
674
753
|
|
|
675
754
|
/**
|
|
@@ -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,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseSubprocessEnvExtra,
|
|
3
|
+
SUBPROCESS_ENV,
|
|
4
|
+
type SubprocessEnvAudience,
|
|
5
|
+
} from "@schlessera/brain-ui-sdk/server";
|
|
6
|
+
|
|
7
|
+
/** One cron manifest entry in `brain module list --json`. */
|
|
8
|
+
export interface BrainModuleListCronEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
schedule: string;
|
|
11
|
+
command: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One enabled module in the core CLI's module-list payload. */
|
|
15
|
+
export interface BrainModuleListEnabledModule {
|
|
16
|
+
name: string;
|
|
17
|
+
key: string;
|
|
18
|
+
description: string | null;
|
|
19
|
+
types: string[];
|
|
20
|
+
commands: string[];
|
|
21
|
+
cron: BrainModuleListCronEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One disabled-but-available module in the core CLI's module-list payload. */
|
|
25
|
+
export interface BrainModuleListAvailableModule {
|
|
26
|
+
key: string;
|
|
27
|
+
description: string | null;
|
|
28
|
+
enabled: false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The payload emitted by `brain module list --json`. */
|
|
32
|
+
export interface BrainModuleListPayload {
|
|
33
|
+
enabled: BrainModuleListEnabledModule[];
|
|
34
|
+
available: BrainModuleListAvailableModule[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EmitCrontabOptions {
|
|
38
|
+
/** Null means the core CLI was unavailable or did not return JSON. */
|
|
39
|
+
modules: BrainModuleListPayload | null;
|
|
40
|
+
/** Command prefix that records and runs one job. */
|
|
41
|
+
wrapperCommand: string;
|
|
42
|
+
/** Command executed inside the wrapper's `digest` job. */
|
|
43
|
+
digestCommand: string;
|
|
44
|
+
/** Complete `PATH=...` crontab line. */
|
|
45
|
+
pathLine: string;
|
|
46
|
+
/** System crontab user field. */
|
|
47
|
+
user: string;
|
|
48
|
+
/** Whether `scripts/jobs/scrape-all.ts` exists in the brain repository. */
|
|
49
|
+
legacyScraperPresent: boolean;
|
|
50
|
+
/** Valid names carried into each wrapper so the run process re-admits them. */
|
|
51
|
+
subprocessEnvExtraNames?: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MODULE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
55
|
+
const CRON_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
56
|
+
const CRON_SCHEDULE = /^[-0-9*,/ ]{1,100}$/;
|
|
57
|
+
const CRON_COMMAND = /^[A-Za-z0-9 _.:=@,/-]{1,200}$/;
|
|
58
|
+
|
|
59
|
+
// These use ^...$ without the `m` flag deliberately. In JavaScript `$`
|
|
60
|
+
// matches only at the true end of the string, so these are the correct
|
|
61
|
+
// translation of the bash jq/Oniguruma guards, which needed \A...\z because
|
|
62
|
+
// Oniguruma's `$` can match before a trailing newline.
|
|
63
|
+
function validModuleName(value: unknown): value is string {
|
|
64
|
+
return typeof value === "string" && MODULE_NAME.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validCronEntry(value: unknown): value is BrainModuleListCronEntry {
|
|
68
|
+
if (!value || typeof value !== "object") return false;
|
|
69
|
+
const entry = value as Partial<BrainModuleListCronEntry>;
|
|
70
|
+
return (
|
|
71
|
+
typeof entry.name === "string" &&
|
|
72
|
+
CRON_NAME.test(entry.name) &&
|
|
73
|
+
typeof entry.schedule === "string" &&
|
|
74
|
+
CRON_SCHEDULE.test(entry.schedule) &&
|
|
75
|
+
typeof entry.command === "string" &&
|
|
76
|
+
CRON_COMMAND.test(entry.command)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertSingleLine(name: string, value: string): void {
|
|
81
|
+
if (/[\0\r\n]/.test(value)) {
|
|
82
|
+
throw new Error(`${name} must be a single line`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Emit `/etc/cron.d/brain-ui` with the same byte layout as the deployment
|
|
88
|
+
* shell's historical `generate_crontab` function.
|
|
89
|
+
*/
|
|
90
|
+
export function emitCrontab(options: EmitCrontabOptions): string {
|
|
91
|
+
const {
|
|
92
|
+
modules,
|
|
93
|
+
wrapperCommand,
|
|
94
|
+
digestCommand,
|
|
95
|
+
pathLine,
|
|
96
|
+
user,
|
|
97
|
+
legacyScraperPresent,
|
|
98
|
+
subprocessEnvExtraNames = [],
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
assertSingleLine("wrapperCommand", wrapperCommand);
|
|
102
|
+
assertSingleLine("digestCommand", digestCommand);
|
|
103
|
+
assertSingleLine("pathLine", pathLine);
|
|
104
|
+
assertSingleLine("user", user);
|
|
105
|
+
|
|
106
|
+
const validatedExtraNames = parseSubprocessEnvExtra(
|
|
107
|
+
subprocessEnvExtraNames.join(",")
|
|
108
|
+
);
|
|
109
|
+
const wrapper =
|
|
110
|
+
validatedExtraNames.length === 0
|
|
111
|
+
? wrapperCommand
|
|
112
|
+
: `${wrapperCommand} --subprocess-env-extra ${validatedExtraNames.join(",")}`;
|
|
113
|
+
|
|
114
|
+
const lines = [
|
|
115
|
+
"# Generated by entrypoint.sh — regenerated on each container start.",
|
|
116
|
+
pathLine,
|
|
117
|
+
"",
|
|
118
|
+
"# Base jobs",
|
|
119
|
+
`0 2 * * * ${user} cd /data/brain && ${wrapper} sync -- sh -c 'brain sync && brain index' 2>&1 | logger -t brain-sync`,
|
|
120
|
+
`0 3 * * * ${user} cd /data/brain && ${wrapper} validate -- brain validate 2>&1 | logger -t brain-validate`,
|
|
121
|
+
`0 7 * * * ${user} cd /data/brain && ${wrapper} maintain -- brain maintain 2>&1 | logger -t brain-maintain`,
|
|
122
|
+
`30 7 * * * ${user} cd /data/brain && ${wrapper} digest -- ${digestCommand} 2>&1 | logger -t brain-digest`,
|
|
123
|
+
"",
|
|
124
|
+
"# Module jobs (from enabled modules' cron manifests, when exposed)",
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const enabled = modules && Array.isArray(modules.enabled) ? modules.enabled : [];
|
|
128
|
+
if (modules === null) {
|
|
129
|
+
lines.push("# (brain module list unavailable — module cron skipped)");
|
|
130
|
+
} else {
|
|
131
|
+
for (const module of enabled) {
|
|
132
|
+
if (!module || typeof module !== "object" || !validModuleName(module.name)) continue;
|
|
133
|
+
const entries = Array.isArray(module.cron) ? module.cron : [];
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (!validCronEntry(entry)) continue;
|
|
136
|
+
const jobName = `${module.name}-${entry.name}`;
|
|
137
|
+
lines.push(
|
|
138
|
+
`${entry.schedule} ${user} cd /data/brain && ${wrapper} ${jobName} -- brain ${entry.command} 2>&1 | logger -t brain-${jobName}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const jobsModuleEnabled = enabled.some(
|
|
145
|
+
(module) =>
|
|
146
|
+
module &&
|
|
147
|
+
typeof module === "object" &&
|
|
148
|
+
Array.isArray(module.commands) &&
|
|
149
|
+
module.commands.includes("jobs")
|
|
150
|
+
);
|
|
151
|
+
// Keep the historical standalone scraper fallback for brain repositories
|
|
152
|
+
// that have not moved their jobs workflow to an enabled module.
|
|
153
|
+
if (!jobsModuleEnabled && legacyScraperPresent) {
|
|
154
|
+
lines.push(
|
|
155
|
+
`0 4 * * * ${user} cd /data/brain && ${wrapper} jobs -- bun scripts/jobs/scrape-all.ts --api-only 2>&1 | logger -t brain-jobs`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
lines.push("");
|
|
160
|
+
return `${lines.join("\n")}\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* NODE_ENV is in the descriptor because Bun uses it to choose `.env.<mode>` in
|
|
165
|
+
* every child spawned by the server. Scheduled jobs take a different path:
|
|
166
|
+
* they historically receive their environment through pam_env without
|
|
167
|
+
* NODE_ENV, and adding it would change which brain-repo env file their Bun
|
|
168
|
+
* commands load. The 0.33.1 allowlist keeps that historical behavior while
|
|
169
|
+
* widening the explicitly approved capability settings below.
|
|
170
|
+
*/
|
|
171
|
+
export const CRON_ENV_EXCLUSIONS: ReadonlySet<string> = new Set(["NODE_ENV"]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The order the shell emitted `/etc/environment` in. pam_env does not care,
|
|
175
|
+
* but the historical golden is byte-exact and this is what it is compared
|
|
176
|
+
* against.
|
|
177
|
+
*
|
|
178
|
+
* Order lives HERE, not in the SDK descriptor: the SET is owned by
|
|
179
|
+
* `SUBPROCESS_ENV`'s cron audience (see {@link CRON_ENV_NAMES}, which fails
|
|
180
|
+
* loudly if the two ever disagree), and making an unrelated file's declaration
|
|
181
|
+
* order load-bearing for a cosmetic property would be a trap for whoever next
|
|
182
|
+
* tidies it.
|
|
183
|
+
*/
|
|
184
|
+
const CRON_ENV_ORDER: readonly string[] = [
|
|
185
|
+
"PATH",
|
|
186
|
+
"BRAIN_PATH",
|
|
187
|
+
"BRAIN_ROOT",
|
|
188
|
+
"TZ",
|
|
189
|
+
"XDG_BIN_HOME",
|
|
190
|
+
"DB_PATH",
|
|
191
|
+
"BRAIN_RERANK_MODE",
|
|
192
|
+
"SCRAPE_CHROME_URL",
|
|
193
|
+
"CHROME_CDP_URL",
|
|
194
|
+
"SCRAPE_CHROME_PATH",
|
|
195
|
+
"SCRAPE_CHROME_NO_SANDBOX",
|
|
196
|
+
"SCRAPE_USER_AGENT",
|
|
197
|
+
"SCRAPE_RESPECT_ROBOTS",
|
|
198
|
+
"GEMINI_API_KEY",
|
|
199
|
+
"GEMINI_BASE_URL",
|
|
200
|
+
"OPENAI_API_KEY",
|
|
201
|
+
"OPENAI_BASE_URL",
|
|
202
|
+
"GITHUB_TOKEN",
|
|
203
|
+
"BRAIN_UI_SYNC_GITHUB_TOKEN",
|
|
204
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
205
|
+
"BRAIN_UI_PRICING_DISCOVERY",
|
|
206
|
+
"BRAIN_UI_PRICING_TTL_HOURS",
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Names emitted into `/etc/environment`: the SDK's cron audience minus the
|
|
211
|
+
* explicit exclusions, in {@link CRON_ENV_ORDER}.
|
|
212
|
+
*
|
|
213
|
+
* A variable that joins or leaves the cron audience and is not accounted for
|
|
214
|
+
* here throws at module load rather than silently changing what scheduled jobs
|
|
215
|
+
* can see.
|
|
216
|
+
*/
|
|
217
|
+
export const CRON_ENV_NAMES = Object.freeze(
|
|
218
|
+
(() => {
|
|
219
|
+
const audience = Object.entries(SUBPROCESS_ENV)
|
|
220
|
+
.filter(([, audiences]) =>
|
|
221
|
+
(audiences as readonly SubprocessEnvAudience[]).includes("cron")
|
|
222
|
+
)
|
|
223
|
+
.map(([name]) => name)
|
|
224
|
+
.filter((name) => !CRON_ENV_EXCLUSIONS.has(name));
|
|
225
|
+
const ordered = new Set(CRON_ENV_ORDER);
|
|
226
|
+
const missing = audience.filter((name) => !ordered.has(name));
|
|
227
|
+
const extra = CRON_ENV_ORDER.filter((name) => !audience.includes(name));
|
|
228
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"cron environment list drifted from SUBPROCESS_ENV's cron audience: " +
|
|
231
|
+
`unordered=[${missing.join(", ")}] unknown=[${extra.join(", ")}]`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return CRON_ENV_ORDER.slice();
|
|
235
|
+
})()
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/** Emit the pam_env input consumed by scheduled jobs. */
|
|
239
|
+
export function emitEnvironment(
|
|
240
|
+
env: Record<string, string | undefined>,
|
|
241
|
+
extraNames: readonly string[] = []
|
|
242
|
+
): string {
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
const names = [
|
|
245
|
+
...CRON_ENV_NAMES,
|
|
246
|
+
...parseSubprocessEnvExtra(extraNames.join(",")).filter(
|
|
247
|
+
(name) => !CRON_ENV_NAMES.includes(name)
|
|
248
|
+
),
|
|
249
|
+
];
|
|
250
|
+
for (const name of names) {
|
|
251
|
+
const value = env[name];
|
|
252
|
+
// Match the shell's `[ -n ]`: both unset and empty values are omitted.
|
|
253
|
+
if (!value) continue;
|
|
254
|
+
// /etc/environment has no shell-safe interpolation layer. Reject anything
|
|
255
|
+
// that could terminate/escape the quoted value or create another line.
|
|
256
|
+
if (/[\0"\\\r\n]/.test(value)) {
|
|
257
|
+
throw new Error(`cannot emit unsafe /etc/environment value for ${name}`);
|
|
258
|
+
}
|
|
259
|
+
lines.push(`${name}="${value}"`);
|
|
260
|
+
}
|
|
261
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
262
|
+
}
|