@capybara-hq/kernel 0.1.0-rc.0 → 0.1.0-rc.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/dist/log.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { pino } from "pino";
2
+ import { prettyFactory } from "pino-pretty";
1
3
  import { secretScalars } from "./config/fields.js";
4
+ import { logContext } from "./log-context.js";
5
+ import { createLokiStream } from "./log-loki.js";
2
6
  /** 日志级别,按严重程度递增。 */
3
7
  export const LOG_LEVELS = ["debug", "info", "warn", "error"];
4
8
  const LEVELS = LOG_LEVELS;
5
9
  const REDACTED = "[REDACTED]";
6
- const RESERVED = new Set(["ts", "level", "msg"]);
10
+ const RESERVED = new Set(["time", "level", "msg"]);
7
11
  /** 部署方密钥的最短登记长度:过短的值打码会误伤普通文本。 */
8
12
  const MIN_SECRET_LEN = 4;
9
13
  /** 组织来源密钥的最短登记长度。 */
@@ -101,16 +105,64 @@ function expandSecret(value) {
101
105
  }
102
106
  return out;
103
107
  }
108
+ /** Console format: `pretty` (pino-pretty, one line per record), `json`, or `auto` (pretty on a TTY). */
109
+ export const LOG_FORMATS = ["auto", "pretty", "json"];
110
+ /** Fields shown in the pretty prefix instead of the trailing object, in this order. */
111
+ const PREFIX_FIELDS = ["reqId", "traceId", "jobId", "runId", "plugin"];
112
+ /** pino-pretty's message format: `[role] [reqId=…] [plugin=…] msg`, each part only when present. */
113
+ function prettyMessage(log, messageKey) {
114
+ const parts = [];
115
+ if (typeof log.role === "string")
116
+ parts.push(`[${log.role}]`);
117
+ for (const k of PREFIX_FIELDS) {
118
+ const v = log[k];
119
+ if (typeof v === "string" || typeof v === "number")
120
+ parts.push(`[${k}=${v}]`);
121
+ }
122
+ parts.push(String(log[messageKey] ?? ""));
123
+ return parts.join(" ");
124
+ }
125
+ function consoleStream(opts) {
126
+ if (opts.write) {
127
+ const write = opts.write;
128
+ // pino hands each record as one string ending in "\n"
129
+ return { write: (s) => write(s.endsWith("\n") ? s.slice(0, -1) : s) };
130
+ }
131
+ const format = opts.format ?? "auto";
132
+ if (!LOG_FORMATS.includes(format)) {
133
+ throw new Error(`invalid log.format "${format}" (expected ${LOG_FORMATS.join(", ")})`);
134
+ }
135
+ const out = opts.out ?? process.stdout;
136
+ const isTTY = opts.isTTY ?? process.stdout.isTTY === true;
137
+ const pretty = format === "pretty" || (format === "auto" && isTTY);
138
+ if (!pretty)
139
+ return { write: (s) => out.write(s) };
140
+ // In process (no worker thread): pino-pretty formats each line synchronously.
141
+ const format_ = prettyFactory({
142
+ colorize: isTTY,
143
+ colorizeObjects: isTTY,
144
+ singleLine: true,
145
+ // errors stay inside the one-line object instead of a multi-line stack block
146
+ errorLikeObjectKeys: [],
147
+ translateTime: "SYS:HH:MM:ss.l",
148
+ ignore: ["pid", "hostname", "role", "spanId", ...PREFIX_FIELDS].join(","),
149
+ messageFormat: (log, messageKey) => prettyMessage(log, messageKey),
150
+ });
151
+ return { write: (s) => out.write(format_(s)) };
152
+ }
104
153
  /**
105
- * 创建结构化 JSON logger:每条日志写一行 `{ ts, level, msg, ...fields }`。
106
- * 字段不能覆盖 ts / level / msg;Error 序列化为 name / message / stack;
107
- * 经 `addSecrets` 登记的部署方密钥在消息与字段的任何字符串里都被打码;经 `addOrgSecrets` 登记的组织来源密钥
108
- * 只在字段里打码,不动顶层 msg;顶层 ts / level 从不打码。
109
- * `opts.orgSecretCapacity` 为组织来源密钥集合的容量(测试用);`opts.onMatcherBuild` 在每次重建匹配器时调用(测试用)。
154
+ * Creates the kernel logger on pino. Each record is `{ time, level, msg, ...base, ...fields }` with
155
+ * `level` the label and `time` ISO 8601; fields cannot overwrite time / level / msg or the base
156
+ * fields; an Error becomes name / message / stack (and cause). Redaction happens before pino, so
157
+ * every output sees the same redacted record: deployment secrets (`addSecrets`) in msg and every
158
+ * field string, org secrets (`addOrgSecrets`) in fields only; level and time are never redacted.
159
+ * The console gets pino-pretty or JSON lines per `opts.format`; `opts.write` replaces the console
160
+ * with a JSON line sink.
110
161
  */
