pi-harness-runtime 1.1.396 → 1.1.397

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 (204) hide show
  1. package/AGENTS.md +54 -7
  2. package/package.json +1 -1
  3. package/packages/a2a-adapter/package.json +1 -1
  4. package/packages/agent-policy/package.json +1 -1
  5. package/packages/architecture-generator/package.json +1 -1
  6. package/packages/auth/package.json +1 -1
  7. package/packages/autonomous-refactor/package.json +1 -1
  8. package/packages/autonomous-runtime/package.json +1 -1
  9. package/packages/cache-strategy/package.json +1 -1
  10. package/packages/capability-registry/package.json +1 -1
  11. package/packages/checkpoint/package.json +1 -1
  12. package/packages/cli-plugin-sdk/package.json +1 -1
  13. package/packages/clipboard/package.json +1 -1
  14. package/packages/clipboard-plugin/package.json +1 -1
  15. package/packages/code-generation/package.json +1 -1
  16. package/packages/code-review/package.json +1 -1
  17. package/packages/codex-adapter/package.json +1 -1
  18. package/packages/codex-watcher/package.json +1 -1
  19. package/packages/config-capture/package.json +1 -1
  20. package/packages/connection-runtime/package.json +1 -1
  21. package/packages/context-compiler/package.json +1 -1
  22. package/packages/context-discovery/package.json +1 -1
  23. package/packages/context-manager/package.json +1 -1
  24. package/packages/contracts/package.json +1 -1
  25. package/packages/cookie-sanitizer/package.json +1 -1
  26. package/packages/cost-optimizer/package.json +1 -1
  27. package/packages/dependency-analyzer/package.json +1 -1
  28. package/packages/django-plugin/package.json +1 -1
  29. package/packages/doc-generator/package.json +1 -1
  30. package/packages/durable-review/package.json +1 -1
  31. package/packages/e2e-tester/package.json +1 -1
  32. package/packages/evaluation-engine/package.json +1 -1
  33. package/packages/evaluation-runner/package.json +1 -1
  34. package/packages/event-bus/package.json +1 -1
  35. package/packages/event-sink/package.json +1 -1
  36. package/packages/event-store/package.json +1 -1
  37. package/packages/experience-replay/package.json +1 -1
  38. package/packages/feedback-collector/package.json +1 -1
  39. package/packages/file-copy-helper/package.json +1 -1
  40. package/packages/framework-detector/package.json +1 -1
  41. package/packages/framework-plugin-sdk/package.json +1 -1
  42. package/packages/frappe-plugin/package.json +1 -1
  43. package/packages/generic-web-plugin/package.json +1 -1
  44. package/packages/health-monitor/package.json +1 -1
  45. package/packages/intent-analyzer/package.json +1 -1
  46. package/packages/jev-judge/package.json +1 -1
  47. package/packages/knowledge-graph/package.json +1 -1
  48. package/packages/knowledge-retrieval/package.json +1 -1
  49. package/packages/laravel-plugin/package.json +1 -1
  50. package/packages/learning-engine/package.json +1 -1
  51. package/packages/logging/README.md +41 -6
  52. package/packages/logging/dist/classification.d.ts +65 -0
  53. package/packages/logging/dist/classification.d.ts.map +1 -0
  54. package/packages/logging/dist/classification.js +155 -0
  55. package/packages/logging/dist/classification.js.map +1 -0
  56. package/packages/logging/dist/config.d.ts +9 -0
  57. package/packages/logging/dist/config.d.ts.map +1 -1
  58. package/packages/logging/dist/config.js +40 -3
  59. package/packages/logging/dist/config.js.map +1 -1
  60. package/packages/logging/dist/index.d.ts +9 -1
  61. package/packages/logging/dist/index.d.ts.map +1 -1
  62. package/packages/logging/dist/index.js +10 -1
  63. package/packages/logging/dist/index.js.map +1 -1
  64. package/packages/logging/dist/ingest/schema.d.ts +60 -0
  65. package/packages/logging/dist/ingest/schema.d.ts.map +1 -0
  66. package/packages/logging/dist/ingest/schema.js +245 -0
  67. package/packages/logging/dist/ingest/schema.js.map +1 -0
  68. package/packages/logging/dist/installation.d.ts +32 -0
  69. package/packages/logging/dist/installation.d.ts.map +1 -0
  70. package/packages/logging/dist/installation.js +64 -0
  71. package/packages/logging/dist/installation.js.map +1 -0
  72. package/packages/logging/dist/sensitive-keys.d.ts +12 -0
  73. package/packages/logging/dist/sensitive-keys.d.ts.map +1 -0
  74. package/packages/logging/dist/sensitive-keys.js +39 -0
  75. package/packages/logging/dist/sensitive-keys.js.map +1 -0
  76. package/packages/logging/dist/server/credentials.d.ts +85 -0
  77. package/packages/logging/dist/server/credentials.d.ts.map +1 -0
  78. package/packages/logging/dist/server/credentials.js +147 -0
  79. package/packages/logging/dist/server/credentials.js.map +1 -0
  80. package/packages/logging/dist/server/http.d.ts +36 -0
  81. package/packages/logging/dist/server/http.d.ts.map +1 -0
  82. package/packages/logging/dist/server/http.js +139 -0
  83. package/packages/logging/dist/server/http.js.map +1 -0
  84. package/packages/logging/dist/server/index-store.d.ts +95 -0
  85. package/packages/logging/dist/server/index-store.d.ts.map +1 -0
  86. package/packages/logging/dist/server/index-store.js +190 -0
  87. package/packages/logging/dist/server/index-store.js.map +1 -0
  88. package/packages/logging/dist/server/index.d.ts +22 -0
  89. package/packages/logging/dist/server/index.d.ts.map +1 -0
  90. package/packages/logging/dist/server/index.js +15 -0
  91. package/packages/logging/dist/server/index.js.map +1 -0
  92. package/packages/logging/dist/server/keys.d.ts +22 -0
  93. package/packages/logging/dist/server/keys.d.ts.map +1 -0
  94. package/packages/logging/dist/server/keys.js +24 -0
  95. package/packages/logging/dist/server/keys.js.map +1 -0
  96. package/packages/logging/dist/server/outbox-worker.d.ts +55 -0
  97. package/packages/logging/dist/server/outbox-worker.d.ts.map +1 -0
  98. package/packages/logging/dist/server/outbox-worker.js +95 -0
  99. package/packages/logging/dist/server/outbox-worker.js.map +1 -0
  100. package/packages/logging/dist/server/outbox.d.ts +112 -0
  101. package/packages/logging/dist/server/outbox.d.ts.map +1 -0
  102. package/packages/logging/dist/server/outbox.js +247 -0
  103. package/packages/logging/dist/server/outbox.js.map +1 -0
  104. package/packages/logging/dist/server/service.d.ts +83 -0
  105. package/packages/logging/dist/server/service.d.ts.map +1 -0
  106. package/packages/logging/dist/server/service.js +144 -0
  107. package/packages/logging/dist/server/service.js.map +1 -0
  108. package/packages/logging/dist/shipper.d.ts +20 -4
  109. package/packages/logging/dist/shipper.d.ts.map +1 -1
  110. package/packages/logging/dist/shipper.js +31 -5
  111. package/packages/logging/dist/shipper.js.map +1 -1
  112. package/packages/logging/dist/singleton.d.ts.map +1 -1
  113. package/packages/logging/dist/singleton.js +3 -0
  114. package/packages/logging/dist/singleton.js.map +1 -1
  115. package/packages/logging/dist/types.d.ts +28 -1
  116. package/packages/logging/dist/types.d.ts.map +1 -1
  117. package/packages/logging/dist/types.js +8 -0
  118. package/packages/logging/dist/types.js.map +1 -1
  119. package/packages/logging/package.json +1 -1
  120. package/packages/logging/src/classification.ts +175 -0
  121. package/packages/logging/src/config.ts +60 -3
  122. package/packages/logging/src/index.ts +45 -1
  123. package/packages/logging/src/ingest/schema.ts +295 -0
  124. package/packages/logging/src/installation.ts +76 -0
  125. package/packages/logging/src/sensitive-keys.ts +40 -0
  126. package/packages/logging/src/server/credentials.ts +198 -0
  127. package/packages/logging/src/server/http.ts +170 -0
  128. package/packages/logging/src/server/index-store.ts +242 -0
  129. package/packages/logging/src/server/index.ts +56 -0
  130. package/packages/logging/src/server/keys.ts +32 -0
  131. package/packages/logging/src/server/outbox-worker.ts +124 -0
  132. package/packages/logging/src/server/outbox.ts +316 -0
  133. package/packages/logging/src/server/service.ts +196 -0
  134. package/packages/logging/src/shipper.ts +54 -8
  135. package/packages/logging/src/singleton.ts +3 -0
  136. package/packages/logging/src/types.ts +29 -1
  137. package/packages/logging/test/classification.test.ts +137 -0
  138. package/packages/logging/test/identity-transport.test.ts +197 -0
  139. package/packages/logging/test/ingest-schema.test.ts +184 -0
  140. package/packages/logging/test/integration.test.ts +9 -4
  141. package/packages/logging/test/log-http.test.ts +246 -0
  142. package/packages/logging/test/server.test.ts +444 -0
  143. package/packages/logging/test/shipper.test.ts +11 -11
  144. package/packages/logging/test/spool.test.ts +4 -1
  145. package/packages/mcp-adapter/package.json +1 -1
  146. package/packages/memory-engine/package.json +1 -1
  147. package/packages/milestone-manager/package.json +1 -1
  148. package/packages/model-registry/package.json +1 -1
  149. package/packages/nextjs-plugin/package.json +1 -1
  150. package/packages/notification/package.json +1 -1
  151. package/packages/observability/package.json +1 -1
  152. package/packages/okf-indexer/package.json +1 -1
  153. package/packages/paperclip-integration/package.json +1 -1
  154. package/packages/performance-optimizer/package.json +1 -1
  155. package/packages/permission-mode/package.json +1 -1
  156. package/packages/privilege-broker/package.json +1 -1
  157. package/packages/project-analyzer/package.json +1 -1
  158. package/packages/project-bootstrap/package.json +1 -1
  159. package/packages/projection-engine/package.json +1 -1
  160. package/packages/prompt-compiler/package.json +1 -1
  161. package/packages/prompt-versioning/package.json +1 -1
  162. package/packages/provider-adapter-sdk/package.json +1 -1
  163. package/packages/provider-router/package.json +1 -1
  164. package/packages/provider-selector/package.json +1 -1
  165. package/packages/providers/package.json +1 -1
  166. package/packages/qdrant-skills/package.json +1 -1
  167. package/packages/quota-manager/package.json +1 -1
  168. package/packages/rate-limiter/package.json +1 -1
  169. package/packages/react-vite-plugin/package.json +1 -1
  170. package/packages/relay/package.json +1 -1
  171. package/packages/release-manager/package.json +1 -1
  172. package/packages/requirement-compiler/package.json +1 -1
  173. package/packages/runtime/package.json +1 -1
  174. package/packages/scheduler/package.json +1 -1
  175. package/packages/scheduler-adapter/package.json +1 -1
  176. package/packages/session/package.json +1 -1
  177. package/packages/session-api/package.json +1 -1
  178. package/packages/session-export/package.json +1 -1
  179. package/packages/shared-context/package.json +1 -1
  180. package/packages/skill-mcp-client/package.json +1 -1
  181. package/packages/skill-registry/package.json +1 -1
  182. package/packages/skills/package.json +1 -1
  183. package/packages/sprint-planner/package.json +1 -1
  184. package/packages/subscription-engine/package.json +1 -1
  185. package/packages/task-compiler/package.json +1 -1
  186. package/packages/telegram/package.json +1 -1
  187. package/packages/tencentdb-memory/package.json +1 -1
  188. package/packages/tencentdb-sync/package.json +1 -1
  189. package/packages/test-data-generator/package.json +1 -1
  190. package/packages/test-generator/package.json +1 -1
  191. package/packages/todo-bd-sync/package.json +1 -1
  192. package/packages/token-estimation/package.json +1 -1
  193. package/packages/token-optimizer/package.json +1 -1
  194. package/packages/trajectory/package.json +1 -1
  195. package/packages/tui/package.json +1 -1
  196. package/packages/types/package.json +1 -1
  197. package/packages/voice-api/package.json +1 -1
  198. package/packages/webmcp-tester/package.json +1 -1
  199. package/packages/wiki/package.json +1 -1
  200. package/packages/workflow-events/package.json +1 -1
  201. package/packages/workspace-scanner/package.json +1 -1
  202. package/packages/worktree/package.json +1 -1
  203. package/packages/write-review/package.json +1 -1
  204. package/scripts/unified-webhook-server.ts +86 -81
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Durable server-side outbox.
3
+ *
4
+ * The ingest endpoint must not tell a client "accepted" before the batch
5
+ * survived a crash. The outbox is the durable hand-off point between the
6
+ * HTTP request and the R2 upload:
7
+ *
8
+ * request -> validate -> enqueue (durable) -> 202
9
+ * |
10
+ * v
11
+ * worker -> upload -> done
12
+ *
13
+ * The queue is a directory of JSON files, one per accepted batch:
14
+ *
15
+ * <dir>/pending/<key>.json the batch payload
16
+ * <dir>/pending/<key>.meta.json attempts, next attempt, last error
17
+ * <dir>/done/<key>.json uploaded batches (pruned after a while)
18
+ *
19
+ * A plain file queue is used on purpose: the server runs under Bun, which
20
+ * has no `node:sqlite`, and the queue stays inspectable with `ls`/`jq`.
21
+ *
22
+ * The key is derived from (tenant, installation, batch), so the same batch
23
+ * is enqueued only once - the ingest endpoint is idempotent.
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ renameSync,
33
+ rmSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { join } from "node:path";
38
+ import type { LogRecord } from "../types.js";
39
+
40
+ export interface OutboxBatch {
41
+ tenantId: string;
42
+ installationId: string;
43
+ batchId: string;
44
+ app: string;
45
+ appVersion?: string;
46
+ hostLabel?: string;
47
+ sessionId?: string;
48
+ sentAt: string;
49
+ records: LogRecord[];
50
+ }
51
+
52
+ export interface OutboxEntry {
53
+ /** File name key of the batch. */
54
+ key: string;
55
+ /** Absolute path of the payload file. */
56
+ path: string;
57
+ batch: OutboxBatch;
58
+ attempts: number;
59
+ nextAttemptAt: number;
60
+ lastError?: string;
61
+ createdAt: number;
62
+ }
63
+
64
+ export interface OutboxStats {
65
+ /** Batches waiting for an upload. */
66
+ pending: number;
67
+ /** Age of the oldest pending batch in milliseconds. */
68
+ oldestPendingAgeMs: number;
69
+ /** Batches that failed at least once. */
70
+ failures: number;
71
+ /** Last upload error (message only, never a payload). */
72
+ lastError?: string;
73
+ }
74
+
75
+ export interface EnqueueResult {
76
+ accepted: boolean;
77
+ /** True when the same batch was already queued (idempotent replay). */
78
+ duplicate: boolean;
79
+ }
80
+
81
+ export interface LogOutboxOptions {
82
+ /** Directory of the queue. */
83
+ dir: string;
84
+ /** How long uploaded batches are kept, in milliseconds. Default 7 days. */
85
+ retentionMs?: number;
86
+ }
87
+
88
+ /** Make a value safe to use inside a file name. */
89
+ export function safeSegment(value: string, maxLength = 48): string {
90
+ const cleaned = value
91
+ .replace(/\.\./g, "_")
92
+ .replace(/[^A-Za-z0-9._-]/g, "_")
93
+ .slice(0, maxLength)
94
+ .replace(/^\.+/, "_");
95
+ return cleaned.length > 0 ? cleaned : "unknown";
96
+ }
97
+
98
+ /**
99
+ * Build the queue key of a batch.
100
+ *
101
+ * The key is unique per (tenant, installation, batch) and contains no
102
+ * user controlled path segment.
103
+ */
104
+ export function outboxKey(batch: {
105
+ tenantId: string;
106
+ installationId: string;
107
+ batchId: string;
108
+ }): string {
109
+ const digest = createHash("sha256")
110
+ .update(`${batch.tenantId}|${batch.installationId}|${batch.batchId}`, "utf8")
111
+ .digest("hex")
112
+ .slice(0, 12);
113
+ return `${safeSegment(batch.tenantId)}--${safeSegment(batch.batchId)}--${digest}`;
114
+ }
115
+
116
+ export class LogOutbox {
117
+ private readonly dir: string;
118
+ private readonly pendingDir: string;
119
+ private readonly doneDir: string;
120
+ private readonly retentionMs: number;
121
+
122
+ constructor(options: LogOutboxOptions) {
123
+ this.dir = options.dir;
124
+ this.pendingDir = join(options.dir, "pending");
125
+ this.doneDir = join(options.dir, "done");
126
+ this.retentionMs = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
127
+ try {
128
+ mkdirSync(this.pendingDir, { recursive: true });
129
+ mkdirSync(this.doneDir, { recursive: true });
130
+ } catch {
131
+ // The enqueue call reports the failure with a retryable error.
132
+ }
133
+ }
134
+
135
+ /** Directory of the queue. */
136
+ get directory(): string {
137
+ return this.dir;
138
+ }
139
+
140
+ /**
141
+ * Store a batch durably.
142
+ *
143
+ * The write is atomic (write to a temp file, then rename), so a crash
144
+ * can never leave a half written batch behind.
145
+ */
146
+ enqueue(batch: OutboxBatch): EnqueueResult {
147
+ const key = outboxKey(batch);
148
+ const payloadPath = join(this.pendingDir, `${key}.json`);
149
+
150
+ if (existsSync(payloadPath) || existsSync(join(this.doneDir, `${key}.json`))) {
151
+ return { accepted: true, duplicate: true };
152
+ }
153
+
154
+ const tempPath = `${payloadPath}.tmp`;
155
+ try {
156
+ writeFileSync(tempPath, `${JSON.stringify(batch)}\n`, { mode: 0o600 });
157
+ renameSync(tempPath, payloadPath);
158
+ } catch (error) {
159
+ try {
160
+ rmSync(tempPath, { force: true });
161
+ } catch {
162
+ // Nothing else to do.
163
+ }
164
+ throw error;
165
+ }
166
+
167
+ this.writeMeta(key, {
168
+ attempts: 0,
169
+ nextAttemptAt: Date.now(),
170
+ createdAt: Date.now(),
171
+ });
172
+
173
+ return { accepted: true, duplicate: false };
174
+ }
175
+
176
+ /** The batches waiting for an upload, oldest first. */
177
+ listPending(limit = 100): OutboxEntry[] {
178
+ const entries: OutboxEntry[] = [];
179
+ let names: string[];
180
+ try {
181
+ names = readdirSync(this.pendingDir);
182
+ } catch {
183
+ return entries;
184
+ }
185
+
186
+ for (const name of names) {
187
+ if (!name.endsWith(".json") || name.endsWith(".meta.json")) continue;
188
+ const entry = this.readEntry(join(this.pendingDir, name));
189
+ if (entry) entries.push(entry);
190
+ }
191
+
192
+ entries.sort((a, b) => a.createdAt - b.createdAt);
193
+ return entries.slice(0, limit);
194
+ }
195
+
196
+ /** Mark a batch as uploaded and move it out of the pending directory. */
197
+ markUploaded(entry: OutboxEntry): void {
198
+ try {
199
+ renameSync(entry.path, join(this.doneDir, `${entry.key}.json`));
200
+ } catch {
201
+ // The file may have been moved already; nothing to do.
202
+ }
203
+ try {
204
+ rmSync(join(this.pendingDir, `${entry.key}.meta.json`), { force: true });
205
+ } catch {
206
+ // Ignore.
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Record a failed upload and schedule the next attempt.
212
+ */
213
+ markFailed(entry: OutboxEntry, error: string, nextAttemptAt: number): void {
214
+ this.writeMeta(entry.key, {
215
+ attempts: entry.attempts + 1,
216
+ nextAttemptAt,
217
+ lastError: error,
218
+ createdAt: entry.createdAt,
219
+ });
220
+ }
221
+
222
+ /** Queue depth, oldest pending age and failure information. */
223
+ stats(now = Date.now()): OutboxStats {
224
+ const entries = this.listPending(Number.MAX_SAFE_INTEGER);
225
+ let oldestPendingAgeMs = 0;
226
+ let failures = 0;
227
+ let lastError: string | undefined;
228
+
229
+ for (const entry of entries) {
230
+ const age = now - entry.createdAt;
231
+ if (oldestPendingAgeMs === 0 || age > oldestPendingAgeMs) oldestPendingAgeMs = age;
232
+ if (entry.attempts > 0) failures++;
233
+ if (entry.lastError) lastError = entry.lastError;
234
+ }
235
+
236
+ return { pending: entries.length, oldestPendingAgeMs, failures, lastError };
237
+ }
238
+
239
+ /** Delete uploaded batches that are older than the retention period. */
240
+ prune(now = Date.now()): number {
241
+ let removed = 0;
242
+ let names: string[];
243
+ try {
244
+ names = readdirSync(this.doneDir);
245
+ } catch {
246
+ return 0;
247
+ }
248
+ for (const name of names) {
249
+ const path = join(this.doneDir, name);
250
+ try {
251
+ const age = now - statSync(path).mtimeMs;
252
+ if (age > this.retentionMs) {
253
+ rmSync(path, { force: true });
254
+ removed++;
255
+ }
256
+ } catch {
257
+ // Ignore a file that vanished.
258
+ }
259
+ }
260
+ return removed;
261
+ }
262
+
263
+ private writeMeta(
264
+ key: string,
265
+ meta: { attempts: number; nextAttemptAt: number; createdAt: number; lastError?: string },
266
+ ): void {
267
+ const path = join(this.pendingDir, `${key}.meta.json`);
268
+ const tempPath = `${path}.tmp`;
269
+ try {
270
+ writeFileSync(tempPath, `${JSON.stringify(meta)}\n`, { mode: 0o600 });
271
+ renameSync(tempPath, path);
272
+ } catch {
273
+ // The meta file is an optimisation: without it the batch is still
274
+ // retried, only without the backoff hint.
275
+ }
276
+ }
277
+
278
+ private readEntry(path: string): OutboxEntry | null {
279
+ const key = path.slice(path.lastIndexOf("/") + 1).replace(/\.json$/, "");
280
+ try {
281
+ const batch = JSON.parse(readFileSync(path, "utf8")) as OutboxBatch;
282
+ let meta: { attempts: number; nextAttemptAt: number; createdAt: number; lastError?: string } = {
283
+ attempts: 0,
284
+ nextAttemptAt: 0,
285
+ createdAt: 0,
286
+ };
287
+ try {
288
+ const metaPath = join(this.pendingDir, `${key}.meta.json`);
289
+ if (existsSync(metaPath)) {
290
+ meta = { ...meta, ...(JSON.parse(readFileSync(metaPath, "utf8")) as typeof meta) };
291
+ }
292
+ } catch {
293
+ // Fall back to the defaults.
294
+ }
295
+ let createdAt = meta.createdAt;
296
+ if (!createdAt) {
297
+ try {
298
+ createdAt = statSync(path).mtimeMs;
299
+ } catch {
300
+ createdAt = Date.now();
301
+ }
302
+ }
303
+ return {
304
+ key,
305
+ path,
306
+ batch,
307
+ attempts: meta.attempts ?? 0,
308
+ nextAttemptAt: meta.nextAttemptAt ?? 0,
309
+ lastError: meta.lastError,
310
+ createdAt,
311
+ };
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The log ingest service.
3
+ *
4
+ * The HTTP handler is a thin adapter around this class, so the whole ingest
5
+ * and query policy is testable without a socket:
6
+ *
7
+ * - authentication happens BEFORE the body is parsed,
8
+ * - the tenant comes from the credential, never from the request body,
9
+ * - a batch is only acknowledged after it is durably queued,
10
+ * - a query is always scoped to the tenant of the caller.
11
+ */
12
+
13
+ import { validateBatch, type BatchValidationLimits } from "../ingest/schema.js";
14
+ import {
15
+ LOG_READ_SCOPE,
16
+ LOG_WRITE_SCOPE,
17
+ type AuthenticatedCredential,
18
+ type LogCredentialStore,
19
+ } from "./credentials.js";
20
+ import { serverObjectKey } from "./keys.js";
21
+ import type { LogIndexStore, LogQueryParams, LogQueryResult } from "./index-store.js";
22
+ import type { LogOutbox, OutboxBatch } from "./outbox.js";
23
+
24
+ export interface IngestSuccess {
25
+ accepted: number;
26
+ batchId: string;
27
+ duplicate: boolean;
28
+ tenantId: string;
29
+ queued: true;
30
+ }
31
+
32
+ export interface IngestFailure {
33
+ error: string;
34
+ code: string;
35
+ }
36
+
37
+ export interface IngestResult {
38
+ status: number;
39
+ body: IngestSuccess | IngestFailure;
40
+ }
41
+
42
+ export interface QuerySuccess {
43
+ entries: LogQueryResult["entries"];
44
+ nextCursor?: string;
45
+ hasMore: boolean;
46
+ scannedFiles: number;
47
+ truncated: boolean;
48
+ tenantId: string;
49
+ }
50
+
51
+ export interface LogIngestServiceOptions {
52
+ credentials: LogCredentialStore;
53
+ outbox: LogOutbox;
54
+ index: LogIndexStore;
55
+ /** Validation limits for an inbound batch. */
56
+ limits?: Partial<BatchValidationLimits>;
57
+ }
58
+
59
+ export class LogIngestService {
60
+ private readonly credentials: LogCredentialStore;
61
+ private readonly outbox: LogOutbox;
62
+ private readonly index: LogIndexStore;
63
+ private readonly limits: Partial<BatchValidationLimits>;
64
+
65
+ constructor(options: LogIngestServiceOptions) {
66
+ this.credentials = options.credentials;
67
+ this.outbox = options.outbox;
68
+ this.index = options.index;
69
+ this.limits = options.limits ?? {};
70
+ }
71
+
72
+ /** True when the ingest endpoint has at least one usable credential. */
73
+ get configured(): boolean {
74
+ return this.credentials.configured;
75
+ }
76
+
77
+ /**
78
+ * Authenticate a presented bearer token.
79
+ *
80
+ * The HTTP layer uses it to reject a request before the body is read.
81
+ */
82
+ authenticate(token: string | undefined): AuthenticatedCredential | null {
83
+ return this.credentials.authenticate(token);
84
+ }
85
+
86
+ /**
87
+ * Accept a batch.
88
+ *
89
+ * The status codes matter:
90
+ * 503 - no credential is configured (fail closed) or the queue is
91
+ * unavailable. The client keeps the batch in its spool.
92
+ * 401 - the credential is missing or unknown.
93
+ * 403 - the credential may not write.
94
+ * 400 - the batch does not match the schema.
95
+ * 202 - the batch is durably queued.
96
+ */
97
+ ingest(body: unknown, bearerToken: string | undefined): IngestResult {
98
+ if (!this.credentials.configured) {
99
+ return {
100
+ status: 503,
101
+ body: { error: "log ingest is not configured", code: "ingest_unconfigured" },
102
+ };
103
+ }
104
+
105
+ const auth = this.credentials.authenticate(bearerToken);
106
+ if (!auth) {
107
+ return { status: 401, body: { error: "unauthorized", code: "unauthorized" } };
108
+ }
109
+ if (!auth.scopes.includes(LOG_WRITE_SCOPE)) {
110
+ return { status: 403, body: { error: "forbidden", code: "forbidden" } };
111
+ }
112
+
113
+ const validation = validateBatch(body, { limits: this.limits });
114
+ if (!validation.ok) {
115
+ return {
116
+ status: 400,
117
+ body: { error: validation.error.message, code: validation.error.code },
118
+ };
119
+ }
120
+
121
+ const incoming = validation.batch;
122
+ const batch: OutboxBatch = {
123
+ tenantId: auth.tenantId,
124
+ installationId: incoming.installationId,
125
+ batchId: incoming.batchId,
126
+ app: incoming.app,
127
+ sentAt: incoming.sentAt,
128
+ records: incoming.records,
129
+ };
130
+ if (incoming.appVersion) batch.appVersion = incoming.appVersion;
131
+ if (incoming.hostLabel) batch.hostLabel = incoming.hostLabel;
132
+ if (incoming.sessionId) batch.sessionId = incoming.sessionId;
133
+
134
+ try {
135
+ const result = this.outbox.enqueue(batch);
136
+ // The metadata index is best effort: a failing index never loses
137
+ // the batch itself.
138
+ this.index.append(batch, serverObjectKey(batch));
139
+ return {
140
+ status: 202,
141
+ body: {
142
+ accepted: batch.records.length,
143
+ batchId: batch.batchId,
144
+ duplicate: result.duplicate,
145
+ tenantId: batch.tenantId,
146
+ queued: true,
147
+ },
148
+ };
149
+ } catch (error) {
150
+ return {
151
+ status: 503,
152
+ body: {
153
+ error: `log queue unavailable: ${error instanceof Error ? error.message : String(error)}`,
154
+ code: "queue_unavailable",
155
+ },
156
+ };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Query the metadata index for the tenant of the caller.
162
+ *
163
+ * The `tenantId` of the request is ignored on purpose - only the
164
+ * credential decides whose records are visible.
165
+ */
166
+ query(params: Omit<LogQueryParams, "tenantId">, bearerToken: string | undefined): {
167
+ status: number;
168
+ body: QuerySuccess | IngestFailure;
169
+ } {
170
+ if (!this.credentials.configured) {
171
+ return {
172
+ status: 503,
173
+ body: { error: "log ingest is not configured", code: "ingest_unconfigured" },
174
+ };
175
+ }
176
+
177
+ const auth = this.credentials.authenticate(bearerToken);
178
+ if (!auth) {
179
+ return { status: 401, body: { error: "unauthorized", code: "unauthorized" } };
180
+ }
181
+ if (!auth.scopes.includes(LOG_READ_SCOPE)) {
182
+ return { status: 403, body: { error: "forbidden", code: "forbidden" } };
183
+ }
184
+
185
+ const result = this.index.query({ ...params, tenantId: auth.tenantId });
186
+ const body: QuerySuccess = {
187
+ entries: result.entries,
188
+ hasMore: result.hasMore,
189
+ scannedFiles: result.scannedFiles,
190
+ truncated: result.truncated,
191
+ tenantId: auth.tenantId,
192
+ };
193
+ if (result.nextCursor) body.nextCursor = result.nextCursor;
194
+ return { status: 200, body };
195
+ }
196
+ }
@@ -12,16 +12,39 @@
12
12
 
13
13
  import { hostname } from "node:os";
14
14
  import { LogBuffer, type LogBufferOptions } from "./buffer.js";
15
+ import {
16
+ DEFAULT_CLASSIFICATION_POLICY,
17
+ prepareRemoteRecord,
18
+ type ClassificationPolicy,
19
+ } from "./classification.js";
15
20
  import { redactFields, redactText, type RedactionOptions } from "./redact.js";
16
21
  import { LogSampler, type SamplingPolicy } from "./sampler.js";
17
22
  import { LogSpool } from "./spool.js";
18
- import type { FlushStats, LogBatch, LogErrorInfo, LogInput, LogLevel, LogRecord, LogTransport } from "./types.js";
23
+ import {
24
+ LOG_SCHEMA_VERSION,
25
+ type FlushStats,
26
+ type LogBatch,
27
+ type LogErrorInfo,
28
+ type LogInput,
29
+ type LogLevel,
30
+ type LogRecord,
31
+ type LogTransport,
32
+ } from "./types.js";
19
33
 
20
34
  export interface LogShipperOptions {
21
35
  /** Application name reported with every batch. */
22
36
  app?: string;
23
- /** Host name reported with every batch. */
24
- host?: string;
37
+ /** Version of the emitting application. */
38
+ appVersion?: string;
39
+ /** Stable id of this installation. */
40
+ installationId?: string;
41
+ /**
42
+ * Optional display label for the machine (never an identity).
43
+ * Defaults to the hostname, which is display metadata only.
44
+ */
45
+ hostLabel?: string;
46
+ /** Opaque session id attached to the batch when the records agree. */
47
+ sessionId?: string;
25
48
  /** Transports in priority order. */
26
49
  transports?: LogTransport[];
27
50
  /** Local spool used when every transport fails. */
@@ -32,6 +55,8 @@ export interface LogShipperOptions {
32
55
  sampling?: Partial<SamplingPolicy>;
33
56
  /** Redaction limits. */
34
57
  redaction?: RedactionOptions;
58
+ /** What may leave the machine. */
59
+ classification?: ClassificationPolicy;
35
60
  /** Interval for the automatic flush timer. Default 15000 ms. */
36
61
  flushIntervalMs?: number;
37
62
  /** Clock injection (tests). */
@@ -56,12 +81,16 @@ function toErrorInfo(error: unknown): LogErrorInfo {
56
81
 
57
82
  export class LogShipper {
58
83
  private readonly app: string;
59
- private readonly host: string;
84
+ private readonly appVersion: string | undefined;
85
+ private readonly installationId: string;
86
+ private readonly hostLabel: string | undefined;
87
+ private readonly sessionId: string | undefined;
60
88
  private readonly transports: LogTransport[];
61
89
  private readonly spool: LogSpool | undefined;
62
90
  private readonly buffer: LogBuffer;
63
91
  private readonly sampler: LogSampler;
64
92
  private readonly redaction: RedactionOptions;
93
+ private readonly classification: ClassificationPolicy;
65
94
  private readonly flushIntervalMs: number;
66
95
  private readonly now: () => number;
67
96
  private readonly createBatchId: () => string;
@@ -72,17 +101,22 @@ export class LogShipper {
72
101
  spooled: 0,
73
102
  dropped: 0,
74
103
  sampled: 0,
104
+ localOnly: 0,
75
105
  errors: {},
76
106
  };
77
107
 
78
108
  constructor(options: LogShipperOptions = {}) {
79
109
  this.app = options.app ?? "pi-harness";
80
- this.host = options.host ?? hostname();
110
+ this.appVersion = options.appVersion;
111
+ this.installationId = options.installationId ?? "unknown";
112
+ this.hostLabel = options.hostLabel ?? hostname();
113
+ this.sessionId = options.sessionId;
81
114
  this.transports = options.transports ?? [];
82
115
  this.spool = options.spool;
83
116
  this.buffer = new LogBuffer(options.buffer);
84
117
  this.sampler = new LogSampler(options.sampling);
85
118
  this.redaction = options.redaction ?? {};
119
+ this.classification = options.classification ?? DEFAULT_CLASSIFICATION_POLICY;
86
120
  this.flushIntervalMs = options.flushIntervalMs ?? 15_000;
87
121
  this.now = options.now ?? (() => Date.now());
88
122
  this.createBatchId = options.createBatchId ?? defaultBatchId;
@@ -96,7 +130,13 @@ export class LogShipper {
96
130
  this.stats.sampled++;
97
131
  return;
98
132
  }
99
- this.buffer.push(record);
133
+ // What must stay on the machine never enters the shipping path.
134
+ const remote = prepareRemoteRecord(record, this.classification);
135
+ if (!remote) {
136
+ this.stats.localOnly++;
137
+ return;
138
+ }
139
+ this.buffer.push(remote);
100
140
  if (this.buffer.size >= this.buffer.batchSize) {
101
141
  void this.flush();
102
142
  }
@@ -211,13 +251,19 @@ export class LogShipper {
211
251
  }
212
252
 
213
253
  private buildBatch(records: LogRecord[]): LogBatch {
214
- return {
254
+ const sessionId = this.sessionId ?? records.find((record) => record.sessionId)?.sessionId;
255
+ const batch: LogBatch = {
256
+ schemaVersion: LOG_SCHEMA_VERSION,
215
257
  batchId: this.createBatchId(),
216
258
  app: this.app,
217
- host: this.host,
259
+ installationId: this.installationId,
218
260
  sentAt: new Date(this.now()).toISOString(),
219
261
  records,
220
262
  };
263
+ if (this.appVersion) batch.appVersion = this.appVersion;
264
+ if (this.hostLabel) batch.hostLabel = this.hostLabel;
265
+ if (sessionId) batch.sessionId = sessionId;
266
+ return batch;
221
267
  }
222
268
 
223
269
  private async doFlush(): Promise<void> {
@@ -46,10 +46,13 @@ export function initCentralLogging(
46
46
  const spool = resolved.spoolDir ? new LogSpool({ dir: resolved.spoolDir }) : undefined;
47
47
  shipper = new LogShipper({
48
48
  app: resolved.app,
49
+ appVersion: resolved.appVersion,
50
+ installationId: resolved.installationId,
49
51
  transports: resolved.transports,
50
52
  spool,
51
53
  buffer: resolved.buffer,
52
54
  sampling: resolved.sampling,
55
+ classification: resolved.classification,
53
56
  flushIntervalMs: resolved.flushIntervalMs,
54
57
  });
55
58
  shipper.start();
@@ -12,6 +12,15 @@
12
12
 
13
13
  export type LogLevel = "debug" | "info" | "warn" | "error";
14
14
 
15
+ /**
16
+ * Schema version of the batch/record contract.
17
+ *
18
+ * v2 adds the installation identity (`installationId`), the app version and
19
+ * a `schemaVersion` marker, so a server can tell an old client from a new
20
+ * one instead of guessing.
21
+ */
22
+ export const LOG_SCHEMA_VERSION = 2;
23
+
15
24
  /** Severity order used by the sampler and the buffer drop policy. */
16
25
  export const LOG_LEVEL_SEVERITY: Record<LogLevel, number> = {
17
26
  debug: 10,
@@ -58,9 +67,26 @@ export interface LogInput {
58
67
 
59
68
  /** The transport unit: a batch of records shipped together. */
60
69
  export interface LogBatch {
70
+ /** Contract version, see `LOG_SCHEMA_VERSION`. */
71
+ schemaVersion: number;
61
72
  batchId: string;
73
+ /** Application name, e.g. "pi-harness". */
62
74
  app: string;
63
- host: string;
75
+ /** Version of the emitting application. */
76
+ appVersion?: string;
77
+ /**
78
+ * Stable random id of this installation, created once per local install.
79
+ *
80
+ * It is the only machine identity in the protocol. It is never derived
81
+ * from the hostname and it is not an identity on the server either - the
82
+ * tenant comes from the authenticated credential.
83
+ */
84
+ installationId: string;
85
+ /** Optional user-editable display label (never an identity). */
86
+ hostLabel?: string;
87
+ /** Opaque session id, when the producer knows one. */
88
+ sessionId?: string;
89
+ /** ISO-8601 timestamp of the send. */
64
90
  sentAt: string;
65
91
  records: LogRecord[];
66
92
  }
@@ -80,6 +106,8 @@ export interface FlushStats {
80
106
  dropped: number;
81
107
  /** Records dropped by the sampler (level, sample rate, rate limit). */
82
108
  sampled: number;
109
+ /** Records that stayed on the machine (classification: local only). */
110
+ localOnly: number;
83
111
  /** Transport errors, keyed by transport name. */
84
112
  errors: Record<string, string>;
85
113
  }