pi-harness-runtime 1.1.394 → 1.1.396

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.
Files changed (214) hide show
  1. package/AGENTS.md +135 -4
  2. package/harness/glm-quota-logger.ts +17 -0
  3. package/harness/langchain/planner-logger.ts +15 -0
  4. package/harness/service-diagnostics.ts +32 -1
  5. package/index.ts +92 -3
  6. package/package.json +1 -1
  7. package/packages/a2a-adapter/package.json +1 -1
  8. package/packages/agent-policy/package.json +1 -1
  9. package/packages/architecture-generator/package.json +1 -1
  10. package/packages/auth/package.json +1 -1
  11. package/packages/autonomous-refactor/package.json +1 -1
  12. package/packages/autonomous-runtime/package.json +1 -1
  13. package/packages/cache-strategy/package.json +1 -1
  14. package/packages/capability-registry/package.json +1 -1
  15. package/packages/checkpoint/package.json +1 -1
  16. package/packages/cli-plugin-sdk/package.json +1 -1
  17. package/packages/clipboard/package.json +1 -1
  18. package/packages/clipboard-plugin/package.json +1 -1
  19. package/packages/code-generation/package.json +1 -1
  20. package/packages/code-review/package.json +1 -1
  21. package/packages/codex-adapter/package.json +1 -1
  22. package/packages/codex-watcher/package.json +1 -1
  23. package/packages/config-capture/package.json +1 -1
  24. package/packages/connection-runtime/package.json +1 -1
  25. package/packages/context-compiler/package.json +1 -1
  26. package/packages/context-discovery/package.json +1 -1
  27. package/packages/context-manager/package.json +1 -1
  28. package/packages/contracts/dist/workspace-env-format.d.ts +87 -0
  29. package/packages/contracts/dist/workspace-env-format.js +174 -6
  30. package/packages/contracts/dist/workspace-env.js +120 -9
  31. package/packages/contracts/package.json +1 -1
  32. package/packages/contracts/src/workspace-env-format.ts +209 -6
  33. package/packages/contracts/src/workspace-env.ts +128 -7
  34. package/packages/contracts/test/workspace-env.test.ts +177 -0
  35. package/packages/cookie-sanitizer/package.json +1 -1
  36. package/packages/cost-optimizer/package.json +1 -1
  37. package/packages/dependency-analyzer/package.json +1 -1
  38. package/packages/django-plugin/package.json +1 -1
  39. package/packages/doc-generator/package.json +1 -1
  40. package/packages/durable-review/package.json +1 -1
  41. package/packages/e2e-tester/package.json +1 -1
  42. package/packages/evaluation-engine/package.json +1 -1
  43. package/packages/evaluation-runner/package.json +1 -1
  44. package/packages/event-bus/package.json +1 -1
  45. package/packages/event-sink/package.json +1 -1
  46. package/packages/event-store/package.json +1 -1
  47. package/packages/experience-replay/package.json +1 -1
  48. package/packages/feedback-collector/package.json +1 -1
  49. package/packages/file-copy-helper/package.json +1 -1
  50. package/packages/framework-detector/package.json +1 -1
  51. package/packages/framework-plugin-sdk/package.json +1 -1
  52. package/packages/frappe-plugin/package.json +1 -1
  53. package/packages/generic-web-plugin/package.json +1 -1
  54. package/packages/health-monitor/package.json +1 -1
  55. package/packages/intent-analyzer/package.json +1 -1
  56. package/packages/jev-judge/dist/continuation-controller.d.ts +47 -0
  57. package/packages/jev-judge/dist/continuation-controller.d.ts.map +1 -1
  58. package/packages/jev-judge/dist/continuation-controller.js +142 -16
  59. package/packages/jev-judge/dist/continuation-controller.js.map +1 -1
  60. package/packages/jev-judge/dist/index.d.ts +6 -0
  61. package/packages/jev-judge/dist/index.d.ts.map +1 -1
  62. package/packages/jev-judge/dist/index.js +6 -0
  63. package/packages/jev-judge/dist/index.js.map +1 -1
  64. package/packages/jev-judge/dist/validation-ledger.d.ts +129 -0
  65. package/packages/jev-judge/dist/validation-ledger.d.ts.map +1 -0
  66. package/packages/jev-judge/dist/validation-ledger.js +234 -0
  67. package/packages/jev-judge/dist/validation-ledger.js.map +1 -0
  68. package/packages/jev-judge/dist/validation-plan.d.ts +176 -0
  69. package/packages/jev-judge/dist/validation-plan.d.ts.map +1 -0
  70. package/packages/jev-judge/dist/validation-plan.js +293 -0
  71. package/packages/jev-judge/dist/validation-plan.js.map +1 -0
  72. package/packages/jev-judge/dist/validation-tie-breaker.d.ts +48 -0
  73. package/packages/jev-judge/dist/validation-tie-breaker.d.ts.map +1 -0
  74. package/packages/jev-judge/dist/validation-tie-breaker.js +83 -0
  75. package/packages/jev-judge/dist/validation-tie-breaker.js.map +1 -0
  76. package/packages/jev-judge/package.json +1 -1
  77. package/packages/jev-judge/src/continuation-controller.ts +179 -20
  78. package/packages/jev-judge/src/index.ts +41 -0
  79. package/packages/jev-judge/src/validation-ledger.ts +290 -0
  80. package/packages/jev-judge/src/validation-plan.ts +406 -0
  81. package/packages/jev-judge/src/validation-tie-breaker.ts +123 -0
  82. package/packages/jev-judge/tests/continuation-controller.test.ts +167 -0
  83. package/packages/jev-judge/tests/validation-ledger.test.ts +181 -0
  84. package/packages/jev-judge/tests/validation-plan.test.ts +170 -0
  85. package/packages/jev-judge/tests/validation-tie-breaker.test.ts +88 -0
  86. package/packages/knowledge-graph/package.json +1 -1
  87. package/packages/knowledge-retrieval/package.json +1 -1
  88. package/packages/laravel-plugin/package.json +1 -1
  89. package/packages/learning-engine/package.json +1 -1
  90. package/packages/logging/README.md +37 -0
  91. package/packages/logging/dist/buffer.d.ts +33 -0
  92. package/packages/logging/dist/buffer.d.ts.map +1 -0
  93. package/packages/logging/dist/buffer.js +60 -0
  94. package/packages/logging/dist/buffer.js.map +1 -0
  95. package/packages/logging/dist/config.d.ts +40 -0
  96. package/packages/logging/dist/config.d.ts.map +1 -0
  97. package/packages/logging/dist/config.js +114 -0
  98. package/packages/logging/dist/config.js.map +1 -0
  99. package/packages/logging/dist/index.d.ts +32 -0
  100. package/packages/logging/dist/index.d.ts.map +1 -0
  101. package/packages/logging/dist/index.js +31 -0
  102. package/packages/logging/dist/index.js.map +1 -0
  103. package/packages/logging/dist/redact.d.ts +43 -0
  104. package/packages/logging/dist/redact.d.ts.map +1 -0
  105. package/packages/logging/dist/redact.js +220 -0
  106. package/packages/logging/dist/redact.js.map +1 -0
  107. package/packages/logging/dist/sampler.d.ts +37 -0
  108. package/packages/logging/dist/sampler.d.ts.map +1 -0
  109. package/packages/logging/dist/sampler.js +78 -0
  110. package/packages/logging/dist/sampler.js.map +1 -0
  111. package/packages/logging/dist/shipper.d.ts +82 -0
  112. package/packages/logging/dist/shipper.d.ts.map +1 -0
  113. package/packages/logging/dist/shipper.js +238 -0
  114. package/packages/logging/dist/shipper.js.map +1 -0
  115. package/packages/logging/dist/singleton.d.ts +38 -0
  116. package/packages/logging/dist/singleton.d.ts.map +1 -0
  117. package/packages/logging/dist/singleton.js +95 -0
  118. package/packages/logging/dist/singleton.js.map +1 -0
  119. package/packages/logging/dist/spool.d.ts +39 -0
  120. package/packages/logging/dist/spool.d.ts.map +1 -0
  121. package/packages/logging/dist/spool.js +110 -0
  122. package/packages/logging/dist/spool.js.map +1 -0
  123. package/packages/logging/dist/transports/r2.d.ts +31 -0
  124. package/packages/logging/dist/transports/r2.d.ts.map +1 -0
  125. package/packages/logging/dist/transports/r2.js +60 -0
  126. package/packages/logging/dist/transports/r2.js.map +1 -0
  127. package/packages/logging/dist/transports/webhook.d.ts +22 -0
  128. package/packages/logging/dist/transports/webhook.d.ts.map +1 -0
  129. package/packages/logging/dist/transports/webhook.js +37 -0
  130. package/packages/logging/dist/transports/webhook.js.map +1 -0
  131. package/packages/logging/dist/types.d.ts +73 -0
  132. package/packages/logging/dist/types.d.ts.map +1 -0
  133. package/packages/logging/dist/types.js +19 -0
  134. package/packages/logging/dist/types.js.map +1 -0
  135. package/packages/logging/package.json +37 -0
  136. package/packages/logging/src/buffer.ts +77 -0
  137. package/packages/logging/src/config.ts +163 -0
  138. package/packages/logging/src/index.ts +87 -0
  139. package/packages/logging/src/redact.ts +271 -0
  140. package/packages/logging/src/sampler.ts +97 -0
  141. package/packages/logging/src/shipper.ts +281 -0
  142. package/packages/logging/src/singleton.ts +142 -0
  143. package/packages/logging/src/spool.ts +139 -0
  144. package/packages/logging/src/transports/r2.ts +86 -0
  145. package/packages/logging/src/transports/webhook.ts +54 -0
  146. package/packages/logging/src/types.ts +85 -0
  147. package/packages/logging/test/buffer.test.ts +44 -0
  148. package/packages/logging/test/config.test.ts +97 -0
  149. package/packages/logging/test/integration.test.ts +135 -0
  150. package/packages/logging/test/redact.test.ts +109 -0
  151. package/packages/logging/test/sampler.test.ts +57 -0
  152. package/packages/logging/test/shipper.test.ts +152 -0
  153. package/packages/logging/test/spool.test.ts +93 -0
  154. package/packages/mcp-adapter/package.json +1 -1
  155. package/packages/memory-engine/package.json +1 -1
  156. package/packages/milestone-manager/package.json +1 -1
  157. package/packages/model-registry/package.json +1 -1
  158. package/packages/nextjs-plugin/package.json +1 -1
  159. package/packages/notification/package.json +1 -1
  160. package/packages/observability/package.json +1 -1
  161. package/packages/okf-indexer/package.json +1 -1
  162. package/packages/paperclip-integration/package.json +1 -1
  163. package/packages/performance-optimizer/package.json +1 -1
  164. package/packages/permission-mode/package.json +1 -1
  165. package/packages/privilege-broker/package.json +1 -1
  166. package/packages/project-analyzer/package.json +1 -1
  167. package/packages/project-bootstrap/package.json +1 -1
  168. package/packages/projection-engine/package.json +1 -1
  169. package/packages/prompt-compiler/package.json +1 -1
  170. package/packages/prompt-versioning/package.json +1 -1
  171. package/packages/provider-adapter-sdk/package.json +1 -1
  172. package/packages/provider-router/package.json +1 -1
  173. package/packages/provider-selector/package.json +1 -1
  174. package/packages/providers/package.json +1 -1
  175. package/packages/qdrant-skills/package.json +1 -1
  176. package/packages/quota-manager/package.json +1 -1
  177. package/packages/rate-limiter/package.json +1 -1
  178. package/packages/react-vite-plugin/package.json +1 -1
  179. package/packages/relay/package.json +1 -1
  180. package/packages/release-manager/package.json +1 -1
  181. package/packages/requirement-compiler/package.json +1 -1
  182. package/packages/runtime/package.json +1 -1
  183. package/packages/scheduler/package.json +1 -1
  184. package/packages/scheduler-adapter/package.json +1 -1
  185. package/packages/session/package.json +1 -1
  186. package/packages/session-api/package.json +1 -1
  187. package/packages/session-export/package.json +1 -1
  188. package/packages/shared-context/package.json +1 -1
  189. package/packages/skill-mcp-client/package.json +1 -1
  190. package/packages/skill-registry/package.json +1 -1
  191. package/packages/skills/package.json +1 -1
  192. package/packages/sprint-planner/package.json +1 -1
  193. package/packages/subscription-engine/package.json +1 -1
  194. package/packages/task-compiler/package.json +1 -1
  195. package/packages/telegram/package.json +1 -1
  196. package/packages/tencentdb-memory/package.json +1 -1
  197. package/packages/tencentdb-sync/package.json +1 -1
  198. package/packages/test-data-generator/package.json +1 -1
  199. package/packages/test-generator/package.json +1 -1
  200. package/packages/todo-bd-sync/package.json +1 -1
  201. package/packages/token-estimation/package.json +1 -1
  202. package/packages/token-optimizer/package.json +1 -1
  203. package/packages/trajectory/package.json +1 -1
  204. package/packages/tui/package.json +1 -1
  205. package/packages/types/package.json +1 -1
  206. package/packages/voice-api/package.json +1 -1
  207. package/packages/webmcp-tester/package.json +1 -1
  208. package/packages/wiki/package.json +1 -1
  209. package/packages/workflow-events/package.json +1 -1
  210. package/packages/workspace-scanner/package.json +1 -1
  211. package/packages/worktree/package.json +1 -1
  212. package/packages/write-review/package.json +1 -1
  213. package/scripts/unified-webhook-server.ts +102 -0
  214. package/scripts/validation-run.ts +191 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The process-wide logging singleton.