111
162
  export function createLogger(level = "info", opts = {}) {
112
163
  let current = parseLevel(level);
113
- const write = opts.write ?? ((line) => process.stdout.write(`${line}\n`));
164
+ const base = opts.base ?? {};
165
+ const reserved = new Set([...RESERVED, ...Object.keys(base)]);
114
166
  const orgCapacity = opts.orgSecretCapacity ?? ORG_SECRET_CAPACITY;
115
167
  /** 部署方密钥(数量由配置决定,不淘汰)。 */
116
168
  const secrets = new Set();
@@ -136,12 +188,10 @@ export function createLogger(level = "info", opts = {}) {
136
188
  return deployMatcher(s);
137
189
  };
138
190
  const isSecret = (v) => secrets.has(v) || orgSecrets.has(v);
191
+ /** Redacts and serializes the fields to JSON (errors expanded, cycles cut). */
139
192
  const serialize = (entry) => {
140
193
  const seen = new WeakSet();
141
- return JSON.stringify(entry, function (key, value) {
142
- if (this === entry && RESERVED.has(key)) {
143
- return key === "msg" && typeof value === "string" ? redactMsg(value) : value;
144
- }
194
+ return JSON.stringify(entry, (_key, value) => {
145
195
  if (typeof value === "string")
146
196
  return redact(value);
147
197
  if ((typeof value === "number" || typeof value === "bigint") && isSecret(String(value))) {
@@ -167,21 +217,42 @@ export function createLogger(level = "info", opts = {}) {
167
217
  return value;
168
218
  });
169
219
  };
220
+ const loki = opts.loki ? createLokiStream(opts.loki) : undefined;
221
+ const sink = pino({
222
+ level: "debug",
223
+ base,
224
+ messageKey: "msg",
225
+ timestamp: pino.stdTimeFunctions.isoTime,
226
+ formatters: { level: (label) => ({ level: label }) },
227
+ // request / job / trace fields of the current async context (log-context.ts)
228
+ mixin: () => ({ ...logContext() }),
229
+ // fields arrive already serialized: no pino serializers, no depth or size limits
230
+ serializers: { err: (e) => e },
231
+ depthLimit: Number.MAX_SAFE_INTEGER,
232
+ edgeLimit: Number.MAX_SAFE_INTEGER,
233
+ },
234
+ // level filtering is ours (setLevel); every stream takes everything that got this far
235
+ loki
236
+ ? pino.multistream([
237
+ { level: "debug", stream: consoleStream(opts) },
238
+ { level: "debug", stream: loki },
239
+ ])
240
+ : consoleStream(opts));
170
241
  const emit = (lvl, msg, fields) => {
171
242
  if (LEVELS.indexOf(lvl) < LEVELS.indexOf(current))
172
243
  return;
173
- const entry = { ts: new Date().toISOString(), level: lvl, msg };
244
+ const entry = {};
174
245
  for (const [k, v] of Object.entries(fields ?? {}))
175
- if (!RESERVED.has(k))
246
+ if (!reserved.has(k))
176
247
  entry[k] = v;
177
- let line;
248
+ let obj;
178
249
  try {
179
- line = serialize(entry);
250
+ obj = JSON.parse(serialize(entry));
180
251
  }
181
252
  catch {
182
- line = serialize({ ts: entry.ts, level: lvl, msg, logError: "unserializable fields" });
253
+ obj = { logError: "unserializable fields" };
183
254
  }
184
- write(line);
255
+ sink[lvl](obj, redactMsg(msg));
185
256
  };
186
257
  return {
187
258
  debug: (msg, fields) => emit("debug", msg, fields),
@@ -192,6 +263,9 @@ export function createLogger(level = "info", opts = {}) {
192
263
  return current;
193
264
  },
194
265
  redact: (text) => redact(text),
266
+ flush: async () => {
267
+ await loki?.flush();
268
+ },
195
269
  setLevel(l) {
196
270
  current = parseLevel(l);
197
271
  },
@@ -6,8 +6,9 @@ import { BlockList, isIP } from "node:net";
6
6
  * checked addresses only (see `safe-fetch.ts`), so a DNS answer that changes between check and
7
7
  * connect (DNS rebinding, e.g. `10.0.0.1.nip.io`) cannot reach a private address.
8
8
  *
9
- * The blocked ranges are those of `plugins/sandbox-docker/egress-proxy/proxy.mjs`, ported here
10
- * because the kernel must not import from a plugin. IPv6 addresses that embed an IPv4 address
9
+ * The blocked ranges are those of the sandbox-docker plugin's egress proxy
10
+ * (`plugins/sandbox-docker/egress-proxy/proxy.mjs` in capybara-hq/plugins), ported here because the
11
+ * kernel must not import from a plugin. IPv6 addresses that embed an IPv4 address
11
12
  * (v4-mapped `::ffff:0:0/96`, v4-compatible `::/96`, NAT64 `64:ff9b::/96`, 6to4 `2002::/16`) are
12
13
  * judged by the embedded IPv4 address; anything unparseable is refused. One addition over proxy.mjs:
13
14
  * SIIT IPv4-translated addresses (`::ffff:0:a.b.c.d`, `::ffff:0:0:0/96`, RFC 2765) are also judged by
@@ -1,5 +1,6 @@
1
1
  import { Queue, Worker } from "bullmq";
2
2
  import { sql } from "kysely";
3
+ import { withJobContext } from "../log-context.js";
3
4
  import { bullConnection } from "./run-queue.js";
4
5
  /**
5
6
  * 内核维护队列(不含 `:`):等待超时任务(`wait-timeout`,见 waits.ts)与周期性维护(输出存档清理、
@@ -104,7 +105,7 @@ export class Maintenance {
104
105
  if (this.worker || this.closed)
105
106
  return;
106
107
  this.workerConn = bullConnection(this.d.redisUrl, this.d.log);
107
- this.worker = new Worker(MAINTENANCE_QUEUE_NAME, (job, token) => this.process(job, token), {
108
+ this.worker = new Worker(MAINTENANCE_QUEUE_NAME, (job, token) => withJobContext(MAINTENANCE_QUEUE_NAME, job, () => this.process(job, token)), {
108
109
  connection: this.workerConn,
109
110
  concurrency: 5,
110
111
  });
@@ -4,6 +4,7 @@ import { sql } from "kysely";
4
4
  import { normalizeStoredDefinition } from "../agents/definition.js";
5
5
  import { effectiveDefinition, NO_SELECTION, readSelection, sameModel, sameSelection, } from "../conversations/selection.js";
6
6
  import { ApiError } from "../errors.js";
7
+ import { withJobContext } from "../log-context.js";
7
8
  import { pinnedFailureText, recordSkillsCatalog } from "../skills/catalog.js";
8
9
  import { RunError } from "./model-call.js";
9
10
  import { ProviderListError } from "./providers.js";
@@ -60,7 +61,7 @@ export class RunWorker {
60
61
  const runtime = this.d.config().runtime;
61
62
  this.conn = bullConnection(this.d.redisUrl, this.d.log);
62
63
  for (const queue of [RUN_QUEUE_NAME, SUBAGENT_RUN_QUEUE_NAME]) {
63
- const worker = new Worker(queue, (job, token) => this.process(job, token), {
64
+ const worker = new Worker(queue, (job, token) => withJobContext(queue, job, () => this.process(job, token)), {
64
65
  // the worker name is recorded on the job (BullMQ processedBy): which replica ran it
65
66
  name: `${hostname()}-${process.pid}`,
66
67
  connection: this.conn,
@@ -2,6 +2,7 @@ import { DelayedError, Job, Queue, UnrecoverableError, Worker } from "bullmq";
2
2
  import { sql } from "kysely";
3
3
  import { enabledExtensionsQuery } from "../extensions/enabled.js";
4
4
  import { deepFreeze, errMsg } from "../extensions/hooks.js";
5
+ import { withJobContext } from "../log-context.js";
5
6
  import { bullConnection } from "./run-queue.js";
6
7
  /** Queue of `usage.report` deliveries (no `:` in the name). */
7
8
  export const USAGE_REPORT_QUEUE = "cap-kernel-usage-report";
@@ -256,7 +257,7 @@ export class UsageReporter {
256
257
  // replica that never enqueued (m-b)
257
258
  this.getQueue();
258
259
  this.workerConn = bullConnection(this.d.redisUrl, this.d.log);
259
- this.worker = new Worker(USAGE_REPORT_QUEUE, (j, t) => this.process(j, t), {
260
+ this.worker = new Worker(USAGE_REPORT_QUEUE, (j, t) => withJobContext(USAGE_REPORT_QUEUE, j, () => this.process(j, t)), {
260
261
  connection: this.workerConn,
261
262
  concurrency: this.d.config().hooks.usageReportConcurrency,
262
263
  lockDuration: jobLockMs,
@@ -37,9 +37,11 @@ export declare const SYNC_MARKER = "/workspace/attachments/.capybara-sync.json";
37
37
  /** 附件文件名的上限(UTF-8 字节,常见文件系统单个文件名上限 255 字节,留出 `-N` 后缀的余量)。 */
38
38
  export declare const ATTACHMENT_NAME_MAX_BYTES = 200;
39
39
  /**
40
- * 沙箱工具时限里额外留出的余量(毫秒):创建 / 恢复沙箱,以及命令超时后停命令的兜底——E2B 杀进程脚本没跑完时
41
- * 重启沙箱(杀进程最多约 20 秒,加 pause + 冷启动 connect 最多 120 秒,约 160 秒)。首次创建时拉取镜像不在其内
42
- * (部署时应预先拉好 `sandbox.image`,见 plugins/sandbox-docker/README.md)。
40
+ * Extra margin (ms) on top of a sandbox tool's time limit: creating / resuming the sandbox, and the
41
+ * fallback for stopping a command after its timeout (when the E2B kill script does not finish, the
42
+ * sandbox is restarted: up to about 20 s to kill, plus pause and a cold connect of up to 120 s, about
43
+ * 160 s). Pulling the image on first creation is not included (deployments pre-pull
44
+ * `sandbox.image`; see the sandbox-docker plugin's README in capybara-hq/plugins).
43
45
  */
44
46
  export declare const SANDBOX_SETUP_MARGIN_MS = 180000;
45
47
  /**
@@ -22,9 +22,11 @@ const MARKER_MAX_BYTES = 1024 * 1024;
22
22
  /** 附件文件名的上限(UTF-8 字节,常见文件系统单个文件名上限 255 字节,留出 `-N` 后缀的余量)。 */
23
23
  export const ATTACHMENT_NAME_MAX_BYTES = 200;
24
24
  /**
25
- * 沙箱工具时限里额外留出的余量(毫秒):创建 / 恢复沙箱,以及命令超时后停命令的兜底——E2B 杀进程脚本没跑完时
26
- * 重启沙箱(杀进程最多约 20 秒,加 pause + 冷启动 connect 最多 120 秒,约 160 秒)。首次创建时拉取镜像不在其内
27
- * (部署时应预先拉好 `sandbox.image`,见 plugins/sandbox-docker/README.md)。
25
+ * Extra margin (ms) on top of a sandbox tool's time limit: creating / resuming the sandbox, and the
26
+ * fallback for stopping a command after its timeout (when the E2B kill script does not finish, the
27
+ * sandbox is restarted: up to about 20 s to kill, plus pause and a cold connect of up to 120 s, about
28
+ * 160 s). Pulling the image on first creation is not included (deployments pre-pull
29
+ * `sandbox.image`; see the sandbox-docker plugin's README in capybara-hq/plugins).
28
30
  */
29
31
  export const SANDBOX_SETUP_MARGIN_MS = 180_000;
30
32
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/kernel",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0-rc.1",
4
4
  "description": "Capybara kernel: config, database, plugin host and HTTP API of the Capybara agent platform",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -47,6 +47,8 @@
47
47
  "jose": "^6.2.12",
48
48
  "kysely": "^0.29.6",
49
49
  "pg": "^8.23.1",
50
+ "pino": "10.4.0",
51
+ "pino-pretty": "13.2.0",
50
52
  "reflect-metadata": "0.2.2",
51
53
  "rxjs": "^7.8.2",
52
54
  "semver": "^7.8.5",
@@ -62,7 +64,7 @@
62
64
  "@types/pg": "^8.23.1",
63
65
  "@types/semver": "^7.8.0",
64
66
  "@types/yauzl": "3.4.0",
65
- "@capybara-hq/testing": "0.1.0-rc.0"
67
+ "@capybara-hq/testing": "0.1.0-rc.2"
66
68
  },
67
69
  "scripts": {
68
70
  "build": "tsc -b"