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,271 @@
1
+ /**
2
+ * Secret redaction for log records.
3
+ *
4
+ * Two independent layers, because either one alone leaks:
5
+ * 1. Key based - a field named `apiKey` is replaced wholesale.
6
+ * 2. Value based - a secret embedded in free text (`Bearer abc...`) is
7
+ * replaced even when the key name is innocuous.
8
+ *
9
+ * The rules are intentionally conservative: over-redacting a log line is
10
+ * cheap, leaking a credential is not.
11
+ */
12
+
13
+ /**
14
+ * The redacted shape of a log value. Everything that leaves the process is
15
+ * JSON, so the redactor is typed against JSON instead of `unknown`.
16
+ */
17
+ export type JsonValue =
18
+ | string
19
+ | number
20
+ | boolean
21
+ | null
22
+ | JsonValue[]
23
+ | { [key: string]: JsonValue };
24
+
25
+ export interface RedactionOptions {
26
+ /** Longest string kept verbatim before truncation. Default 2000. */
27
+ maxStringLength?: number;
28
+ /** Maximum nesting depth kept. Default 5. */
29
+ maxDepth?: number;
30
+ /** Maximum number of keys kept per object. Default 50. */
31
+ maxKeys?: number;
32
+ /** Maximum number of items kept per array. Default 20. */
33
+ maxArrayItems?: number;
34
+ /** Placeholder written instead of a redacted value. */
35
+ placeholder?: string;
36
+ }
37
+
38
+ const DEFAULT_PLACEHOLDER = "[REDACTED]";
39
+
40
+ /**
41
+ * Key names that always hold a secret. Matched as a whole token after the
42
+ * key is split on non-alphanumerics and camelCase boundaries.
43
+ */
44
+ const SECRET_KEY_TOKENS = new Set([
45
+ "password",
46
+ "passwd",
47
+ "passphrase",
48
+ "secret",
49
+ "secrets",
50
+ "token",
51
+ "tokens",
52
+ "apikey",
53
+ "apisecret",
54
+ "authorization",
55
+ "auth",
56
+ "credential",
57
+ "credentials",
58
+ "privatekey",
59
+ "cookie",
60
+ "setcookie",
61
+ "sessionkey",
62
+ "accesskey",
63
+ "secretaccesskey",
64
+ "refreshtoken",
65
+ "bearertoken",
66
+ "signature",
67
+ "otp",
68
+ "pin",
69
+ "mfa",
70
+ "totp",
71
+ ]);
72
+
73
+ /**
74
+ * Key names whose VALUE is sensitive even though the key itself is not a
75
+ * credential: prompts, raw model output and embeddings must never leave the
76
+ * machine in a log line.
77
+ */
78
+ const SENSITIVE_VALUE_TOKENS = new Set([
79
+ "content",
80
+ "raw",
81
+ "prompt",
82
+ "prompts",
83
+ "messages",
84
+ "embedding",
85
+ "embeddings",
86
+ "vector",
87
+ "vectors",
88
+ "body",
89
+ "chatid",
90
+ "payload",
91
+ "transcript",
92
+ ]);
93
+
94
+ /**
95
+ * Value patterns that look like credentials wherever they appear.
96
+ * Order matters: the more specific patterns run first.
97
+ */
98
+ const SECRET_VALUE_PATTERNS: RegExp[] = [
99
+ // Authorization headers and bearer tokens.
100
+ /\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
101
+ // JWT.
102
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g,
103
+ // OpenAI / Anthropic style keys.
104
+ /\bsk-[A-Za-z0-9_-]{12,}/g,
105
+ // AWS access key ids.
106
+ /\bAKIA[0-9A-Z]{12,}/g,
107
+ // GitHub / Slack / Stripe style tokens.
108
+ /\bgh[pousr]_[A-Za-z0-9]{16,}/g,
109
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
110
+ // Cloudflare R2 style secret keys (long base64-ish runs).
111
+ /\b[A-Za-z0-9_-]{40,}\b/g,
112
+ ];
113
+
114
+ /**
115
+ * `key = value` assignments of a credential-looking key. The key prefix is
116
+ * preserved, only the value is replaced.
117
+ */
118
+ const SECRET_ASSIGNMENT_PATTERN =
119
+ /\b((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|secret|token|password|passwd|authorization)\s*[:=]\s*)("?)([^\s"',;}]{4,})\2/gi;
120
+
121
+ /** Split a key into lowercase tokens (`apiKey` -> `api`, `key`). */
122
+ export function splitKeyTokens(key: string): string[] {
123
+ return key
124
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
125
+ .split(/[^A-Za-z0-9]+/)
126
+ .filter(Boolean)
127
+ .map((part) => part.toLowerCase());
128
+ }
129
+
130
+ /** True when the key itself identifies a secret. */
131
+ export function isSecretKey(key: string): boolean {
132
+ const tokens = splitKeyTokens(key);
133
+ if (tokens.some((token) => SECRET_KEY_TOKENS.has(token))) return true;
134
+ // Compound names such as `openaiApiKey` or `r2_secret_access_key`.
135
+ const joined = tokens.join("");
136
+ return (
137
+ joined.includes("apikey") ||
138
+ joined.includes("secretaccesskey") ||
139
+ joined.includes("privatekey") ||
140
+ joined.includes("accesstoken") ||
141
+ joined.includes("authtoken")
142
+ );
143
+ }
144
+
145
+ /** True when the key holds content that must not be logged verbatim. */
146
+ export function isSensitiveValueKey(key: string): boolean {
147
+ const tokens = splitKeyTokens(key);
148
+ return tokens.some((token) => SENSITIVE_VALUE_TOKENS.has(token));
149
+ }
150
+
151
+ /** Replace credential-looking substrings inside a free-text value. */
152
+ export function redactText(text: string, placeholder = DEFAULT_PLACEHOLDER): string {
153
+ let result = text;
154
+
155
+ for (const pattern of SECRET_VALUE_PATTERNS) {
156
+ // The regexes are global, so `lastIndex` must be reset per use.
157
+ pattern.lastIndex = 0;
158
+ result = result.replace(pattern, placeholder);
159
+ }
160
+
161
+ // `apiKey=abc` keeps the key name, loses the value. This runs last so a
162
+ // credential that was already replaced is not matched a second time.
163
+ SECRET_ASSIGNMENT_PATTERN.lastIndex = 0;
164
+ result = result.replace(SECRET_ASSIGNMENT_PATTERN, `$1${placeholder}`);
165
+
166
+ return result;
167
+ }
168
+
169
+ function truncate(text: string, maxLength: number): string {
170
+ if (text.length <= maxLength) return text;
171
+ return `${text.slice(0, maxLength)}...[truncated:${text.length - maxLength}]`;
172
+ }
173
+
174
+ /** Redact an arbitrary value (strings, numbers, objects, arrays). */
175
+ export function redactValue(
176
+ value: unknown,
177
+ options: RedactionOptions = {},
178
+ depth = 0,
179
+ keyHint?: string,
180
+ ): JsonValue {
181
+ const maxStringLength = options.maxStringLength ?? 2000;
182
+ const maxDepth = options.maxDepth ?? 5;
183
+ const maxKeys = options.maxKeys ?? 50;
184
+ const maxArrayItems = options.maxArrayItems ?? 20;
185
+ const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER;
186
+
187
+ if (keyHint && isSecretKey(keyHint)) {
188
+ return placeholder;
189
+ }
190
+
191
+ if (value === null || value === undefined) return null;
192
+
193
+ switch (typeof value) {
194
+ case "string":
195
+ return truncate(redactText(value, placeholder), maxStringLength);
196
+ case "number":
197
+ case "boolean":
198
+ return value;
199
+ case "bigint":
200
+ return value.toString();
201
+ case "function":
202
+ case "symbol":
203
+ return `[${typeof value}]`;
204
+ default:
205
+ break;
206
+ }
207
+
208
+ if (value instanceof Date) return value.toISOString();
209
+ if (value instanceof Error) {
210
+ const info: { name: string; message: string; stack?: string } = {
211
+ name: value.name,
212
+ message: redactText(value.message, placeholder),
213
+ };
214
+ if (value.stack) info.stack = redactText(value.stack, placeholder);
215
+ return info;
216
+ }
217
+
218
+ if (depth >= maxDepth) return "[MAX_DEPTH]";
219
+
220
+ if (Array.isArray(value)) {
221
+ const items = value.slice(0, maxArrayItems).map((item) => redactValue(item, options, depth + 1));
222
+ if (value.length > maxArrayItems) {
223
+ items.push(`[+${value.length - maxArrayItems} more]`);
224
+ }
225
+ return items;
226
+ }
227
+
228
+ if (value instanceof Map) {
229
+ const out: Record<string, JsonValue> = {};
230
+ let count = 0;
231
+ for (const [key, item] of value) {
232
+ if (count >= maxKeys) break;
233
+ out[String(key)] = redactValue(item, options, depth + 1, String(key));
234
+ count++;
235
+ }
236
+ return out;
237
+ }
238
+
239
+ if (value instanceof Set) {
240
+ return redactValue([...value], options, depth + 1);
241
+ }
242
+
243
+ const record = value as Record<string, unknown>;
244
+ const out: Record<string, JsonValue> = {};
245
+ const keys = Object.keys(record);
246
+ for (const key of keys.slice(0, maxKeys)) {
247
+ const item = record[key];
248
+ if (isSecretKey(key)) {
249
+ out[key] = placeholder;
250
+ continue;
251
+ }
252
+ if (isSensitiveValueKey(key)) {
253
+ out[key] = placeholder;
254
+ continue;
255
+ }
256
+ out[key] = redactValue(item, options, depth + 1, key);
257
+ }
258
+ if (keys.length > maxKeys) {
259
+ out["[truncatedKeys]"] = keys.length - maxKeys;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** Redact a flat field bag (the `fields` member of a record). */
265
+ export function redactFields(
266
+ fields: Record<string, unknown> | undefined,
267
+ options: RedactionOptions = {},
268
+ ): Record<string, JsonValue> | undefined {
269
+ if (!fields) return undefined;
270
+ return redactValue(fields, options) as Record<string, JsonValue>;
271
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Sampling for the centralized log pipeline.
3
+ *
4
+ * Sampling protects the transport budget, not the producer: a chatty
5
+ * component must not be able to flood R2 (or the webhook server) with
6
+ * debug-level noise.
7
+ *
8
+ * Three independent gates:
9
+ * 1. minimum level - everything below the configured level is dropped
10
+ * 2. sample rate - debug records are kept deterministically per event
11
+ * 3. rate limit - a cap per source+event per minute
12
+ *
13
+ * Errors bypass the sample rate and the rate limit. Losing an error to
14
+ * save a request is the wrong trade.
15
+ */
16
+
17
+ import { LOG_LEVEL_SEVERITY, type LogLevel, type LogRecord } from "./types.js";
18
+
19
+ export interface SamplingPolicy {
20
+ /** Records below this level are dropped. Default "info". */
21
+ minLevel: LogLevel;
22
+ /** Fraction of debug records kept, 0..1. Default 0.1. */
23
+ sampleRate: number;
24
+ /** Max records per source+event per minute (errors exempt). Default 60. */
25
+ rateLimitPerMinute: number;
26
+ }
27
+
28
+ export const DEFAULT_SAMPLING_POLICY: SamplingPolicy = {
29
+ minLevel: "info",
30
+ sampleRate: 0.1,
31
+ rateLimitPerMinute: 60,
32
+ };
33
+
34
+ /**
35
+ * Stable hash so the same event keeps the same decision across restarts -
36
+ * a debug event is either always sampled or never, never randomly.
37
+ */
38
+ function stableHash(input: string): number {
39
+ let hash = 2166136261;
40
+ for (let i = 0; i < input.length; i++) {
41
+ hash ^= input.charCodeAt(i);
42
+ hash = Math.imul(hash, 16777619);
43
+ }
44
+ return (hash >>> 0) / 0xffffffff;
45
+ }
46
+
47
+ export class LogSampler {
48
+ private readonly policy: SamplingPolicy;
49
+ private readonly counters = new Map<string, { windowStart: number; count: number }>();
50
+
51
+ constructor(policy: Partial<SamplingPolicy> = {}) {
52
+ this.policy = { ...DEFAULT_SAMPLING_POLICY, ...policy };
53
+ }
54
+
55
+ getPolicy(): SamplingPolicy {
56
+ return { ...this.policy };
57
+ }
58
+
59
+ /** True when the record should be kept. */
60
+ shouldLog(record: LogRecord, now: number = Date.now()): boolean {
61
+ if (LOG_LEVEL_SEVERITY[record.level] < LOG_LEVEL_SEVERITY[this.policy.minLevel]) {
62
+ return false;
63
+ }
64
+
65
+ if (record.level === "error") return true;
66
+
67
+ if (record.level === "debug") {
68
+ const bucket = stableHash(`${record.source}:${record.event}`);
69
+ if (bucket >= this.policy.sampleRate) return false;
70
+ }
71
+
72
+ return this.allowByRateLimit(record, now);
73
+ }
74
+
75
+ private allowByRateLimit(record: LogRecord, now: number): boolean {
76
+ const limit = this.policy.rateLimitPerMinute;
77
+ if (limit <= 0) return true;
78
+
79
+ const key = `${record.source}:${record.event}`;
80
+ const windowStart = Math.floor(now / 60_000) * 60_000;
81
+ const entry = this.counters.get(key);
82
+
83
+ if (!entry || entry.windowStart !== windowStart) {
84
+ this.counters.set(key, { windowStart, count: 1 });
85
+ return true;
86
+ }
87
+
88
+ if (entry.count >= limit) return false;
89
+ entry.count++;
90
+ return true;
91
+ }
92
+
93
+ /** Forget the rate-limit counters (used by tests and by long sessions). */
94
+ reset(): void {
95
+ this.counters.clear();
96
+ }
97
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * The shipper owns the delivery decision.
3
+ *
4
+ * Producers call `log()`. The shipper redacts, samples, buffers, and then
5
+ * delivers a batch through the configured transports in priority order:
6
+ *
7
+ * R2 (durable archive) -> webhook (live) -> local spool (last resort)
8
+ *
9
+ * If every transport fails the batch is spooled to disk and retried on the
10
+ * next flush, so a temporary outage costs latency, not data.
11
+ */
12
+
13
+ import { hostname } from "node:os";
14
+ import { LogBuffer, type LogBufferOptions } from "./buffer.js";
15
+ import { redactFields, redactText, type RedactionOptions } from "./redact.js";
16
+ import { LogSampler, type SamplingPolicy } from "./sampler.js";
17
+ import { LogSpool } from "./spool.js";
18
+ import type { FlushStats, LogBatch, LogErrorInfo, LogInput, LogLevel, LogRecord, LogTransport } from "./types.js";
19
+
20
+ export interface LogShipperOptions {
21
+ /** Application name reported with every batch. */
22
+ app?: string;
23
+ /** Host name reported with every batch. */
24
+ host?: string;
25
+ /** Transports in priority order. */
26
+ transports?: LogTransport[];
27
+ /** Local spool used when every transport fails. */
28
+ spool?: LogSpool;
29
+ /** Buffer bounds. */
30
+ buffer?: LogBufferOptions;
31
+ /** Sampling policy. */
32
+ sampling?: Partial<SamplingPolicy>;
33
+ /** Redaction limits. */
34
+ redaction?: RedactionOptions;
35
+ /** Interval for the automatic flush timer. Default 15000 ms. */
36
+ flushIntervalMs?: number;
37
+ /** Clock injection (tests). */
38
+ now?: () => number;
39
+ /** Batch id factory (tests). */
40
+ createBatchId?: () => string;
41
+ }
42
+
43
+ function defaultBatchId(): string {
44
+ const stamp = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 15);
45
+ return `${stamp}-${Math.random().toString(36).slice(2, 8)}`;
46
+ }
47
+
48
+ function toErrorInfo(error: unknown): LogErrorInfo {
49
+ if (error instanceof Error) {
50
+ const info: LogErrorInfo = { name: error.name, message: error.message };
51
+ if (error.stack) info.stack = error.stack;
52
+ return info;
53
+ }
54
+ return { name: "Error", message: String(error) };
55
+ }
56
+
57
+ export class LogShipper {
58
+ private readonly app: string;
59
+ private readonly host: string;
60
+ private readonly transports: LogTransport[];
61
+ private readonly spool: LogSpool | undefined;
62
+ private readonly buffer: LogBuffer;
63
+ private readonly sampler: LogSampler;
64
+ private readonly redaction: RedactionOptions;
65
+ private readonly flushIntervalMs: number;
66
+ private readonly now: () => number;
67
+ private readonly createBatchId: () => string;
68
+ private timer: ReturnType<typeof setInterval> | undefined;
69
+ private flushing: Promise<void> | undefined;
70
+ private stats: FlushStats = {
71
+ shipped: 0,
72
+ spooled: 0,
73
+ dropped: 0,
74
+ sampled: 0,
75
+ errors: {},
76
+ };
77
+
78
+ constructor(options: LogShipperOptions = {}) {
79
+ this.app = options.app ?? "pi-harness";
80
+ this.host = options.host ?? hostname();
81
+ this.transports = options.transports ?? [];
82
+ this.spool = options.spool;
83
+ this.buffer = new LogBuffer(options.buffer);
84
+ this.sampler = new LogSampler(options.sampling);
85
+ this.redaction = options.redaction ?? {};
86
+ this.flushIntervalMs = options.flushIntervalMs ?? 15_000;
87
+ this.now = options.now ?? (() => Date.now());
88
+ this.createBatchId = options.createBatchId ?? defaultBatchId;
89
+ }
90
+
91
+ /** Record a log event. Never throws. */
92
+ log(input: LogInput): void {
93
+ try {
94
+ const record = this.buildRecord(input);
95
+ if (!this.sampler.shouldLog(record, this.now())) {
96
+ this.stats.sampled++;
97
+ return;
98
+ }
99
+ this.buffer.push(record);
100
+ if (this.buffer.size >= this.buffer.batchSize) {
101
+ void this.flush();
102
+ }
103
+ } catch {
104
+ // A logging failure must never break the caller.
105
+ }
106
+ }
107
+
108
+ /** Convenience wrappers around `log()`. */
109
+ debug(source: string, event: string, message?: string, fields?: Record<string, unknown>): void {
110
+ this.log({ level: "debug", source, event, message, fields });
111
+ }
112
+
113
+ info(source: string, event: string, message?: string, fields?: Record<string, unknown>): void {
114
+ this.log({ level: "info", source, event, message, fields });
115
+ }
116
+
117
+ warn(source: string, event: string, message?: string, fields?: Record<string, unknown>): void {
118
+ this.log({ level: "warn", source, event, message, fields });
119
+ }
120
+
121
+ error(
122
+ source: string,
123
+ event: string,
124
+ message?: string,
125
+ fields?: Record<string, unknown>,
126
+ error?: unknown,
127
+ ): void {
128
+ this.log({ level: "error", source, event, message, fields, error });
129
+ }
130
+
131
+ /** Deliver buffered records (and drain the spool). Never throws. */
132
+ async flush(): Promise<void> {
133
+ if (this.flushing) return this.flushing;
134
+ this.flushing = this.doFlush().finally(() => {
135
+ this.flushing = undefined;
136
+ });
137
+ return this.flushing;
138
+ }
139
+
140
+ /**
141
+ * Synchronous last resort used on process exit: whatever is still in
142
+ * memory is written to the local spool so it is not lost.
143
+ */
144
+ flushSync(): void {
145
+ try {
146
+ const records = this.buffer.peekAll();
147
+ if (records.length === 0) return;
148
+ this.buffer.clear();
149
+ if (!this.spool) return;
150
+ this.spool.append(this.buildBatch(records));
151
+ this.stats.spooled += records.length;
152
+ } catch {
153
+ // Nothing else we can do at exit time.
154
+ }
155
+ }
156
+
157
+ /** Start the periodic flush timer. */
158
+ start(): void {
159
+ if (this.timer) return;
160
+ this.timer = setInterval(() => {
161
+ void this.flush();
162
+ }, this.flushIntervalMs);
163
+ // Never keep the process alive just for log delivery.
164
+ this.timer.unref?.();
165
+ }
166
+
167
+ /** Stop the timer and flush once more. */
168
+ async stop(): Promise<void> {
169
+ if (this.timer) {
170
+ clearInterval(this.timer);
171
+ this.timer = undefined;
172
+ }
173
+ await this.flush();
174
+ }
175
+
176
+ getStats(): FlushStats {
177
+ return {
178
+ ...this.stats,
179
+ dropped: this.stats.dropped + this.buffer.dropped,
180
+ errors: { ...this.stats.errors },
181
+ };
182
+ }
183
+
184
+ private buildRecord(input: LogInput): LogRecord {
185
+ const record: LogRecord = {
186
+ ts: new Date(this.now()).toISOString(),
187
+ level: input.level,
188
+ source: input.source,
189
+ event: input.event,
190
+ };
191
+
192
+ if (input.message !== undefined) {
193
+ record.message = redactText(input.message, this.redaction.placeholder);
194
+ }
195
+ if (input.sessionId) record.sessionId = input.sessionId;
196
+ if (typeof input.durationMs === "number") record.durationMs = input.durationMs;
197
+
198
+ const fields = redactFields(input.fields, this.redaction);
199
+ if (fields && Object.keys(fields).length > 0) record.fields = fields;
200
+
201
+ if (input.error !== undefined) {
202
+ const info = toErrorInfo(input.error);
203
+ record.error = {
204
+ name: info.name,
205
+ message: redactText(info.message, this.redaction.placeholder),
206
+ ...(info.stack ? { stack: redactText(info.stack, this.redaction.placeholder) } : {}),
207
+ };
208
+ }
209
+
210
+ return record;
211
+ }
212
+
213
+ private buildBatch(records: LogRecord[]): LogBatch {
214
+ return {
215
+ batchId: this.createBatchId(),
216
+ app: this.app,
217
+ host: this.host,
218
+ sentAt: new Date(this.now()).toISOString(),
219
+ records,
220
+ };
221
+ }
222
+
223
+ private async doFlush(): Promise<void> {
224
+ try {
225
+ await this.drainSpool();
226
+
227
+ const records = this.buffer.takeBatch();
228
+ if (records.length === 0) return;
229
+
230
+ const batch = this.buildBatch(records);
231
+ const delivered = await this.deliver(batch);
232
+ if (delivered) {
233
+ this.stats.shipped += records.length;
234
+ return;
235
+ }
236
+
237
+ if (this.spool) {
238
+ this.spool.append(batch);
239
+ this.stats.spooled += records.length;
240
+ } else {
241
+ this.stats.dropped += records.length;
242
+ }
243
+ } catch {
244
+ // Never let a flush failure escape.
245
+ }
246
+ }
247
+
248
+ /** Try every transport in order. Returns true when one accepted the batch. */
249
+ private async deliver(batch: LogBatch): Promise<boolean> {
250
+ for (const transport of this.transports) {
251
+ try {
252
+ await transport.send(batch);
253
+ return true;
254
+ } catch (error) {
255
+ this.stats.errors[transport.name] =
256
+ error instanceof Error ? error.message : String(error);
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+
262
+ /** Re-send spooled files. Stops at the first failure. */
263
+ private async drainSpool(): Promise<void> {
264
+ if (!this.spool) return;
265
+ const files = this.spool.list();
266
+ for (const file of files) {
267
+ const records = this.spool.read(file);
268
+ if (records.length === 0) {
269
+ this.spool.remove(file);
270
+ continue;
271
+ }
272
+ const batch = this.buildBatch(records);
273
+ const delivered = await this.deliver(batch);
274
+ if (!delivered) return;
275
+ this.spool.remove(file);
276
+ this.stats.shipped += records.length;
277
+ }
278
+ }
279
+ }
280
+
281
+ export type { LogLevel };