3
+ *
4
+ * The singleton lives in the package (not in the harness) so that every
5
+ * producer - the root extension and the harness modules - shares exactly one
6
+ * shipper, one buffer and one spool.
7
+ *
8
+ * Nothing here throws: a logging failure must never break the caller.
9
+ */
10
+
11
+ import { resolveLogSinkConfig, type LogSinkConfig } from "./config.js";
12
+ import { LogShipper } from "./shipper.js";
13
+ import { LogSpool } from "./spool.js";
14
+ import type { LogLevel } from "./types.js";
15
+
16
+ export interface InitCentralLoggingOptions {
17
+ /** Environment used for the configuration. Defaults to `process.env`. */
18
+ env?: Record<string, string | undefined>;
19
+ /** Directory holding the credential key files. */
20
+ keysDir?: string;
21
+ }
22
+
23
+ let shipper: LogShipper | null = null;
24
+ let config: LogSinkConfig | null = null;
25
+
26
+ /**
27
+ * Initialize the centralized logging pipeline.
28
+ *
29
+ * Calling it twice is safe: the second call returns the existing
30
+ * configuration without creating a second shipper.
31
+ */
32
+ export function initCentralLogging(
33
+ options: InitCentralLoggingOptions = {},
34
+ ): LogSinkConfig | null {
35
+ if (config) return config;
36
+
37
+ try {
38
+ const resolved = resolveLogSinkConfig({
39
+ env: options.env ?? process.env,
40
+ keysDir: options.keysDir,
41
+ });
42
+ config = resolved;
43
+
44
+ if (!resolved.enabled) return resolved;
45
+
46
+ const spool = resolved.spoolDir ? new LogSpool({ dir: resolved.spoolDir }) : undefined;
47
+ shipper = new LogShipper({
48
+ app: resolved.app,
49
+ transports: resolved.transports,
50
+ spool,
51
+ buffer: resolved.buffer,
52
+ sampling: resolved.sampling,
53
+ flushIntervalMs: resolved.flushIntervalMs,
54
+ });
55
+ shipper.start();
56
+
57
+ // The exit handler is synchronous on purpose: an async flush cannot
58
+ // run during `exit`, so the remaining records go to the local spool.
59
+ process.once("exit", () => {
60
+ shipper?.flushSync();
61
+ });
62
+
63
+ return resolved;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** The active shipper, or null when logging is not initialized. */
70
+ export function getCentralShipper(): LogShipper | null {
71
+ return shipper;
72
+ }
73
+
74
+ /** The resolved configuration, or null when logging was never initialized. */
75
+ export function getCentralLogConfig(): LogSinkConfig | null {
76
+ return config;
77
+ }
78
+
79
+ /** Emit one record. No-op when the pipeline is not initialized. */
80
+ export function centralLog(
81
+ level: LogLevel,
82
+ source: string,
83
+ event: string,
84
+ message?: string,
85
+ fields?: Record<string, unknown>,
86
+ error?: unknown,
87
+ ): void {
88
+ try {
89
+ shipper?.log({ level, source, event, message, fields, error });
90
+ } catch {
91
+ // Never let logging break the caller.
92
+ }
93
+ }
94
+
95
+ export function centralDebug(
96
+ source: string,
97
+ event: string,
98
+ message?: string,
99
+ fields?: Record<string, unknown>,
100
+ ): void {
101
+ centralLog("debug", source, event, message, fields);
102
+ }
103
+
104
+ export function centralInfo(
105
+ source: string,
106
+ event: string,
107
+ message?: string,
108
+ fields?: Record<string, unknown>,
109
+ ): void {
110
+ centralLog("info", source, event, message, fields);
111
+ }
112
+
113
+ export function centralWarn(
114
+ source: string,
115
+ event: string,
116
+ message?: string,
117
+ fields?: Record<string, unknown>,
118
+ ): void {
119
+ centralLog("warn", source, event, message, fields);
120
+ }
121
+
122
+ export function centralError(
123
+ source: string,
124
+ event: string,
125
+ message?: string,
126
+ fields?: Record<string, unknown>,
127
+ error?: unknown,
128
+ ): void {
129
+ centralLog("error", source, event, message, fields, error);
130
+ }
131
+
132
+ /** Flush and stop the pipeline. Safe to call when it was never started. */
133
+ export async function shutdownCentralLogging(): Promise<void> {
134
+ if (!shipper) return;
135
+ const current = shipper;
136
+ shipper = null;
137
+ try {
138
+ await current.stop();
139
+ } catch {
140
+ // Nothing to do at shutdown time.
141
+ }
142
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Local NDJSON spool - the last-resort destination for log batches.
3
+ *
4
+ * When every transport fails (no network, R2 down, webhook server down) the
5
+ * batch is written to disk instead of being lost. The spool is drained on
6
+ * the next successful flush, so a temporary outage does not cost data.
7
+ *
8
+ * Files are plain NDJSON, one record per line, so an operator can inspect
9
+ * them with `tail`, `jq` or `less` without any tooling.
10
+ */
11
+
12
+ import {
13
+ appendFileSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ renameSync,
19
+ rmSync,
20
+ statSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+ import type { LogBatch, LogRecord } from "./types.js";
24
+
25
+ export interface LogSpoolOptions {
26
+ /** Directory that holds the spool files. */
27
+ dir: string;
28
+ /** Rotate a file when it would exceed this size. Default 5 MB. */
29
+ maxFileBytes?: number;
30
+ /** Maximum number of spool files kept. Default 20. */
31
+ maxFiles?: number;
32
+ }
33
+
34
+ const FILE_PREFIX = "logs-spool-";
35
+ const FILE_SUFFIX = ".ndjson";
36
+
37
+ export class LogSpool {
38
+ private readonly dir: string;
39
+ private readonly maxFileBytes: number;
40
+ private readonly maxFiles: number;
41
+
42
+ constructor(options: LogSpoolOptions) {
43
+ this.dir = options.dir;
44
+ this.maxFileBytes = Math.max(1024, options.maxFileBytes ?? 5 * 1024 * 1024);
45
+ this.maxFiles = Math.max(1, options.maxFiles ?? 20);
46
+ }
47
+
48
+ /** Append a batch to the spool. Returns the file that was written. */
49
+ append(batch: LogBatch, now: Date = new Date()): string {
50
+ this.ensureDir();
51
+ const file = this.currentFile(now);
52
+ const payload = batch.records.map((record) => JSON.stringify(record)).join("\n") + "\n";
53
+
54
+ if (this.shouldRotate(file, payload.length)) {
55
+ this.rotate(file);
56
+ }
57
+
58
+ appendFileSync(file, payload, "utf8");
59
+ this.enforceRetention();
60
+ return file;
61
+ }
62
+
63
+ /** Spool files, oldest first. */
64
+ list(): string[] {
65
+ if (!existsSync(this.dir)) return [];
66
+ return readdirSync(this.dir)
67
+ .filter(
68
+ (name) =>
69
+ name.startsWith(FILE_PREFIX) &&
70
+ (name.endsWith(FILE_SUFFIX) || new RegExp(`\\${FILE_SUFFIX}\\.\\d+$`).test(name)),
71
+ )
72
+ .map((name) => join(this.dir, name))
73
+ .sort();
74
+ }
75
+
76
+ /** Read the records of a spool file. */
77
+ read(file: string): LogRecord[] {
78
+ if (!existsSync(file)) return [];
79
+ const content = readFileSync(file, "utf8");
80
+ const records: LogRecord[] = [];
81
+ for (const line of content.split("\n")) {
82
+ const trimmed = line.trim();
83
+ if (!trimmed) continue;
84
+ try {
85
+ records.push(JSON.parse(trimmed) as LogRecord);
86
+ } catch {
87
+ // A truncated last line is expected after a crash; skip it.
88
+ }
89
+ }
90
+ return records;
91
+ }
92
+
93
+ /** Delete a spool file after it was shipped. */
94
+ remove(file: string): void {
95
+ try {
96
+ rmSync(file, { force: true });
97
+ } catch {
98
+ // Nothing to do - the file stays and is retried later.
99
+ }
100
+ }
101
+
102
+ private ensureDir(): void {
103
+ if (!existsSync(this.dir)) {
104
+ mkdirSync(this.dir, { recursive: true });
105
+ }
106
+ }
107
+
108
+ private currentFile(now: Date): string {
109
+ const day = now.toISOString().slice(0, 10);
110
+ return join(this.dir, `${FILE_PREFIX}${day}${FILE_SUFFIX}`);
111
+ }
112
+
113
+ private shouldRotate(file: string, incomingBytes: number): boolean {
114
+ if (!existsSync(file)) return false;
115
+ try {
116
+ return statSync(file).size + incomingBytes > this.maxFileBytes;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ private rotate(file: string): void {
123
+ for (let index = 1; index <= this.maxFiles; index++) {
124
+ const candidate = `${file}.${index}`;
125
+ if (!existsSync(candidate)) {
126
+ renameSync(file, candidate);
127
+ return;
128
+ }
129
+ }
130
+ }
131
+
132
+ private enforceRetention(): void {
133
+ const files = this.list();
134
+ const extra = files.length - this.maxFiles;
135
+ for (let i = 0; i < extra; i++) {
136
+ this.remove(files[i]);
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Cloudflare R2 transport.
3
+ *
4
+ * Batches are uploaded as NDJSON objects under a date-partitioned prefix so
5
+ * a bucket can be queried by day without listing every object:
6
+ *
7
+ * logs/harness/2026/10/24/09/20261024T091500-abc123.ndjson.gz
8
+ *
9
+ * The AWS SDK is imported lazily so the package stays usable (and testable)
10
+ * without the SDK installed, and so a missing dependency surfaces as a
11
+ * transport error instead of a crash at startup.
12
+ */
13
+
14
+ import { gzipSync } from "node:zlib";
15
+ import type { LogBatch, LogTransport } from "../types.js";
16
+
17
+ export interface R2TransportOptions {
18
+ /** R2 bucket name. */
19
+ bucket: string;
20
+ /** R2 endpoint, e.g. https://<account>.r2.cloudflarestorage.com */
21
+ endpoint: string;
22
+ accessKeyId: string;
23
+ secretAccessKey: string;
24
+ /** Key prefix inside the bucket. Default "logs/harness". */
25
+ prefix?: string;
26
+ /** Region label; R2 uses "auto". Default "auto". */
27
+ region?: string;
28
+ /** gzip the payload. Default true. */
29
+ gzip?: boolean;
30
+ }
31
+
32
+ /** Build the date-partitioned object key for a batch. */
33
+ export function r2ObjectKey(prefix: string, batch: LogBatch): string {
34
+ const sentAt = new Date(batch.sentAt);
35
+ const year = sentAt.getUTCFullYear();
36
+ const month = String(sentAt.getUTCMonth() + 1).padStart(2, "0");
37
+ const day = String(sentAt.getUTCDate()).padStart(2, "0");
38
+ const hour = String(sentAt.getUTCHours()).padStart(2, "0");
39
+ return `${prefix}/${year}/${month}/${day}/${hour}/${batch.batchId}.ndjson`;
40
+ }
41
+
42
+ export function createR2Transport(options: R2TransportOptions): LogTransport {
43
+ const prefix = options.prefix ?? "logs/harness";
44
+ const region = options.region ?? "auto";
45
+ const gzip = options.gzip ?? true;
46
+
47
+ return {
48
+ name: "r2",
49
+ async send(batch: LogBatch): Promise<void> {
50
+ if (batch.records.length === 0) return;
51
+
52
+ let s3: typeof import("@aws-sdk/client-s3");
53
+ try {
54
+ s3 = await import("@aws-sdk/client-s3");
55
+ } catch (error) {
56
+ throw new Error(
57
+ `R2 transport requires @aws-sdk/client-s3: ${error instanceof Error ? error.message : String(error)}`,
58
+ );
59
+ }
60
+
61
+ const client = new s3.S3Client({
62
+ region,
63
+ endpoint: options.endpoint,
64
+ credentials: {
65
+ accessKeyId: options.accessKeyId,
66
+ secretAccessKey: options.secretAccessKey,
67
+ },
68
+ });
69
+
70
+ const payload = batch.records.map((record) => JSON.stringify(record)).join("\n") + "\n";
71
+ const body = gzip ? gzipSync(payload) : payload;
72
+ const key = `${r2ObjectKey(prefix, batch)}${gzip ? ".gz" : ""}`;
73
+
74
+ await client.send(
75
+ new s3.PutObjectCommand({
76
+ Bucket: options.bucket,
77
+ Key: key,
78
+ Body: body,
79
+ ContentType: gzip ? "application/gzip" : "application/x-ndjson",
80
+ }),
81
+ );
82
+
83
+ client.destroy();
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Webhook transport.
3
+ *
4
+ * Delivers batches over HTTP to the existing unified webhook server
5
+ * (`POST /api/logs`). This is the low-latency path: the server can fan the
6
+ * batch out to alerting, while R2 keeps the durable archive.
7
+ */
8
+
9
+ import type { LogBatch, LogTransport } from "../types.js";
10
+
11
+ export interface WebhookTransportOptions {
12
+ /** Full URL of the ingest endpoint. */
13
+ url: string;
14
+ /** Optional bearer token. */
15
+ token?: string;
16
+ /** Extra headers. */
17
+ headers?: Record<string, string>;
18
+ /** Request timeout in milliseconds. Default 10000. */
19
+ timeoutMs?: number;
20
+ /** Custom fetch implementation (tests). */
21
+ fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ export function createWebhookTransport(options: WebhookTransportOptions): LogTransport {
25
+ const timeoutMs = options.timeoutMs ?? 10_000;
26
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
27
+
28
+ return {
29
+ name: "webhook",
30
+ async send(batch: LogBatch): Promise<void> {
31
+ if (batch.records.length === 0) return;
32
+ if (typeof fetchImpl !== "function") {
33
+ throw new Error("Webhook transport requires a fetch implementation");
34
+ }
35
+
36
+ const headers: Record<string, string> = {
37
+ "Content-Type": "application/json",
38
+ ...options.headers,
39
+ };
40
+ if (options.token) headers.Authorization = `Bearer ${options.token}`;
41
+
42
+ const response = await fetchImpl(options.url, {
43
+ method: "POST",
44
+ headers,
45
+ body: JSON.stringify(batch),
46
+ signal: AbortSignal.timeout(timeoutMs),
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new Error(`Webhook transport failed with HTTP ${response.status}`);
51
+ }
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Log Sink - shared types for the centralized logging pipeline.
3
+ *
4
+ * The pipeline has three layers:
5
+ * 1. `LogRecord` - the structured, redacted unit that every producer emits.
6
+ * 2. `LogBatch` - the transport unit (NDJSON over R2 or HTTP to the webhook).
7
+ * 3. `LogTransport` - the pluggable delivery mechanism.
8
+ *
9
+ * Producers never know which transport is configured; the shipper owns that
10
+ * decision, including the fallback order and the local spool.
11
+ */
12
+
13
+ export type LogLevel = "debug" | "info" | "warn" | "error";
14
+
15
+ /** Severity order used by the sampler and the buffer drop policy. */
16
+ export const LOG_LEVEL_SEVERITY: Record<LogLevel, number> = {
17
+ debug: 10,
18
+ info: 20,
19
+ warn: 30,
20
+ error: 40,
21
+ };
22
+
23
+ export interface LogErrorInfo {
24
+ name: string;
25
+ message: string;
26
+ stack?: string;
27
+ }
28
+
29
+ /** A single structured log record. */
30
+ export interface LogRecord {
31
+ /** ISO-8601 timestamp of the event. */
32
+ ts: string;
33
+ level: LogLevel;
34
+ /** Emitting component, e.g. "jev-judge" or "harness.service-diagnostics". */
35
+ source: string;
36
+ /** Dotted event name, e.g. "service.config_checked". */
37
+ event: string;
38
+ message?: string;
39
+ sessionId?: string;
40
+ durationMs?: number;
41
+ /** Structured, already redacted payload. */
42
+ fields?: Record<string, unknown>;
43
+ error?: LogErrorInfo;
44
+ }
45
+
46
+ /** What a producer hands to the shipper. */
47
+ export interface LogInput {
48
+ level: LogLevel;
49
+ source: string;
50
+ event: string;
51
+ message?: string;
52
+ sessionId?: string;
53
+ durationMs?: number;
54
+ fields?: Record<string, unknown>;
55
+ /** Anything thrown - converted to a `LogErrorInfo` by the shipper. */
56
+ error?: unknown;
57
+ }
58
+
59
+ /** The transport unit: a batch of records shipped together. */
60
+ export interface LogBatch {
61
+ batchId: string;
62
+ app: string;
63
+ host: string;
64
+ sentAt: string;
65
+ records: LogRecord[];
66
+ }
67
+
68
+ /** A delivery mechanism. Throwing means "delivery failed". */
69
+ export interface LogTransport {
70
+ readonly name: string;
71
+ send(batch: LogBatch): Promise<void>;
72
+ }
73
+
74
+ export interface FlushStats {
75
+ /** Records handed to a transport successfully. */
76
+ shipped: number;
77
+ /** Records written to the local spool after every transport failed. */
78
+ spooled: number;
79
+ /** Records dropped by the buffer because it was full. */
80
+ dropped: number;
81
+ /** Records dropped by the sampler (level, sample rate, rate limit). */
82
+ sampled: number;
83
+ /** Transport errors, keyed by transport name. */
84
+ errors: Record<string, string>;
85
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { LogBuffer } from "../src/buffer.js";
3
+ import type { LogRecord } from "../src/types.js";
4
+
5
+ function record(level: LogRecord["level"], id: number): LogRecord {
6
+ return {
7
+ ts: "2026-10-24T00:00:00.000Z",
8
+ level,
9
+ source: "test",
10
+ event: `event-${id}`,
11
+ };
12
+ }
13
+
14
+ describe("LogBuffer", () => {
15
+ test("takes records in order and respects the batch size", () => {
16
+ const buffer = new LogBuffer({ maxSize: 10, maxBatchSize: 2 });
17
+ buffer.push(record("info", 1));
18
+ buffer.push(record("info", 2));
19
+ buffer.push(record("info", 3));
20
+
21
+ const batch = buffer.takeBatch();
22
+ expect(batch).toHaveLength(2);
23
+ expect(batch[0].event).toBe("event-1");
24
+ expect(buffer.size).toBe(1);
25
+ });
26
+
27
+ test("drops the least severe record when full", () => {
28
+ const buffer = new LogBuffer({ maxSize: 2, maxBatchSize: 10 });
29
+ buffer.push(record("error", 1));
30
+ buffer.push(record("info", 2));
31
+ buffer.push(record("debug", 3));
32
+
33
+ expect(buffer.dropped).toBe(1);
34
+ const batch = buffer.takeBatch();
35
+ expect(batch.map((item) => item.event)).toEqual(["event-1", "event-3"]);
36
+ });
37
+
38
+ test("peekAll does not consume the buffer", () => {
39
+ const buffer = new LogBuffer({ maxSize: 10, maxBatchSize: 10 });
40
+ buffer.push(record("info", 1));
41
+ expect(buffer.peekAll()).toHaveLength(1);
42
+ expect(buffer.size).toBe(1);
43
+ });
44
+ });
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveLogSinkConfig } from "../src/config.js";
6
+
7
+ const created: string[] = [];
8
+
9
+ function tempDir(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "log-config-"));
11
+ created.push(dir);
12
+ return dir;
13
+ }
14
+
15
+ afterEach(() => {
16
+ while (created.length > 0) {
17
+ rmSync(created.pop() as string, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ describe("resolveLogSinkConfig", () => {
22
+ test("PI_LOG_SINK=off disables logging completely", () => {
23
+ const config = resolveLogSinkConfig({ env: { PI_LOG_SINK: "off" }, keysDir: tempDir() });
24
+ expect(config.enabled).toBe(false);
25
+ expect(config.transports).toHaveLength(0);
26
+ expect(config.spoolDir).toBeUndefined();
27
+ });
28
+
29
+ test("auto mode stays local when nothing is configured", () => {
30
+ const config = resolveLogSinkConfig({ env: {}, keysDir: tempDir() });
31
+ expect(config.enabled).toBe(true);
32
+ expect(config.transports).toHaveLength(0);
33
+ expect(config.spoolDir).toBeTruthy();
34
+ });
35
+
36
+ test("auto mode picks up R2 credentials from the key files", () => {
37
+ const keysDir = tempDir();
38
+ writeFileSync(join(keysDir, "r2-bucket-name.txt"), "logs-bucket\n");
39
+ writeFileSync(join(keysDir, "r2-access-key-id.txt"), "AKIAEXAMPLE\n");
40
+ writeFileSync(join(keysDir, "r2-secret-access-key.txt"), "secret\n");
41
+ writeFileSync(join(keysDir, "r2-account-id.txt"), "account\n");
42
+
43
+ const config = resolveLogSinkConfig({ env: {}, keysDir });
44
+ expect(config.transports).toHaveLength(1);
45
+ expect(config.transports[0].name).toBe("r2");
46
+ });
47
+
48
+ test("auto mode picks up an explicit webhook url", () => {
49
+ const config = resolveLogSinkConfig({
50
+ env: { PI_LOG_WEBHOOK_URL: "http://127.0.0.1:8443/api/logs" },
51
+ keysDir: tempDir(),
52
+ });
53
+ expect(config.transports).toHaveLength(1);
54
+ expect(config.transports[0].name).toBe("webhook");
55
+ });
56
+
57
+ test("r2+webhook keeps R2 as the first transport", () => {
58
+ const keysDir = tempDir();
59
+ writeFileSync(join(keysDir, "r2-bucket-name.txt"), "logs-bucket\n");
60
+ writeFileSync(join(keysDir, "r2-access-key-id.txt"), "AKIAEXAMPLE\n");
61
+ writeFileSync(join(keysDir, "r2-secret-access-key.txt"), "secret\n");
62
+ writeFileSync(join(keysDir, "r2-endpoint.txt"), "https://example.r2.cloudflarestorage.com\n");
63
+
64
+ const config = resolveLogSinkConfig({
65
+ env: { PI_LOG_SINK: "r2+webhook", PI_LOG_WEBHOOK_URL: "http://127.0.0.1:8443/api/logs" },
66
+ keysDir,
67
+ });
68
+ expect(config.transports.map((transport) => transport.name)).toEqual(["r2", "webhook"]);
69
+ });
70
+
71
+ test("r2 without credentials is skipped and reported", () => {
72
+ const config = resolveLogSinkConfig({ env: { PI_LOG_SINK: "r2" }, keysDir: tempDir() });
73
+ expect(config.transports).toHaveLength(0);
74
+ expect(config.notes.join(" ")).toContain("credentials are incomplete");
75
+ });
76
+
77
+ test("sampling and buffer settings come from the environment", () => {
78
+ const config = resolveLogSinkConfig({
79
+ env: {
80
+ PI_LOG_LEVEL: "warn",
81
+ PI_LOG_SAMPLE_RATE: "0.5",
82
+ PI_LOG_RATE_LIMIT: "5",
83
+ PI_LOG_BUFFER_SIZE: "42",
84
+ PI_LOG_BATCH_SIZE: "7",
85
+ PI_LOG_FLUSH_INTERVAL_MS: "1000",
86
+ },
87
+ keysDir: tempDir(),
88
+ });
89
+
90
+ expect(config.sampling.minLevel).toBe("warn");
91
+ expect(config.sampling.sampleRate).toBe(0.5);
92
+ expect(config.sampling.rateLimitPerMinute).toBe(5);
93
+ expect(config.buffer.maxSize).toBe(42);
94
+ expect(config.buffer.maxBatchSize).toBe(7);
95
+ expect(config.flushIntervalMs).toBe(1000);
96
+ });
97
+ });