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.
- package/AGENTS.md +54 -7
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/README.md +41 -6
- package/packages/logging/dist/classification.d.ts +65 -0
- package/packages/logging/dist/classification.d.ts.map +1 -0
- package/packages/logging/dist/classification.js +155 -0
- package/packages/logging/dist/classification.js.map +1 -0
- package/packages/logging/dist/config.d.ts +9 -0
- package/packages/logging/dist/config.d.ts.map +1 -1
- package/packages/logging/dist/config.js +40 -3
- package/packages/logging/dist/config.js.map +1 -1
- package/packages/logging/dist/index.d.ts +9 -1
- package/packages/logging/dist/index.d.ts.map +1 -1
- package/packages/logging/dist/index.js +10 -1
- package/packages/logging/dist/index.js.map +1 -1
- package/packages/logging/dist/ingest/schema.d.ts +60 -0
- package/packages/logging/dist/ingest/schema.d.ts.map +1 -0
- package/packages/logging/dist/ingest/schema.js +245 -0
- package/packages/logging/dist/ingest/schema.js.map +1 -0
- package/packages/logging/dist/installation.d.ts +32 -0
- package/packages/logging/dist/installation.d.ts.map +1 -0
- package/packages/logging/dist/installation.js +64 -0
- package/packages/logging/dist/installation.js.map +1 -0
- package/packages/logging/dist/sensitive-keys.d.ts +12 -0
- package/packages/logging/dist/sensitive-keys.d.ts.map +1 -0
- package/packages/logging/dist/sensitive-keys.js +39 -0
- package/packages/logging/dist/sensitive-keys.js.map +1 -0
- package/packages/logging/dist/server/credentials.d.ts +85 -0
- package/packages/logging/dist/server/credentials.d.ts.map +1 -0
- package/packages/logging/dist/server/credentials.js +147 -0
- package/packages/logging/dist/server/credentials.js.map +1 -0
- package/packages/logging/dist/server/http.d.ts +36 -0
- package/packages/logging/dist/server/http.d.ts.map +1 -0
- package/packages/logging/dist/server/http.js +139 -0
- package/packages/logging/dist/server/http.js.map +1 -0
- package/packages/logging/dist/server/index-store.d.ts +95 -0
- package/packages/logging/dist/server/index-store.d.ts.map +1 -0
- package/packages/logging/dist/server/index-store.js +190 -0
- package/packages/logging/dist/server/index-store.js.map +1 -0
- package/packages/logging/dist/server/index.d.ts +22 -0
- package/packages/logging/dist/server/index.d.ts.map +1 -0
- package/packages/logging/dist/server/index.js +15 -0
- package/packages/logging/dist/server/index.js.map +1 -0
- package/packages/logging/dist/server/keys.d.ts +22 -0
- package/packages/logging/dist/server/keys.d.ts.map +1 -0
- package/packages/logging/dist/server/keys.js +24 -0
- package/packages/logging/dist/server/keys.js.map +1 -0
- package/packages/logging/dist/server/outbox-worker.d.ts +55 -0
- package/packages/logging/dist/server/outbox-worker.d.ts.map +1 -0
- package/packages/logging/dist/server/outbox-worker.js +95 -0
- package/packages/logging/dist/server/outbox-worker.js.map +1 -0
- package/packages/logging/dist/server/outbox.d.ts +112 -0
- package/packages/logging/dist/server/outbox.d.ts.map +1 -0
- package/packages/logging/dist/server/outbox.js +247 -0
- package/packages/logging/dist/server/outbox.js.map +1 -0
- package/packages/logging/dist/server/service.d.ts +83 -0
- package/packages/logging/dist/server/service.d.ts.map +1 -0
- package/packages/logging/dist/server/service.js +144 -0
- package/packages/logging/dist/server/service.js.map +1 -0
- package/packages/logging/dist/shipper.d.ts +20 -4
- package/packages/logging/dist/shipper.d.ts.map +1 -1
- package/packages/logging/dist/shipper.js +31 -5
- package/packages/logging/dist/shipper.js.map +1 -1
- package/packages/logging/dist/singleton.d.ts.map +1 -1
- package/packages/logging/dist/singleton.js +3 -0
- package/packages/logging/dist/singleton.js.map +1 -1
- package/packages/logging/dist/types.d.ts +28 -1
- package/packages/logging/dist/types.d.ts.map +1 -1
- package/packages/logging/dist/types.js +8 -0
- package/packages/logging/dist/types.js.map +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/logging/src/classification.ts +175 -0
- package/packages/logging/src/config.ts +60 -3
- package/packages/logging/src/index.ts +45 -1
- package/packages/logging/src/ingest/schema.ts +295 -0
- package/packages/logging/src/installation.ts +76 -0
- package/packages/logging/src/sensitive-keys.ts +40 -0
- package/packages/logging/src/server/credentials.ts +198 -0
- package/packages/logging/src/server/http.ts +170 -0
- package/packages/logging/src/server/index-store.ts +242 -0
- package/packages/logging/src/server/index.ts +56 -0
- package/packages/logging/src/server/keys.ts +32 -0
- package/packages/logging/src/server/outbox-worker.ts +124 -0
- package/packages/logging/src/server/outbox.ts +316 -0
- package/packages/logging/src/server/service.ts +196 -0
- package/packages/logging/src/shipper.ts +54 -8
- package/packages/logging/src/singleton.ts +3 -0
- package/packages/logging/src/types.ts +29 -1
- package/packages/logging/test/classification.test.ts +137 -0
- package/packages/logging/test/identity-transport.test.ts +197 -0
- package/packages/logging/test/ingest-schema.test.ts +184 -0
- package/packages/logging/test/integration.test.ts +9 -4
- package/packages/logging/test/log-http.test.ts +246 -0
- package/packages/logging/test/server.test.ts +444 -0
- package/packages/logging/test/shipper.test.ts +11 -11
- package/packages/logging/test/spool.test.ts +4 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- 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
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|