@vauban-org/agent-sdk 1.2.0 → 1.4.0
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/CONTRACT.md +595 -7
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestration/ooda/agent.d.ts.map +1 -1
- package/dist/orchestration/ooda/agent.js +77 -0
- package/dist/orchestration/ooda/agent.js.map +1 -1
- package/dist/orchestration/ooda/types.d.ts +11 -0
- package/dist/orchestration/ooda/types.d.ts.map +1 -1
- package/dist/skills/_secrets.d.ts +16 -0
- package/dist/skills/_secrets.d.ts.map +1 -0
- package/dist/skills/_secrets.js +20 -0
- package/dist/skills/_secrets.js.map +1 -0
- package/dist/skills/alpaca-quote.d.ts +2 -2
- package/dist/skills/alpaca-quote.d.ts.map +1 -1
- package/dist/skills/alpaca-quote.js +51 -20
- package/dist/skills/alpaca-quote.js.map +1 -1
- package/dist/skills/send-email.d.ts +2 -2
- package/dist/skills/send-email.d.ts.map +1 -1
- package/dist/skills/send-email.js +1 -12
- package/dist/skills/send-email.js.map +1 -1
- package/dist/skills/slack-notify.d.ts.map +1 -1
- package/dist/skills/slack-notify.js +52 -21
- package/dist/skills/slack-notify.js.map +1 -1
- package/dist/skills/telegram-notify.d.ts.map +1 -1
- package/dist/skills/telegram-notify.js +48 -19
- package/dist/skills/telegram-notify.js.map +1 -1
- package/dist/skills/web-search.d.ts.map +1 -1
- package/dist/skills/web-search.js +85 -40
- package/dist/skills/web-search.js.map +1 -1
- package/dist/telemetry/bus.d.ts +54 -0
- package/dist/telemetry/bus.d.ts.map +1 -0
- package/dist/telemetry/bus.js +159 -0
- package/dist/telemetry/bus.js.map +1 -0
- package/dist/telemetry/index.d.ts +35 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +30 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/port.d.ts +121 -0
- package/dist/telemetry/port.d.ts.map +1 -0
- package/dist/telemetry/port.js +48 -0
- package/dist/telemetry/port.js.map +1 -0
- package/dist/telemetry/sinks/otlp.d.ts +45 -0
- package/dist/telemetry/sinks/otlp.d.ts.map +1 -0
- package/dist/telemetry/sinks/otlp.js +195 -0
- package/dist/telemetry/sinks/otlp.js.map +1 -0
- package/dist/telemetry/sinks/sqlite.d.ts +32 -0
- package/dist/telemetry/sinks/sqlite.d.ts.map +1 -0
- package/dist/telemetry/sinks/sqlite.js +170 -0
- package/dist/telemetry/sinks/sqlite.js.map +1 -0
- package/dist/telemetry/sinks/stdout.d.ts +22 -0
- package/dist/telemetry/sinks/stdout.d.ts.map +1 -0
- package/dist/telemetry/sinks/stdout.js +38 -0
- package/dist/telemetry/sinks/stdout.js.map +1 -0
- package/docs/telemetry/migration.md +155 -0
- package/docs/telemetry/overview.md +154 -0
- package/docs/telemetry/privacy.md +127 -0
- package/docs/telemetry/sinks/cc.md +155 -0
- package/docs/telemetry/sinks/otlp.md +146 -0
- package/docs/telemetry/sinks/sqlite.md +126 -0
- package/docs/telemetry/sinks/stdout.md +82 -0
- package/package.json +18 -19
- package/src/index.ts +30 -1
- package/src/orchestration/ooda/agent.ts +105 -0
- package/src/orchestration/ooda/types.ts +12 -0
- package/src/skills/_secrets.ts +25 -0
- package/src/skills/alpaca-quote.ts +68 -23
- package/src/skills/send-email.ts +1 -12
- package/src/skills/slack-notify.ts +73 -30
- package/src/skills/telegram-notify.ts +70 -24
- package/src/skills/web-search.ts +132 -50
- package/src/telemetry/bus.test.ts +231 -0
- package/src/telemetry/bus.ts +241 -0
- package/src/telemetry/index.ts +49 -0
- package/src/telemetry/port.ts +160 -0
- package/src/telemetry/sinks/otlp.test.ts +146 -0
- package/src/telemetry/sinks/otlp.ts +250 -0
- package/src/telemetry/sinks/sqlite.test.ts +121 -0
- package/src/telemetry/sinks/sqlite.ts +260 -0
- package/src/telemetry/sinks/stdout.test.ts +109 -0
- package/src/telemetry/sinks/stdout.ts +59 -0
package/src/skills/web-search.ts
CHANGED
|
@@ -6,9 +6,31 @@
|
|
|
6
6
|
* @public
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
retry,
|
|
12
|
+
RETRY_TRANSIENT,
|
|
13
|
+
RetryExhaustedError,
|
|
14
|
+
type RetryConfig,
|
|
15
|
+
} from "../retry/index.js";
|
|
9
16
|
import type { Skill, SkillContext } from "../orchestration/ooda/skills.js";
|
|
17
|
+
|
|
10
18
|
import { SkillExecutionError, SkillNotConfiguredError } from "./errors.js";
|
|
11
19
|
import { withSkillSpan } from "./_otel.js";
|
|
20
|
+
import { readSecret } from "./_secrets.js";
|
|
21
|
+
|
|
22
|
+
class RetryableHttpError extends Error {
|
|
23
|
+
constructor(public readonly status: number, public readonly body: string) {
|
|
24
|
+
super(`HTTP ${status}: ${body.slice(0, 200)}`);
|
|
25
|
+
this.name = "RetryableHttpError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SEARCH_RETRY: RetryConfig = {
|
|
30
|
+
...RETRY_TRANSIENT,
|
|
31
|
+
retryIf: (err) =>
|
|
32
|
+
err instanceof RetryableHttpError || err instanceof TypeError,
|
|
33
|
+
};
|
|
12
34
|
|
|
13
35
|
const inputSchema = z
|
|
14
36
|
.object({
|
|
@@ -38,71 +60,131 @@ export const webSearch: Skill<WebSearchInput, WebSearchOutput> = {
|
|
|
38
60
|
return { results: [], provider: "replay" };
|
|
39
61
|
}
|
|
40
62
|
return withSkillSpan("web_search", async () => {
|
|
41
|
-
const braveKey =
|
|
42
|
-
const tavilyKey =
|
|
63
|
+
const braveKey = readSecret(ctx, "BRAVE_SEARCH_KEY");
|
|
64
|
+
const tavilyKey = readSecret(ctx, "TAVILY_API_KEY");
|
|
43
65
|
if (!braveKey && !tavilyKey) {
|
|
44
66
|
throw new SkillNotConfiguredError("web_search", [
|
|
45
67
|
"BRAVE_SEARCH_KEY",
|
|
46
68
|
"TAVILY_API_KEY",
|
|
47
69
|
]);
|
|
48
70
|
}
|
|
71
|
+
|
|
72
|
+
// Try Brave first when configured. Retry on 5xx/429. Fall back to
|
|
73
|
+
// Tavily on any final non-OK (4xx other than 429 short-circuits).
|
|
49
74
|
if (braveKey) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
try {
|
|
76
|
+
return await retry(
|
|
77
|
+
async () => {
|
|
78
|
+
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(
|
|
79
|
+
input.query
|
|
80
|
+
)}&count=${input.limit}`;
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
headers: {
|
|
83
|
+
Accept: "application/json",
|
|
84
|
+
"X-Subscription-Token": braveKey,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const body = await res.text().catch(() => "");
|
|
89
|
+
if (res.status >= 500 || res.status === 429) {
|
|
90
|
+
throw new RetryableHttpError(res.status, body);
|
|
91
|
+
}
|
|
92
|
+
throw new SkillExecutionError(
|
|
93
|
+
"web_search",
|
|
94
|
+
`brave ${res.status}`,
|
|
95
|
+
{ status: res.status }
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const data = (await res.json()) as {
|
|
99
|
+
web?: {
|
|
100
|
+
results?: Array<{
|
|
101
|
+
title?: string;
|
|
102
|
+
url?: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
}>;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
const results: WebSearchResult[] = (data.web?.results ?? []).map(
|
|
108
|
+
(r) => ({
|
|
109
|
+
title: r.title ?? "",
|
|
110
|
+
url: r.url ?? "",
|
|
111
|
+
snippet: r.description ?? "",
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
return { results, provider: "brave" as const };
|
|
115
|
+
},
|
|
116
|
+
{ config: SEARCH_RETRY }
|
|
117
|
+
);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (!tavilyKey) {
|
|
120
|
+
const cause =
|
|
121
|
+
err instanceof RetryExhaustedError ? err.lastError : err;
|
|
122
|
+
if (cause instanceof RetryableHttpError) {
|
|
123
|
+
throw new SkillExecutionError(
|
|
124
|
+
"web_search",
|
|
125
|
+
`brave ${cause.status} after retries`,
|
|
126
|
+
{ status: cause.status }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw cause;
|
|
130
|
+
}
|
|
131
|
+
// Brave failed — fall through to Tavily.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Tavily (primary if no Brave key, else fallback)
|
|
136
|
+
try {
|
|
137
|
+
return await retry(
|
|
138
|
+
async () => {
|
|
139
|
+
const res = await fetch("https://api.tavily.com/search", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
api_key: tavilyKey,
|
|
144
|
+
query: input.query,
|
|
145
|
+
max_results: input.limit,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
const body = await res.text().catch(() => "");
|
|
150
|
+
if (res.status >= 500 || res.status === 429) {
|
|
151
|
+
throw new RetryableHttpError(res.status, body);
|
|
152
|
+
}
|
|
153
|
+
throw new SkillExecutionError(
|
|
154
|
+
"web_search",
|
|
155
|
+
`tavily ${res.status}`,
|
|
156
|
+
{ status: res.status }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const data = (await res.json()) as {
|
|
60
160
|
results?: Array<{
|
|
61
161
|
title?: string;
|
|
62
162
|
url?: string;
|
|
63
|
-
|
|
163
|
+
content?: string;
|
|
64
164
|
}>;
|
|
65
165
|
};
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
166
|
+
const results: WebSearchResult[] = (data.results ?? []).map(
|
|
167
|
+
(r) => ({
|
|
168
|
+
title: r.title ?? "",
|
|
169
|
+
url: r.url ?? "",
|
|
170
|
+
snippet: r.content ?? "",
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
return { results, provider: "tavily" as const };
|
|
174
|
+
},
|
|
175
|
+
{ config: SEARCH_RETRY }
|
|
176
|
+
);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const cause = err instanceof RetryExhaustedError ? err.lastError : err;
|
|
179
|
+
if (cause instanceof RetryableHttpError) {
|
|
180
|
+
throw new SkillExecutionError(
|
|
181
|
+
"web_search",
|
|
182
|
+
`tavily ${cause.status} after retries`,
|
|
183
|
+
{ status: cause.status }
|
|
73
184
|
);
|
|
74
|
-
return { results, provider: "brave" };
|
|
75
|
-
}
|
|
76
|
-
if (!tavilyKey) {
|
|
77
|
-
throw new SkillExecutionError("web_search", `brave ${res.status}`, {
|
|
78
|
-
status: res.status,
|
|
79
|
-
});
|
|
80
185
|
}
|
|
186
|
+
throw cause;
|
|
81
187
|
}
|
|
82
|
-
// Tavily fallback
|
|
83
|
-
const res = await fetch("https://api.tavily.com/search", {
|
|
84
|
-
method: "POST",
|
|
85
|
-
headers: { "Content-Type": "application/json" },
|
|
86
|
-
body: JSON.stringify({
|
|
87
|
-
api_key: tavilyKey,
|
|
88
|
-
query: input.query,
|
|
89
|
-
max_results: input.limit,
|
|
90
|
-
}),
|
|
91
|
-
});
|
|
92
|
-
if (!res.ok) {
|
|
93
|
-
throw new SkillExecutionError("web_search", `tavily ${res.status}`, {
|
|
94
|
-
status: res.status,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
const data = (await res.json()) as {
|
|
98
|
-
results?: Array<{ title?: string; url?: string; content?: string }>;
|
|
99
|
-
};
|
|
100
|
-
const results: WebSearchResult[] = (data.results ?? []).map((r) => ({
|
|
101
|
-
title: r.title ?? "",
|
|
102
|
-
url: r.url ?? "",
|
|
103
|
-
snippet: r.content ?? "",
|
|
104
|
-
}));
|
|
105
|
-
return { results, provider: "tavily" };
|
|
106
188
|
});
|
|
107
189
|
},
|
|
108
190
|
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TelemetryBus — fanout, isolation, backpressure tests.
|
|
3
|
+
*
|
|
4
|
+
* Ref: command-center:sprint-693:telemetry-bus
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { createTelemetryBus } from "./bus.js";
|
|
10
|
+
import type { TelemetrySink } from "./port.js";
|
|
11
|
+
|
|
12
|
+
function makeSink(
|
|
13
|
+
name: string,
|
|
14
|
+
behavior: "ok" | "throw" = "ok"
|
|
15
|
+
): TelemetrySink {
|
|
16
|
+
const calls = { start: 0, step: 0, finish: 0 };
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
async start() {
|
|
20
|
+
calls.start += 1;
|
|
21
|
+
if (behavior === "throw") throw new Error(`${name}.start failed`);
|
|
22
|
+
},
|
|
23
|
+
async step() {
|
|
24
|
+
calls.step += 1;
|
|
25
|
+
if (behavior === "throw") throw new Error(`${name}.step failed`);
|
|
26
|
+
},
|
|
27
|
+
async finish() {
|
|
28
|
+
calls.finish += 1;
|
|
29
|
+
if (behavior === "throw") throw new Error(`${name}.finish failed`);
|
|
30
|
+
},
|
|
31
|
+
// expose counters via name munging — vitest reads `(sink as any).calls`
|
|
32
|
+
// @ts-expect-error — test affordance
|
|
33
|
+
calls,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RUN_START = {
|
|
38
|
+
runId: "11111111-1111-1111-1111-111111111111",
|
|
39
|
+
agentId: "agent-x",
|
|
40
|
+
agentVersion: "1.0.0",
|
|
41
|
+
model: "test",
|
|
42
|
+
provider: "test",
|
|
43
|
+
startedAt: "2026-05-16T17:00:00.000Z",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const STEP_DELTA = {
|
|
47
|
+
stepIndex: 0,
|
|
48
|
+
kind: "observe",
|
|
49
|
+
status: "completed" as const,
|
|
50
|
+
inputTokens: 10,
|
|
51
|
+
outputTokens: 20,
|
|
52
|
+
costUsd: 0.001,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const FINISH = {
|
|
56
|
+
status: "success" as const,
|
|
57
|
+
finishedAt: "2026-05-16T17:00:05.000Z",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe("createTelemetryBus", () => {
|
|
61
|
+
it("returns a NOOP sink when sinks=[]", async () => {
|
|
62
|
+
const bus = createTelemetryBus({ sinks: [] });
|
|
63
|
+
expect(bus.name).toBe("noop");
|
|
64
|
+
await expect(bus.start(RUN_START)).resolves.toBeUndefined();
|
|
65
|
+
expect(bus.counters.dispatched).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("fans out start/step/finish to every sink (blocking mode)", async () => {
|
|
69
|
+
const a = makeSink("a");
|
|
70
|
+
const b = makeSink("b");
|
|
71
|
+
const bus = createTelemetryBus({
|
|
72
|
+
sinks: [a, b],
|
|
73
|
+
nonBlocking: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await bus.start(RUN_START);
|
|
77
|
+
await bus.step(RUN_START.runId, STEP_DELTA);
|
|
78
|
+
await bus.finish(RUN_START.runId, FINISH);
|
|
79
|
+
|
|
80
|
+
// @ts-expect-error — test affordance
|
|
81
|
+
expect(a.calls).toEqual({ start: 1, step: 1, finish: 1 });
|
|
82
|
+
// @ts-expect-error — test affordance
|
|
83
|
+
expect(b.calls).toEqual({ start: 1, step: 1, finish: 1 });
|
|
84
|
+
expect(bus.counters.dispatched).toBe(6); // 3 events × 2 sinks
|
|
85
|
+
expect(bus.counters.sinkErrors).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("isolates failures — one sink throwing does not affect others", async () => {
|
|
89
|
+
const good = makeSink("good");
|
|
90
|
+
const bad = makeSink("bad", "throw");
|
|
91
|
+
const warn = vi.fn();
|
|
92
|
+
const bus = createTelemetryBus({
|
|
93
|
+
sinks: [good, bad],
|
|
94
|
+
logger: { warn, error: () => {} },
|
|
95
|
+
nonBlocking: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await bus.start(RUN_START);
|
|
99
|
+
await bus.step(RUN_START.runId, STEP_DELTA);
|
|
100
|
+
await bus.finish(RUN_START.runId, FINISH);
|
|
101
|
+
|
|
102
|
+
// @ts-expect-error
|
|
103
|
+
expect(good.calls).toEqual({ start: 1, step: 1, finish: 1 });
|
|
104
|
+
// @ts-expect-error
|
|
105
|
+
expect(bad.calls).toEqual({ start: 1, step: 1, finish: 1 });
|
|
106
|
+
expect(bus.counters.sinkErrors).toBe(3);
|
|
107
|
+
expect(warn).toHaveBeenCalledTimes(3);
|
|
108
|
+
expect(warn.mock.calls[0]![1]).toMatch(/isolated/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("backpressure: drops oldest events when queue exceeds maxQueueDepth", async () => {
|
|
112
|
+
// Pending-resolver sink — caller controls when each call resolves.
|
|
113
|
+
const pending: Array<() => void> = [];
|
|
114
|
+
const blockingSink: TelemetrySink = {
|
|
115
|
+
name: "slow",
|
|
116
|
+
async start() {
|
|
117
|
+
await new Promise<void>((res) => {
|
|
118
|
+
pending.push(res);
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
async step() {},
|
|
122
|
+
async finish() {},
|
|
123
|
+
};
|
|
124
|
+
const warn = vi.fn();
|
|
125
|
+
const bus = createTelemetryBus({
|
|
126
|
+
sinks: [blockingSink],
|
|
127
|
+
logger: { warn, error: () => {} },
|
|
128
|
+
nonBlocking: true,
|
|
129
|
+
maxQueueDepth: 3,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Enqueue 6 starts — 1 enters dispatch and blocks; the next 3 fill the
|
|
133
|
+
// queue; the 5th and 6th cause overflow (drop oldest).
|
|
134
|
+
for (let i = 0; i < 6; i++) {
|
|
135
|
+
await bus.start({ ...RUN_START, runId: `run-${i}` });
|
|
136
|
+
}
|
|
137
|
+
expect(bus.counters.dropped).toBeGreaterThanOrEqual(1);
|
|
138
|
+
|
|
139
|
+
// Release all pending dispatches so flush() resolves.
|
|
140
|
+
while (pending.length > 0 || bus.counters.dispatched < 4) {
|
|
141
|
+
const r = pending.shift();
|
|
142
|
+
r?.();
|
|
143
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
144
|
+
}
|
|
145
|
+
await bus.flush();
|
|
146
|
+
}, 10_000);
|
|
147
|
+
|
|
148
|
+
it("non-blocking mode returns immediately", async () => {
|
|
149
|
+
let resolveSink: (() => void) | undefined;
|
|
150
|
+
const sink: TelemetrySink = {
|
|
151
|
+
name: "slow",
|
|
152
|
+
async start() {
|
|
153
|
+
await new Promise<void>((res) => {
|
|
154
|
+
resolveSink = res;
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
async step() {},
|
|
158
|
+
async finish() {},
|
|
159
|
+
};
|
|
160
|
+
const bus = createTelemetryBus({ sinks: [sink], nonBlocking: true });
|
|
161
|
+
|
|
162
|
+
const t0 = Date.now();
|
|
163
|
+
await bus.start(RUN_START); // should return immediately
|
|
164
|
+
const elapsed = Date.now() - t0;
|
|
165
|
+
expect(elapsed).toBeLessThan(50);
|
|
166
|
+
expect(bus.counters.dispatched).toBe(0); // not dispatched yet
|
|
167
|
+
|
|
168
|
+
resolveSink?.();
|
|
169
|
+
await bus.flush();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("flush awaits in-flight dispatches", async () => {
|
|
173
|
+
const sink = makeSink("a");
|
|
174
|
+
const bus = createTelemetryBus({ sinks: [sink], nonBlocking: true });
|
|
175
|
+
|
|
176
|
+
await bus.start(RUN_START);
|
|
177
|
+
await bus.step(RUN_START.runId, STEP_DELTA);
|
|
178
|
+
await bus.finish(RUN_START.runId, FINISH);
|
|
179
|
+
await bus.flush();
|
|
180
|
+
|
|
181
|
+
// @ts-expect-error
|
|
182
|
+
expect(sink.calls).toEqual({ start: 1, step: 1, finish: 1 });
|
|
183
|
+
expect(bus.counters.dispatched).toBe(3);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("preserves FIFO order per sink", async () => {
|
|
187
|
+
const events: string[] = [];
|
|
188
|
+
const sink: TelemetrySink = {
|
|
189
|
+
name: "ordered",
|
|
190
|
+
async start(e) {
|
|
191
|
+
events.push(`start:${e.runId}`);
|
|
192
|
+
},
|
|
193
|
+
async step(runId, d) {
|
|
194
|
+
events.push(`step:${runId}:${d.stepIndex}`);
|
|
195
|
+
},
|
|
196
|
+
async finish(runId) {
|
|
197
|
+
events.push(`finish:${runId}`);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const bus = createTelemetryBus({ sinks: [sink], nonBlocking: true });
|
|
201
|
+
|
|
202
|
+
await bus.start(RUN_START);
|
|
203
|
+
await bus.step(RUN_START.runId, { ...STEP_DELTA, stepIndex: 0 });
|
|
204
|
+
await bus.step(RUN_START.runId, { ...STEP_DELTA, stepIndex: 1 });
|
|
205
|
+
await bus.finish(RUN_START.runId, FINISH);
|
|
206
|
+
await bus.flush();
|
|
207
|
+
|
|
208
|
+
expect(events).toEqual([
|
|
209
|
+
`start:${RUN_START.runId}`,
|
|
210
|
+
`step:${RUN_START.runId}:0`,
|
|
211
|
+
`step:${RUN_START.runId}:1`,
|
|
212
|
+
`finish:${RUN_START.runId}`,
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("counts dispatched accurately under mixed success/failure", async () => {
|
|
217
|
+
const good = makeSink("good");
|
|
218
|
+
const bad = makeSink("bad", "throw");
|
|
219
|
+
const bus = createTelemetryBus({
|
|
220
|
+
sinks: [good, bad],
|
|
221
|
+
nonBlocking: false,
|
|
222
|
+
logger: { warn: () => {}, error: () => {} },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await bus.start(RUN_START);
|
|
226
|
+
await bus.finish(RUN_START.runId, FINISH);
|
|
227
|
+
|
|
228
|
+
expect(bus.counters.dispatched).toBe(2); // good's 2 events
|
|
229
|
+
expect(bus.counters.sinkErrors).toBe(2); // bad's 2 events
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TelemetryBus — fanout to multiple {@link TelemetrySink}s, non-blocking.
|
|
3
|
+
*
|
|
4
|
+
* Invariants :
|
|
5
|
+
* 1. Sink failures are ISOLATED — one sink throwing never affects the others
|
|
6
|
+
* nor the host agent loop. Failures are logged via the bus logger.
|
|
7
|
+
* 2. The bus itself implements {@link TelemetrySink}, so it can be passed
|
|
8
|
+
* transparently to consumers expecting a single sink (composition).
|
|
9
|
+
* 3. When `nonBlocking: true` (default), `start`/`step`/`finish` return
|
|
10
|
+
* immediately and dispatch in background. Errors surface via the logger.
|
|
11
|
+
* 4. Backpressure : if internal queue > `maxQueueDepth`, oldest events are
|
|
12
|
+
* dropped and counted.
|
|
13
|
+
*
|
|
14
|
+
* Ref: command-center:sprint-693:telemetry-bus
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
NOOP_TELEMETRY_SINK,
|
|
19
|
+
type TelemetryRunFinish,
|
|
20
|
+
type TelemetryRunStart,
|
|
21
|
+
type TelemetryRunStep,
|
|
22
|
+
type TelemetrySink,
|
|
23
|
+
} from "./port.js";
|
|
24
|
+
|
|
25
|
+
// ─── Minimal logger interface (avoid SDK dep) ────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface TelemetryLogger {
|
|
28
|
+
warn(obj: Record<string, unknown>, msg?: string): void;
|
|
29
|
+
error(obj: Record<string, unknown>, msg?: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NOOP_LOGGER: TelemetryLogger = {
|
|
33
|
+
warn: () => {
|
|
34
|
+
/* no-op */
|
|
35
|
+
},
|
|
36
|
+
error: () => {
|
|
37
|
+
/* no-op */
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ─── Bus options ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface TelemetryBusOptions {
|
|
44
|
+
sinks: readonly TelemetrySink[];
|
|
45
|
+
/** Pino-compatible logger. Defaults to no-op. */
|
|
46
|
+
logger?: TelemetryLogger;
|
|
47
|
+
/**
|
|
48
|
+
* When true (default), `start/step/finish` resolve immediately and dispatch
|
|
49
|
+
* in background. Set false in tests for deterministic assertions.
|
|
50
|
+
*/
|
|
51
|
+
nonBlocking?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Max number of in-flight events per sink before dropping oldest.
|
|
54
|
+
* Defaults to 1000.
|
|
55
|
+
*/
|
|
56
|
+
maxQueueDepth?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Telemetry counters (exposed for Prometheus or tests) ────────────────────
|
|
60
|
+
|
|
61
|
+
export interface TelemetryCounters {
|
|
62
|
+
/** Events dropped due to queue overflow. */
|
|
63
|
+
readonly dropped: number;
|
|
64
|
+
/** Sink errors caught and isolated. */
|
|
65
|
+
readonly sinkErrors: number;
|
|
66
|
+
/** Events successfully dispatched. */
|
|
67
|
+
readonly dispatched: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Implementation ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
interface PendingEvent {
|
|
73
|
+
kind: "start" | "step" | "finish";
|
|
74
|
+
exec: (sink: TelemetrySink) => Promise<unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class TelemetryBusImpl implements TelemetrySink {
|
|
78
|
+
readonly name = "telemetry-bus";
|
|
79
|
+
|
|
80
|
+
private readonly _sinks: readonly TelemetrySink[];
|
|
81
|
+
private readonly _logger: TelemetryLogger;
|
|
82
|
+
private readonly _nonBlocking: boolean;
|
|
83
|
+
private readonly _maxQueueDepth: number;
|
|
84
|
+
/** Per-sink queue of pending events (FIFO). */
|
|
85
|
+
private readonly _queues: PendingEvent[][];
|
|
86
|
+
/** Per-sink drain promise (single-flight). */
|
|
87
|
+
private readonly _draining: Array<Promise<void> | null>;
|
|
88
|
+
|
|
89
|
+
private _dropped = 0;
|
|
90
|
+
private _sinkErrors = 0;
|
|
91
|
+
private _dispatched = 0;
|
|
92
|
+
|
|
93
|
+
constructor(opts: TelemetryBusOptions) {
|
|
94
|
+
this._sinks = opts.sinks;
|
|
95
|
+
this._logger = opts.logger ?? NOOP_LOGGER;
|
|
96
|
+
this._nonBlocking = opts.nonBlocking ?? true;
|
|
97
|
+
this._maxQueueDepth = opts.maxQueueDepth ?? 1000;
|
|
98
|
+
this._queues = this._sinks.map(() => []);
|
|
99
|
+
this._draining = this._sinks.map(() => null);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get counters(): TelemetryCounters {
|
|
103
|
+
return {
|
|
104
|
+
dropped: this._dropped,
|
|
105
|
+
sinkErrors: this._sinkErrors,
|
|
106
|
+
dispatched: this._dispatched,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async start(event: TelemetryRunStart): Promise<void> {
|
|
111
|
+
return this._fanout({
|
|
112
|
+
kind: "start",
|
|
113
|
+
exec: (s) => s.start(event),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async step(runId: string, delta: TelemetryRunStep): Promise<void> {
|
|
118
|
+
return this._fanout({
|
|
119
|
+
kind: "step",
|
|
120
|
+
exec: (s) => s.step(runId, delta),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async finish(runId: string, event: TelemetryRunFinish): Promise<void> {
|
|
125
|
+
return this._fanout({
|
|
126
|
+
kind: "finish",
|
|
127
|
+
exec: (s) => s.finish(runId, event),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async flush(): Promise<void> {
|
|
132
|
+
await Promise.all([
|
|
133
|
+
...this._draining.map((p) => p ?? Promise.resolve()),
|
|
134
|
+
...this._sinks.map((s) =>
|
|
135
|
+
s.flush?.().catch((err) => {
|
|
136
|
+
this._sinkErrors += 1;
|
|
137
|
+
this._logger.warn(
|
|
138
|
+
{ sink: s.name, err: errorMessage(err) },
|
|
139
|
+
"telemetry-bus: flush failed"
|
|
140
|
+
);
|
|
141
|
+
})
|
|
142
|
+
),
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── internal ───────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
private async _fanout(evt: PendingEvent): Promise<void> {
|
|
149
|
+
if (this._nonBlocking) {
|
|
150
|
+
// Enqueue on each sink, kick drain in background.
|
|
151
|
+
this._sinks.forEach((_, i) => this._enqueue(i, evt));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Blocking mode (tests) — dispatch sequentially and surface errors via log.
|
|
155
|
+
await Promise.all(this._sinks.map((sink) => this._dispatchOne(sink, evt)));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private _enqueue(sinkIdx: number, evt: PendingEvent): void {
|
|
159
|
+
const queue = this._queues[sinkIdx]!;
|
|
160
|
+
if (queue.length >= this._maxQueueDepth) {
|
|
161
|
+
queue.shift(); // drop oldest
|
|
162
|
+
this._dropped += 1;
|
|
163
|
+
const sink = this._sinks[sinkIdx]!;
|
|
164
|
+
this._logger.warn(
|
|
165
|
+
{ sink: sink.name, depth: queue.length, dropped: this._dropped },
|
|
166
|
+
"telemetry-bus: queue overflow, oldest dropped"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
queue.push(evt);
|
|
170
|
+
void this._drain(sinkIdx);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async _drain(sinkIdx: number): Promise<void> {
|
|
174
|
+
if (this._draining[sinkIdx]) return;
|
|
175
|
+
const sink = this._sinks[sinkIdx]!;
|
|
176
|
+
const queue = this._queues[sinkIdx]!;
|
|
177
|
+
|
|
178
|
+
const promise = (async () => {
|
|
179
|
+
while (queue.length > 0) {
|
|
180
|
+
const evt = queue.shift()!;
|
|
181
|
+
await this._dispatchOne(sink, evt);
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
this._draining[sinkIdx] = promise;
|
|
185
|
+
try {
|
|
186
|
+
await promise;
|
|
187
|
+
} finally {
|
|
188
|
+
this._draining[sinkIdx] = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async _dispatchOne(
|
|
193
|
+
sink: TelemetrySink,
|
|
194
|
+
evt: PendingEvent
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
try {
|
|
197
|
+
await evt.exec(sink);
|
|
198
|
+
this._dispatched += 1;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this._sinkErrors += 1;
|
|
201
|
+
this._logger.warn(
|
|
202
|
+
{
|
|
203
|
+
sink: sink.name,
|
|
204
|
+
kind: evt.kind,
|
|
205
|
+
err: errorMessage(err),
|
|
206
|
+
},
|
|
207
|
+
"telemetry-bus: sink dispatch failed (isolated)"
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build a TelemetryBus that fans events to all sinks non-blockingly.
|
|
217
|
+
*
|
|
218
|
+
* If `sinks` is empty, returns {@link NOOP_TELEMETRY_SINK} so callers never
|
|
219
|
+
* need to null-check.
|
|
220
|
+
*/
|
|
221
|
+
export function createTelemetryBus(
|
|
222
|
+
opts: TelemetryBusOptions
|
|
223
|
+
): TelemetrySink & { counters: TelemetryCounters; flush(): Promise<void> } {
|
|
224
|
+
if (opts.sinks.length === 0) {
|
|
225
|
+
// Wrap NOOP with stub counters + flush for type consistency.
|
|
226
|
+
return {
|
|
227
|
+
...NOOP_TELEMETRY_SINK,
|
|
228
|
+
counters: { dropped: 0, sinkErrors: 0, dispatched: 0 },
|
|
229
|
+
async flush() {
|
|
230
|
+
/* no-op */
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return new TelemetryBusImpl(opts);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function errorMessage(err: unknown): string {
|
|
240
|
+
return err instanceof Error ? err.message : String(err);
|
|
241
|
+
}
|