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,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The log service over a real socket.
|
|
3
|
+
*
|
|
4
|
+
* These tests cover the request path an agent or a client actually sees:
|
|
5
|
+
* authentication, the size limit, the durable acceptance and the query.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { createServer, type Server } from "node:http";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
LogCredentialStore,
|
|
15
|
+
LogIndexStore,
|
|
16
|
+
LogIngestService,
|
|
17
|
+
LogOutbox,
|
|
18
|
+
createLogHttpHandler,
|
|
19
|
+
hashToken,
|
|
20
|
+
} from "../src/server/index.js";
|
|
21
|
+
import { LOG_SCHEMA_VERSION } from "../src/types.js";
|
|
22
|
+
|
|
23
|
+
const TOKEN = "install-token-1";
|
|
24
|
+
const created: string[] = [];
|
|
25
|
+
const servers: Server[] = [];
|
|
26
|
+
|
|
27
|
+
function tempDir(): string {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "log-http-"));
|
|
29
|
+
created.push(dir);
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
while (servers.length > 0) servers.pop()?.close();
|
|
35
|
+
while (created.length > 0) {
|
|
36
|
+
rmSync(created.pop() as string, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function batch(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: LOG_SCHEMA_VERSION,
|
|
43
|
+
batchId: "batch-1",
|
|
44
|
+
app: "pi-harness",
|
|
45
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
46
|
+
sentAt: "2026-10-24T09:00:00.000Z",
|
|
47
|
+
records: [
|
|
48
|
+
{
|
|
49
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
50
|
+
level: "error",
|
|
51
|
+
source: "harness.test",
|
|
52
|
+
event: "service.failed",
|
|
53
|
+
message: "boom",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function startServer(options: { configured?: boolean } = {}): Promise<{
|
|
61
|
+
url: string;
|
|
62
|
+
outbox: LogOutbox;
|
|
63
|
+
}> {
|
|
64
|
+
const dir = tempDir();
|
|
65
|
+
const credentialsPath = join(dir, "log-ingest-credentials.json");
|
|
66
|
+
if (options.configured !== false) {
|
|
67
|
+
writeFileSync(
|
|
68
|
+
credentialsPath,
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
credentials: [
|
|
71
|
+
{
|
|
72
|
+
id: "laptop",
|
|
73
|
+
tenantId: "acme",
|
|
74
|
+
scopes: ["logs:write", "logs:read"],
|
|
75
|
+
tokenHash: hashToken(TOKEN),
|
|
76
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const outbox = new LogOutbox({ dir: join(dir, "outbox") });
|
|
84
|
+
const index = new LogIndexStore({ dir: join(dir, "index") });
|
|
85
|
+
const service = new LogIngestService({
|
|
86
|
+
credentials: LogCredentialStore.load({
|
|
87
|
+
path: credentialsPath,
|
|
88
|
+
env: {},
|
|
89
|
+
keysDir: join(dir, "keys"),
|
|
90
|
+
}),
|
|
91
|
+
outbox,
|
|
92
|
+
index,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const handler = createLogHttpHandler({ service, outbox, index });
|
|
96
|
+
const server = createServer((req, res) => {
|
|
97
|
+
void handler(req, res);
|
|
98
|
+
});
|
|
99
|
+
servers.push(server);
|
|
100
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
101
|
+
const address = server.address();
|
|
102
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
103
|
+
|
|
104
|
+
return { url: `http://127.0.0.1:${port}`, outbox };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("ingest over http", () => {
|
|
108
|
+
test("refuses the write when no credential is configured", async () => {
|
|
109
|
+
const { url } = await startServer({ configured: false });
|
|
110
|
+
|
|
111
|
+
const response = await fetch(`${url}/api/logs`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify(batch()),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(response.status).toBe(503);
|
|
118
|
+
const body = (await response.json()) as { code: string };
|
|
119
|
+
expect(body.code).toBe("ingest_unconfigured");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("rejects an unknown credential", async () => {
|
|
123
|
+
const { url } = await startServer();
|
|
124
|
+
|
|
125
|
+
const response = await fetch(`${url}/api/logs`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer nope" },
|
|
128
|
+
body: JSON.stringify(batch()),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(response.status).toBe(401);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("accepts a valid batch and queues it durably", async () => {
|
|
135
|
+
const { url, outbox } = await startServer();
|
|
136
|
+
|
|
137
|
+
const response = await fetch(`${url}/api/logs`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
140
|
+
body: JSON.stringify(batch()),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(response.status).toBe(202);
|
|
144
|
+
const body = (await response.json()) as { accepted: number; tenantId: string; queued: boolean };
|
|
145
|
+
expect(body.accepted).toBe(1);
|
|
146
|
+
expect(body.tenantId).toBe("acme");
|
|
147
|
+
expect(body.queued).toBe(true);
|
|
148
|
+
expect(outbox.listPending()).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("is idempotent for the same batch", async () => {
|
|
152
|
+
const { url, outbox } = await startServer();
|
|
153
|
+
|
|
154
|
+
const send = () =>
|
|
155
|
+
fetch(`${url}/api/logs`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
158
|
+
body: JSON.stringify(batch()),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await send();
|
|
162
|
+
const second = await send();
|
|
163
|
+
|
|
164
|
+
expect(second.status).toBe(202);
|
|
165
|
+
expect(((await second.json()) as { duplicate: boolean }).duplicate).toBe(true);
|
|
166
|
+
expect(outbox.listPending()).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("rejects a malformed batch", async () => {
|
|
170
|
+
const { url } = await startServer();
|
|
171
|
+
|
|
172
|
+
const response = await fetch(`${url}/api/logs`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
175
|
+
body: JSON.stringify({ schemaVersion: LOG_SCHEMA_VERSION, records: [] }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(response.status).toBe(400);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("rejects invalid json", async () => {
|
|
182
|
+
const { url } = await startServer();
|
|
183
|
+
|
|
184
|
+
const response = await fetch(`${url}/api/logs`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
187
|
+
body: "{not json",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(response.status).toBe(400);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("query over http", () => {
|
|
195
|
+
test("returns the records of the caller tenant only", async () => {
|
|
196
|
+
const { url } = await startServer();
|
|
197
|
+
await fetch(`${url}/api/logs`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
200
|
+
body: JSON.stringify(batch()),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const response = await fetch(`${url}/api/logs/query`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
206
|
+
body: JSON.stringify({ tenantId: "someone-else" }),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(response.status).toBe(200);
|
|
210
|
+
const body = (await response.json()) as { entries: unknown[]; tenantId: string };
|
|
211
|
+
expect(body.tenantId).toBe("acme");
|
|
212
|
+
expect(body.entries).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("refuses a query without a credential", async () => {
|
|
216
|
+
const { url } = await startServer();
|
|
217
|
+
|
|
218
|
+
const response = await fetch(`${url}/api/logs/query`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
body: JSON.stringify({}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(response.status).toBe(401);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("diagnostics over http", () => {
|
|
229
|
+
test("reports the queue depth", async () => {
|
|
230
|
+
const { url, outbox } = await startServer();
|
|
231
|
+
await fetch(`${url}/api/logs`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
|
|
234
|
+
body: JSON.stringify(batch()),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const response = await fetch(`${url}/api/logs/health`, {
|
|
238
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(response.status).toBe(200);
|
|
242
|
+
const body = (await response.json()) as { outbox: { pending: number } };
|
|
243
|
+
expect(body.outbox.pending).toBe(1);
|
|
244
|
+
expect(outbox.listPending()).toHaveLength(1);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server side of the log pipeline: authentication, tenant isolation,
|
|
3
|
+
* durable acceptance and the bounded query service.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
LogCredentialStore,
|
|
12
|
+
LogIndexStore,
|
|
13
|
+
LogIngestService,
|
|
14
|
+
LogOutbox,
|
|
15
|
+
LogOutboxWorker,
|
|
16
|
+
hashToken,
|
|
17
|
+
readBearerToken,
|
|
18
|
+
serverObjectKey,
|
|
19
|
+
} from "../src/server/index.js";
|
|
20
|
+
import { LOG_SCHEMA_VERSION } from "../src/types.js";
|
|
21
|
+
|
|
22
|
+
const created: string[] = [];
|
|
23
|
+
|
|
24
|
+
function tempDir(): string {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "log-server-"));
|
|
26
|
+
created.push(dir);
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
while (created.length > 0) {
|
|
32
|
+
rmSync(created.pop() as string, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const TOKEN = "install-token-1";
|
|
37
|
+
|
|
38
|
+
function writeCredentials(dir: string, entries: unknown[]): string {
|
|
39
|
+
const path = join(dir, "log-ingest-credentials.json");
|
|
40
|
+
writeFileSync(path, JSON.stringify({ credentials: entries }));
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function credentialEntry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
45
|
+
return {
|
|
46
|
+
id: "laptop",
|
|
47
|
+
tenantId: "acme",
|
|
48
|
+
label: "laptop",
|
|
49
|
+
scopes: ["logs:write", "logs:read"],
|
|
50
|
+
tokenHash: hashToken(TOKEN),
|
|
51
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildService(options: { credentials?: LogCredentialStore } = {}): {
|
|
57
|
+
service: LogIngestService;
|
|
58
|
+
outbox: LogOutbox;
|
|
59
|
+
index: LogIndexStore;
|
|
60
|
+
} {
|
|
61
|
+
const dir = tempDir();
|
|
62
|
+
const outbox = new LogOutbox({ dir: join(dir, "outbox") });
|
|
63
|
+
const index = new LogIndexStore({ dir: join(dir, "index") });
|
|
64
|
+
const credentials =
|
|
65
|
+
options.credentials ??
|
|
66
|
+
LogCredentialStore.load({
|
|
67
|
+
path: writeCredentials(dir, [credentialEntry()]),
|
|
68
|
+
env: {},
|
|
69
|
+
keysDir: join(dir, "keys"),
|
|
70
|
+
});
|
|
71
|
+
const service = new LogIngestService({ credentials, outbox, index });
|
|
72
|
+
return { service, outbox, index };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function batch(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
76
|
+
return {
|
|
77
|
+
schemaVersion: LOG_SCHEMA_VERSION,
|
|
78
|
+
batchId: "batch-1",
|
|
79
|
+
app: "pi-harness",
|
|
80
|
+
appVersion: "1.2.3",
|
|
81
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
82
|
+
sentAt: "2026-10-24T09:00:00.000Z",
|
|
83
|
+
records: [
|
|
84
|
+
{
|
|
85
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
86
|
+
level: "error",
|
|
87
|
+
source: "harness.test",
|
|
88
|
+
event: "service.failed",
|
|
89
|
+
message: "boom",
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
...overrides,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("ingest authentication", () => {
|
|
97
|
+
test("refuses every write when no credential is configured", () => {
|
|
98
|
+
const dir = tempDir();
|
|
99
|
+
const credentials = LogCredentialStore.load({
|
|
100
|
+
path: join(dir, "missing.json"),
|
|
101
|
+
env: {},
|
|
102
|
+
keysDir: join(dir, "keys"),
|
|
103
|
+
});
|
|
104
|
+
const { service } = buildService({ credentials });
|
|
105
|
+
|
|
106
|
+
const result = service.ingest(batch(), undefined);
|
|
107
|
+
|
|
108
|
+
expect(result.status).toBe(503);
|
|
109
|
+
expect(service.configured).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("rejects an unknown token", () => {
|
|
113
|
+
const { service } = buildService();
|
|
114
|
+
|
|
115
|
+
const result = service.ingest(batch(), "wrong-token");
|
|
116
|
+
|
|
117
|
+
expect(result.status).toBe(401);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rejects a missing token", () => {
|
|
121
|
+
const { service } = buildService();
|
|
122
|
+
|
|
123
|
+
expect(service.ingest(batch(), undefined).status).toBe(401);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects a credential without the write scope", () => {
|
|
127
|
+
const dir = tempDir();
|
|
128
|
+
const credentials = LogCredentialStore.load({
|
|
129
|
+
path: writeCredentials(dir, [credentialEntry({ scopes: ["logs:read"] })]),
|
|
130
|
+
env: {},
|
|
131
|
+
keysDir: join(dir, "keys"),
|
|
132
|
+
});
|
|
133
|
+
const { service } = buildService({ credentials });
|
|
134
|
+
|
|
135
|
+
expect(service.ingest(batch(), TOKEN).status).toBe(403);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("reads the bearer token case insensitively", () => {
|
|
139
|
+
expect(readBearerToken("Bearer abc")).toBe("abc");
|
|
140
|
+
expect(readBearerToken("bearer abc")).toBe("abc");
|
|
141
|
+
expect(readBearerToken("Basic abc")).toBeUndefined();
|
|
142
|
+
expect(readBearerToken(undefined)).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("refuses a revoked credential", () => {
|
|
146
|
+
const dir = tempDir();
|
|
147
|
+
const credentials = LogCredentialStore.load({
|
|
148
|
+
path: writeCredentials(dir, [
|
|
149
|
+
credentialEntry({ revokedAt: "2026-02-01T00:00:00.000Z" }),
|
|
150
|
+
]),
|
|
151
|
+
env: {},
|
|
152
|
+
keysDir: join(dir, "keys"),
|
|
153
|
+
});
|
|
154
|
+
const { service } = buildService({ credentials });
|
|
155
|
+
|
|
156
|
+
// A revoked credential leaves the store without a usable credential,
|
|
157
|
+
// so the endpoint fails closed instead of accepting the batch.
|
|
158
|
+
expect(service.configured).toBe(false);
|
|
159
|
+
expect(service.ingest(batch(), TOKEN).status).toBe(503);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("tenant isolation", () => {
|
|
164
|
+
test("derives the tenant from the credential, never from the body", () => {
|
|
165
|
+
const { service, outbox } = buildService();
|
|
166
|
+
|
|
167
|
+
const result = service.ingest(batch({ tenantId: "someone-else" }), TOKEN);
|
|
168
|
+
|
|
169
|
+
expect(result.status).toBe(202);
|
|
170
|
+
const pending = outbox.listPending();
|
|
171
|
+
expect(pending).toHaveLength(1);
|
|
172
|
+
expect(pending[0].batch.tenantId).toBe("acme");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a query never returns the records of another tenant", () => {
|
|
176
|
+
const { service } = buildService();
|
|
177
|
+
service.ingest(batch(), TOKEN);
|
|
178
|
+
|
|
179
|
+
const result = service.query({ tenantId: "someone-else" } as never, TOKEN);
|
|
180
|
+
|
|
181
|
+
expect(result.status).toBe(200);
|
|
182
|
+
const body = result.body as { entries: unknown[]; tenantId: string };
|
|
183
|
+
expect(body.tenantId).toBe("acme");
|
|
184
|
+
expect(body.entries).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("batch validation", () => {
|
|
189
|
+
test("rejects a malformed batch", () => {
|
|
190
|
+
const { service } = buildService();
|
|
191
|
+
|
|
192
|
+
expect(service.ingest({ schemaVersion: LOG_SCHEMA_VERSION }, TOKEN).status).toBe(400);
|
|
193
|
+
expect(service.ingest(batch({ records: [] }), TOKEN).status).toBe(400);
|
|
194
|
+
expect(service.ingest(batch({ schemaVersion: 99 }), TOKEN).status).toBe(400);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("rejects a record without a timestamp", () => {
|
|
198
|
+
const { service } = buildService();
|
|
199
|
+
|
|
200
|
+
const result = service.ingest(
|
|
201
|
+
batch({ records: [{ level: "info", source: "s", event: "e" }] }),
|
|
202
|
+
TOKEN,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(result.status).toBe(400);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("requires an installation id in schema v2", () => {
|
|
209
|
+
const { service } = buildService();
|
|
210
|
+
|
|
211
|
+
const result = service.ingest(batch({ installationId: undefined }), TOKEN);
|
|
212
|
+
|
|
213
|
+
expect(result.status).toBe(400);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("durable acceptance", () => {
|
|
218
|
+
test("queues the batch and acknowledges it", () => {
|
|
219
|
+
const { service, outbox } = buildService();
|
|
220
|
+
|
|
221
|
+
const result = service.ingest(batch(), TOKEN);
|
|
222
|
+
|
|
223
|
+
expect(result.status).toBe(202);
|
|
224
|
+
expect(outbox.listPending()).toHaveLength(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("is idempotent for a repeated batch", () => {
|
|
228
|
+
const { service, outbox } = buildService();
|
|
229
|
+
|
|
230
|
+
const first = service.ingest(batch(), TOKEN);
|
|
231
|
+
const second = service.ingest(batch(), TOKEN);
|
|
232
|
+
|
|
233
|
+
expect(first.status).toBe(202);
|
|
234
|
+
expect(second.status).toBe(202);
|
|
235
|
+
expect((second.body as { duplicate: boolean }).duplicate).toBe(true);
|
|
236
|
+
expect(outbox.listPending()).toHaveLength(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("returns a retryable error when the queue is unavailable", () => {
|
|
240
|
+
const dir = tempDir();
|
|
241
|
+
const broken = {
|
|
242
|
+
enqueue: () => {
|
|
243
|
+
throw new Error("disk full");
|
|
244
|
+
},
|
|
245
|
+
} as unknown as LogOutbox;
|
|
246
|
+
const service = new LogIngestService({
|
|
247
|
+
credentials: LogCredentialStore.load({
|
|
248
|
+
path: writeCredentials(dir, [credentialEntry()]),
|
|
249
|
+
env: {},
|
|
250
|
+
keysDir: join(dir, "keys"),
|
|
251
|
+
}),
|
|
252
|
+
outbox: broken,
|
|
253
|
+
index: new LogIndexStore({ dir: join(dir, "index") }),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const result = service.ingest(batch(), TOKEN);
|
|
257
|
+
|
|
258
|
+
expect(result.status).toBe(503);
|
|
259
|
+
expect((result.body as { code: string }).code).toBe("queue_unavailable");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("outbox worker", () => {
|
|
264
|
+
test("uploads a pending batch and clears the queue", async () => {
|
|
265
|
+
const { service, outbox } = buildService();
|
|
266
|
+
service.ingest(batch(), TOKEN);
|
|
267
|
+
|
|
268
|
+
const uploaded: string[] = [];
|
|
269
|
+
const worker = new LogOutboxWorker({
|
|
270
|
+
outbox,
|
|
271
|
+
upload: async (entry) => {
|
|
272
|
+
uploaded.push(entry.batch.batchId);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = await worker.runOnce();
|
|
277
|
+
|
|
278
|
+
expect(result.uploaded).toBe(1);
|
|
279
|
+
expect(uploaded).toEqual(["batch-1"]);
|
|
280
|
+
expect(outbox.listPending()).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("keeps the batch and retries after a failure", async () => {
|
|
284
|
+
const { service, outbox } = buildService();
|
|
285
|
+
service.ingest(batch(), TOKEN);
|
|
286
|
+
|
|
287
|
+
let attempts = 0;
|
|
288
|
+
const worker = new LogOutboxWorker({
|
|
289
|
+
outbox,
|
|
290
|
+
upload: async () => {
|
|
291
|
+
attempts++;
|
|
292
|
+
throw new Error("r2 down");
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const result = await worker.runOnce();
|
|
297
|
+
|
|
298
|
+
expect(result.failed).toBe(1);
|
|
299
|
+
expect(outbox.listPending()).toHaveLength(1);
|
|
300
|
+
expect(outbox.stats().failures).toBe(1);
|
|
301
|
+
expect(outbox.stats().lastError).toContain("r2 down");
|
|
302
|
+
expect(attempts).toBe(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("does not retry a batch before its backoff expired", async () => {
|
|
306
|
+
const { service, outbox } = buildService();
|
|
307
|
+
service.ingest(batch(), TOKEN);
|
|
308
|
+
|
|
309
|
+
let attempts = 0;
|
|
310
|
+
const worker = new LogOutboxWorker({
|
|
311
|
+
outbox,
|
|
312
|
+
baseDelayMs: 60_000,
|
|
313
|
+
upload: async () => {
|
|
314
|
+
attempts++;
|
|
315
|
+
throw new Error("r2 down");
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await worker.runOnce();
|
|
320
|
+
const second = await worker.runOnce();
|
|
321
|
+
|
|
322
|
+
expect(second.skipped).toBe(1);
|
|
323
|
+
expect(attempts).toBe(1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("reports the queue depth and the oldest pending age", () => {
|
|
327
|
+
const { service, outbox } = buildService();
|
|
328
|
+
service.ingest(batch(), TOKEN);
|
|
329
|
+
|
|
330
|
+
const stats = outbox.stats(Date.now() + 5_000);
|
|
331
|
+
|
|
332
|
+
expect(stats.pending).toBe(1);
|
|
333
|
+
expect(stats.oldestPendingAgeMs).toBeGreaterThanOrEqual(0);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("query service", () => {
|
|
338
|
+
test("filters by installation, level and event", () => {
|
|
339
|
+
const { service } = buildService();
|
|
340
|
+
service.ingest(batch(), TOKEN);
|
|
341
|
+
|
|
342
|
+
const byLevel = service.query({ levels: ["error"] }, TOKEN);
|
|
343
|
+
expect((byLevel.body as { entries: unknown[] }).entries).toHaveLength(1);
|
|
344
|
+
|
|
345
|
+
const byMissingLevel = service.query({ levels: ["debug"] }, TOKEN);
|
|
346
|
+
expect((byMissingLevel.body as { entries: unknown[] }).entries).toHaveLength(0);
|
|
347
|
+
|
|
348
|
+
const byInstallation = service.query({ installationId: "other" }, TOKEN);
|
|
349
|
+
expect((byInstallation.body as { entries: unknown[] }).entries).toHaveLength(0);
|
|
350
|
+
|
|
351
|
+
const byEvent = service.query({ event: "service.failed" }, TOKEN);
|
|
352
|
+
expect((byEvent.body as { entries: unknown[] }).entries).toHaveLength(1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("never returns the message text", () => {
|
|
356
|
+
const { service } = buildService();
|
|
357
|
+
service.ingest(batch(), TOKEN);
|
|
358
|
+
|
|
359
|
+
const result = service.query({}, TOKEN);
|
|
360
|
+
const entries = (result.body as { entries: Record<string, unknown>[] }).entries;
|
|
361
|
+
|
|
362
|
+
expect(entries).toHaveLength(1);
|
|
363
|
+
expect(entries[0].message).toBeUndefined();
|
|
364
|
+
expect(entries[0].event).toBe("service.failed");
|
|
365
|
+
expect(entries[0].tenantId).toBe("acme");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("bounds the page and paginates with a cursor", () => {
|
|
369
|
+
const { service } = buildService();
|
|
370
|
+
service.ingest(
|
|
371
|
+
batch({
|
|
372
|
+
records: [
|
|
373
|
+
{
|
|
374
|
+
ts: "2026-10-24T09:00:00.000Z",
|
|
375
|
+
level: "info",
|
|
376
|
+
source: "harness.test",
|
|
377
|
+
event: "one",
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
ts: "2026-10-24T09:00:01.000Z",
|
|
381
|
+
level: "info",
|
|
382
|
+
source: "harness.test",
|
|
383
|
+
event: "two",
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
}),
|
|
387
|
+
TOKEN,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const first = service.query({ limit: 1 }, TOKEN);
|
|
391
|
+
const firstBody = first.body as { entries: unknown[]; nextCursor?: string; hasMore: boolean };
|
|
392
|
+
|
|
393
|
+
expect(firstBody.entries).toHaveLength(1);
|
|
394
|
+
expect(firstBody.hasMore).toBe(true);
|
|
395
|
+
expect(firstBody.nextCursor).toBeTruthy();
|
|
396
|
+
|
|
397
|
+
const second = service.query({ limit: 1, cursor: firstBody.nextCursor }, TOKEN);
|
|
398
|
+
expect((second.body as { entries: unknown[] }).entries).toHaveLength(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("refuses a query without the read scope", () => {
|
|
402
|
+
const dir = tempDir();
|
|
403
|
+
const credentials = LogCredentialStore.load({
|
|
404
|
+
path: writeCredentials(dir, [credentialEntry({ scopes: ["logs:write"] })]),
|
|
405
|
+
env: {},
|
|
406
|
+
keysDir: join(dir, "keys"),
|
|
407
|
+
});
|
|
408
|
+
const { service } = buildService({ credentials });
|
|
409
|
+
|
|
410
|
+
expect(service.query({}, TOKEN).status).toBe(403);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("archive keys", () => {
|
|
415
|
+
test("are generated on the server and contain no user path", () => {
|
|
416
|
+
const key = serverObjectKey({
|
|
417
|
+
tenantId: "../../etc",
|
|
418
|
+
batchId: "batch-1",
|
|
419
|
+
sentAt: "2026-10-24T09:00:00.000Z",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
expect(key.startsWith("logs/")).toBe(true);
|
|
423
|
+
expect(key).not.toContain("..");
|
|
424
|
+
expect(key).toContain("2026/10/24/09");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("outbox files", () => {
|
|
429
|
+
test("are stored as one file per batch", () => {
|
|
430
|
+
const dir = tempDir();
|
|
431
|
+
const outbox = new LogOutbox({ dir: join(dir, "outbox") });
|
|
432
|
+
outbox.enqueue({
|
|
433
|
+
tenantId: "acme",
|
|
434
|
+
installationId: "11111111-2222-3333-4444-555555555555",
|
|
435
|
+
batchId: "batch-1",
|
|
436
|
+
app: "pi-harness",
|
|
437
|
+
sentAt: "2026-10-24T09:00:00.000Z",
|
|
438
|
+
records: [],
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const files = readdirSync(join(dir, "outbox", "pending"));
|
|
442
|
+
expect(files.filter((name) => name.endsWith(".json") && !name.endsWith(".meta.json"))).toHaveLength(1);
|
|
443
|
+
});
|
|
444
|
+
});
|