@alisio/core 0.2.1 → 0.3.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/dist/application.d.ts +7 -0
- package/dist/application.js +71 -4
- package/dist/changelog/data.js +21 -0
- package/dist/commands/builtins.js +6 -0
- package/dist/commands/catalog.d.ts +8 -1
- package/dist/commands/catalog.js +62 -0
- package/dist/config.d.ts +37 -3
- package/dist/config.js +109 -7
- package/dist/core/contracts.d.ts +10 -1
- package/dist/core/runner.d.ts +3 -1
- package/dist/core/runner.js +2 -0
- package/dist/decisions/bound.d.ts +23 -0
- package/dist/decisions/bound.js +48 -0
- package/dist/decisions/breaker.d.ts +35 -0
- package/dist/decisions/breaker.js +61 -0
- package/dist/decisions/format.d.ts +15 -0
- package/dist/decisions/format.js +89 -0
- package/dist/decisions/index.d.ts +7 -0
- package/dist/decisions/index.js +7 -0
- package/dist/decisions/metrics.d.ts +45 -0
- package/dist/decisions/metrics.js +79 -0
- package/dist/decisions/registry.d.ts +22 -0
- package/dist/decisions/registry.js +64 -0
- package/dist/decisions/service.d.ts +137 -0
- package/dist/decisions/service.js +377 -0
- package/dist/decisions/validate.d.ts +26 -0
- package/dist/decisions/validate.js +182 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/plugins/host.d.ts +33 -1
- package/dist/plugins/host.js +94 -14
- package/package.json +2 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RunEventDataMap, ToolDecisions } from "@alisio/sdk";
|
|
2
|
+
import type { DecisionOutcome, DecisionService } from "./service.ts";
|
|
3
|
+
/** Reports one decision event of the run that owns the call (never persisted by the caller). */
|
|
4
|
+
export type DecisionAnnouncer = <K extends "decision_completed" | "decision_fallback">(type: K, data: RunEventDataMap[K]) => void;
|
|
5
|
+
/**
|
|
6
|
+
* The event of one outcome. Fields are copied one by one on purpose (ADR-7): the payload is
|
|
7
|
+
* metadata only, so nothing that rides along on the outcome can reach a persisted row.
|
|
8
|
+
*/
|
|
9
|
+
export declare function outcomeEvent(outcome: DecisionOutcome): {
|
|
10
|
+
type: "decision_completed";
|
|
11
|
+
data: RunEventDataMap["decision_completed"];
|
|
12
|
+
} | {
|
|
13
|
+
type: "decision_fallback";
|
|
14
|
+
data: RunEventDataMap["decision_fallback"];
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* `ToolContext.decisions` of one tool call (ADR-8): the consumer surface of the service, bound to
|
|
18
|
+
* the run. Metrics are attributed to the session; the event is announced only while
|
|
19
|
+
* `decisions.telemetry` is on (read per call, so a live change applies at once).
|
|
20
|
+
*/
|
|
21
|
+
export declare function bindDecisions(service: DecisionService, telemetry: () => boolean, call: {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
}, announce: DecisionAnnouncer): ToolDecisions;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event of one outcome. Fields are copied one by one on purpose (ADR-7): the payload is
|
|
3
|
+
* metadata only, so nothing that rides along on the outcome can reach a persisted row.
|
|
4
|
+
*/
|
|
5
|
+
export function outcomeEvent(outcome) {
|
|
6
|
+
const identity = {
|
|
7
|
+
decisionId: outcome.decisionId,
|
|
8
|
+
...(outcome.pack ? { pack: { id: outcome.pack.id, version: outcome.pack.version } } : {}),
|
|
9
|
+
provider: outcome.provider,
|
|
10
|
+
latencyMs: outcome.latencyMs,
|
|
11
|
+
};
|
|
12
|
+
if (outcome.status === "completed")
|
|
13
|
+
return {
|
|
14
|
+
type: "decision_completed",
|
|
15
|
+
data: {
|
|
16
|
+
...identity,
|
|
17
|
+
decisionCount: outcome.decisionCount,
|
|
18
|
+
rejectedCount: outcome.rejectedCount,
|
|
19
|
+
confidenceMin: outcome.confidenceMin,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
return { type: "decision_fallback", data: { ...identity, reason: outcome.reason } };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `ToolContext.decisions` of one tool call (ADR-8): the consumer surface of the service, bound to
|
|
26
|
+
* the run. Metrics are attributed to the session; the event is announced only while
|
|
27
|
+
* `decisions.telemetry` is on (read per call, so a live change applies at once).
|
|
28
|
+
*/
|
|
29
|
+
export function bindDecisions(service, telemetry, call, announce) {
|
|
30
|
+
return {
|
|
31
|
+
available: () => service.available(),
|
|
32
|
+
activeProvider: () => service.activeProvider(),
|
|
33
|
+
async tryDecide(req, opts) {
|
|
34
|
+
const { response, outcome } = await service.attempt(req, opts, { sessionId: call.sessionId });
|
|
35
|
+
if (outcome && telemetry()) {
|
|
36
|
+
const event = outcomeEvent(outcome);
|
|
37
|
+
// The observer path never invalidates a decision that already happened.
|
|
38
|
+
try {
|
|
39
|
+
announce(event.type, event.data);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* The consumer still gets its answer. */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return response;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type CircuitState = "closed" | "open" | "half-open";
|
|
2
|
+
export interface CircuitBreakerOptions {
|
|
3
|
+
/** Consecutive counted failures that open the circuit. */
|
|
4
|
+
threshold?: number;
|
|
5
|
+
/** How long the circuit stays open before a single probe is allowed. */
|
|
6
|
+
openMs?: number;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Circuit breaker with an injected clock. The caller reports only the failures that count (the
|
|
11
|
+
* service maps provider errors to `failure()`, `success()` or `neutral()`):
|
|
12
|
+
* - `closed`: calls flow; `threshold` consecutive failures open it.
|
|
13
|
+
* - `open`: `allow()` answers "open" until `openMs` pass, then the next caller is the single probe.
|
|
14
|
+
* - `half-open`: one probe in flight; concurrent callers get "open". The probe's `success()`
|
|
15
|
+
* closes the circuit and its `failure()` re-opens it for another `openMs`.
|
|
16
|
+
* `neutral()` ends a call that says nothing about the provider's health (a fast `not_ready`, a
|
|
17
|
+
* caller abort): it releases the probe without counting or resetting anything.
|
|
18
|
+
*/
|
|
19
|
+
export declare class CircuitBreaker {
|
|
20
|
+
private readonly threshold;
|
|
21
|
+
private readonly openMs;
|
|
22
|
+
private readonly now;
|
|
23
|
+
private failures;
|
|
24
|
+
private openedAt;
|
|
25
|
+
private probing;
|
|
26
|
+
constructor(options?: CircuitBreakerOptions);
|
|
27
|
+
/** Asks to run one call. "half-open" means the caller IS the single probe. */
|
|
28
|
+
allow(): CircuitState;
|
|
29
|
+
success(): void;
|
|
30
|
+
failure(): void;
|
|
31
|
+
neutral(): void;
|
|
32
|
+
reset(): void;
|
|
33
|
+
/** Read-only view for diagnostics; it never consumes the probe. */
|
|
34
|
+
state(): CircuitState;
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker with an injected clock. The caller reports only the failures that count (the
|
|
3
|
+
* service maps provider errors to `failure()`, `success()` or `neutral()`):
|
|
4
|
+
* - `closed`: calls flow; `threshold` consecutive failures open it.
|
|
5
|
+
* - `open`: `allow()` answers "open" until `openMs` pass, then the next caller is the single probe.
|
|
6
|
+
* - `half-open`: one probe in flight; concurrent callers get "open". The probe's `success()`
|
|
7
|
+
* closes the circuit and its `failure()` re-opens it for another `openMs`.
|
|
8
|
+
* `neutral()` ends a call that says nothing about the provider's health (a fast `not_ready`, a
|
|
9
|
+
* caller abort): it releases the probe without counting or resetting anything.
|
|
10
|
+
*/
|
|
11
|
+
export class CircuitBreaker {
|
|
12
|
+
threshold;
|
|
13
|
+
openMs;
|
|
14
|
+
now;
|
|
15
|
+
failures = 0;
|
|
16
|
+
openedAt;
|
|
17
|
+
probing = false;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.threshold = options.threshold ?? 3;
|
|
20
|
+
this.openMs = options.openMs ?? 30_000;
|
|
21
|
+
this.now = options.now ?? (() => Date.now());
|
|
22
|
+
}
|
|
23
|
+
/** Asks to run one call. "half-open" means the caller IS the single probe. */
|
|
24
|
+
allow() {
|
|
25
|
+
if (this.openedAt === undefined)
|
|
26
|
+
return "closed";
|
|
27
|
+
if (this.probing)
|
|
28
|
+
return "open";
|
|
29
|
+
if (this.now() - this.openedAt < this.openMs)
|
|
30
|
+
return "open";
|
|
31
|
+
this.probing = true;
|
|
32
|
+
return "half-open";
|
|
33
|
+
}
|
|
34
|
+
success() {
|
|
35
|
+
this.reset();
|
|
36
|
+
}
|
|
37
|
+
failure() {
|
|
38
|
+
this.probing = false;
|
|
39
|
+
if (this.openedAt !== undefined) {
|
|
40
|
+
this.openedAt = this.now();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.failures++;
|
|
44
|
+
if (this.failures >= this.threshold)
|
|
45
|
+
this.openedAt = this.now();
|
|
46
|
+
}
|
|
47
|
+
neutral() {
|
|
48
|
+
this.probing = false;
|
|
49
|
+
}
|
|
50
|
+
reset() {
|
|
51
|
+
this.failures = 0;
|
|
52
|
+
this.openedAt = undefined;
|
|
53
|
+
this.probing = false;
|
|
54
|
+
}
|
|
55
|
+
/** Read-only view for diagnostics; it never consumes the probe. */
|
|
56
|
+
state() {
|
|
57
|
+
if (this.openedAt === undefined)
|
|
58
|
+
return "closed";
|
|
59
|
+
return this.probing || this.now() - this.openedAt >= this.openMs ? "half-open" : "open";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DecisionStats } from "@alisio/sdk";
|
|
2
|
+
import type { DecisionMetricsSnapshot } from "./metrics.ts";
|
|
3
|
+
import type { DecisionStatus, DecisionsConfig } from "./service.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The "Decision Intelligence" block of `/stats` (spec §9.2), shared by the core handler and the
|
|
6
|
+
* TUI report. `undefined` when no decision happened, so the section is omitted.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatDecisionStatsSection(stats: DecisionStats): string | undefined;
|
|
9
|
+
/** The `/decisions` report (spec §9.1). */
|
|
10
|
+
export declare function formatDecisionsReport(input: {
|
|
11
|
+
status: DecisionStatus;
|
|
12
|
+
config: Pick<DecisionsConfig, "timeoutMs" | "minConfidence" | "telemetry">;
|
|
13
|
+
session: DecisionMetricsSnapshot;
|
|
14
|
+
process: DecisionMetricsSnapshot;
|
|
15
|
+
}): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** `12:04:31 UTC`: deterministic, independent of the viewer's time zone. */
|
|
2
|
+
const clock = (iso) => {
|
|
3
|
+
const date = new Date(iso);
|
|
4
|
+
return Number.isNaN(date.getTime()) ? iso : `${date.toISOString().slice(11, 19)} UTC`;
|
|
5
|
+
};
|
|
6
|
+
const latency = (s) => s.avgLatencyMs === undefined ? undefined : `avg ${s.avgLatencyMs} ms · p95 ${s.p95LatencyMs} ms`;
|
|
7
|
+
const packsLine = (packs) => Object.entries(packs)
|
|
8
|
+
.map(([id, count]) => `${id} (${count})`)
|
|
9
|
+
.join(", ");
|
|
10
|
+
const lastFallbackLine = (f) => `Last fallback: ${f.reason} (${f.decisionId}) at ${clock(f.at)}`;
|
|
11
|
+
/**
|
|
12
|
+
* The "Decision Intelligence" block of `/stats` (spec §9.2), shared by the core handler and the
|
|
13
|
+
* TUI report. `undefined` when no decision happened, so the section is omitted.
|
|
14
|
+
*/
|
|
15
|
+
export function formatDecisionStatsSection(stats) {
|
|
16
|
+
if (stats.requests <= 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
const lat = latency(stats);
|
|
19
|
+
const packs = packsLine(stats.packs);
|
|
20
|
+
return [
|
|
21
|
+
"**Decision Intelligence**",
|
|
22
|
+
"",
|
|
23
|
+
...(stats.provider ? [`- Provider: ${stats.provider}`] : []),
|
|
24
|
+
`- Requests: ${stats.requests} · completed ${stats.completed} · fallbacks ${stats.fallbacks}`,
|
|
25
|
+
...(lat ? [`- Latency: ${lat}`] : []),
|
|
26
|
+
...(stats.lastFallback ? [`- ${lastFallbackLine(stats.lastFallback)}`] : []),
|
|
27
|
+
...(packs ? [`- Packs: ${packs}`] : []),
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
const MESSAGE_LIMIT = 200;
|
|
31
|
+
/** The `/decisions` report (spec §9.1). */
|
|
32
|
+
export function formatDecisionsReport(input) {
|
|
33
|
+
const { status, config, session, process } = input;
|
|
34
|
+
let head;
|
|
35
|
+
if (!status.enabled)
|
|
36
|
+
head = "disabled";
|
|
37
|
+
else if (status.configured === null)
|
|
38
|
+
head = "enabled · no provider configured (set decisions.provider)";
|
|
39
|
+
else if (!status.active)
|
|
40
|
+
head = `enabled · provider "${status.configured}" is not registered`;
|
|
41
|
+
else {
|
|
42
|
+
const health = status.health
|
|
43
|
+
? `${status.health.status}${status.health.detail ? ` (${status.health.detail})` : ""}`
|
|
44
|
+
: "unknown";
|
|
45
|
+
head = `enabled · provider: ${status.active.id} (${status.active.name}) · health: ${health}`;
|
|
46
|
+
}
|
|
47
|
+
const caps = status.active
|
|
48
|
+
? Object.entries(status.active.capabilities)
|
|
49
|
+
.filter(([, on]) => on === true)
|
|
50
|
+
.map(([name]) => name)
|
|
51
|
+
: [];
|
|
52
|
+
const err = status.lastLifecycleError;
|
|
53
|
+
const row = (label, m) => `| ${label} | ${m.requests} | ${m.completed} | ${m.fallbacks} |`;
|
|
54
|
+
const lat = [
|
|
55
|
+
latency(session) ? `session ${latency(session)}` : undefined,
|
|
56
|
+
latency(process) ? `process ${latency(process)}` : undefined,
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
const fallback = session.lastFallback ?? process.lastFallback;
|
|
59
|
+
const packScope = Object.keys(session.packs).length ? session.packs : process.packs;
|
|
60
|
+
const packs = packsLine(packScope);
|
|
61
|
+
return [
|
|
62
|
+
"**Decision Intelligence**",
|
|
63
|
+
"",
|
|
64
|
+
`- Status: ${head}`,
|
|
65
|
+
...(caps.length ? [`- Capabilities: ${caps.join(", ")}`] : []),
|
|
66
|
+
...(status.active ? [`- Circuit: ${status.circuit}`] : []),
|
|
67
|
+
`- Timeout: ${config.timeoutMs} ms · min confidence: ${config.minConfidence} · telemetry: ${config.telemetry ? "on" : "off"}`,
|
|
68
|
+
...(err
|
|
69
|
+
? [
|
|
70
|
+
`- Lifecycle error: ${err.phase} of ${err.provider} failed: ${err.message.slice(0, MESSAGE_LIMIT)}`,
|
|
71
|
+
]
|
|
72
|
+
: []),
|
|
73
|
+
"",
|
|
74
|
+
...(process.requests > 0
|
|
75
|
+
? [
|
|
76
|
+
"| Scope | Requests | Completed | Fallbacks |",
|
|
77
|
+
"| --- | --- | --- | --- |",
|
|
78
|
+
row("This session", session),
|
|
79
|
+
row("Process", process),
|
|
80
|
+
"",
|
|
81
|
+
...(lat.length ? [`- Latency: ${lat.join("; ")}`] : []),
|
|
82
|
+
...(fallback ? [`- ${lastFallbackLine(fallback)}`] : []),
|
|
83
|
+
...(packs
|
|
84
|
+
? [`- Packs${Object.keys(session.packs).length ? "" : " (process)"}: ${packs}`]
|
|
85
|
+
: []),
|
|
86
|
+
]
|
|
87
|
+
: ["No decisions yet in this process."]),
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { bindDecisions, type DecisionAnnouncer, outcomeEvent } from "./bound.ts";
|
|
2
|
+
export { CircuitBreaker, type CircuitBreakerOptions, type CircuitState } from "./breaker.ts";
|
|
3
|
+
export { formatDecisionStatsSection, formatDecisionsReport } from "./format.ts";
|
|
4
|
+
export { type DecisionMetricEntry, DecisionMetrics, type DecisionMetricsSnapshot, } from "./metrics.ts";
|
|
5
|
+
export { DecisionRegistry, type RegisteredDecisionProvider } from "./registry.ts";
|
|
6
|
+
export { DEFAULT_DECISIONS_CONFIG, type DecisionAttempt, DecisionError, type DecisionOutcome, DecisionService, type DecisionServiceDeps, type DecisionStatus, type DecisionsConfig, HEALTH_TIMEOUT_MS, } from "./service.ts";
|
|
7
|
+
export { DECISION_LIMITS, splitSupported, validateAnswers, validateRequest } from "./validate.ts";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { bindDecisions, outcomeEvent } from "./bound.js";
|
|
2
|
+
export { CircuitBreaker } from "./breaker.js";
|
|
3
|
+
export { formatDecisionStatsSection, formatDecisionsReport } from "./format.js";
|
|
4
|
+
export { DecisionMetrics, } from "./metrics.js";
|
|
5
|
+
export { DecisionRegistry } from "./registry.js";
|
|
6
|
+
export { DEFAULT_DECISIONS_CONFIG, DecisionError, DecisionService, HEALTH_TIMEOUT_MS, } from "./service.js";
|
|
7
|
+
export { DECISION_LIMITS, splitSupported, validateAnswers, validateRequest } from "./validate.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DecisionFallbackReason } from "@alisio/sdk";
|
|
2
|
+
export type DecisionMetricEntry = {
|
|
3
|
+
status: "completed";
|
|
4
|
+
decisionId: string;
|
|
5
|
+
pack?: {
|
|
6
|
+
id: string;
|
|
7
|
+
version: number;
|
|
8
|
+
};
|
|
9
|
+
provider: string;
|
|
10
|
+
latencyMs: number;
|
|
11
|
+
} | {
|
|
12
|
+
status: "fallback";
|
|
13
|
+
decisionId: string;
|
|
14
|
+
pack?: {
|
|
15
|
+
id: string;
|
|
16
|
+
version: number;
|
|
17
|
+
};
|
|
18
|
+
provider: string;
|
|
19
|
+
latencyMs: number;
|
|
20
|
+
reason: DecisionFallbackReason;
|
|
21
|
+
};
|
|
22
|
+
export interface DecisionMetricsSnapshot {
|
|
23
|
+
requests: number;
|
|
24
|
+
completed: number;
|
|
25
|
+
fallbacks: number;
|
|
26
|
+
avgLatencyMs?: number;
|
|
27
|
+
p95LatencyMs?: number;
|
|
28
|
+
lastFallback?: {
|
|
29
|
+
reason: DecisionFallbackReason;
|
|
30
|
+
decisionId: string;
|
|
31
|
+
at: string;
|
|
32
|
+
};
|
|
33
|
+
packs: Record<string, number>;
|
|
34
|
+
provider?: string;
|
|
35
|
+
}
|
|
36
|
+
/** In-memory, per-process decision metrics with an optional per-session view. Never persisted. */
|
|
37
|
+
export declare class DecisionMetrics {
|
|
38
|
+
private now;
|
|
39
|
+
private process;
|
|
40
|
+
private sessions;
|
|
41
|
+
constructor(now?: () => number);
|
|
42
|
+
record(entry: DecisionMetricEntry, sessionId?: string): void;
|
|
43
|
+
/** The session's view, or the whole process when no session is given. */
|
|
44
|
+
snapshot(sessionId?: string): DecisionMetricsSnapshot;
|
|
45
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const LATENCY_RING = 200;
|
|
2
|
+
const MAX_SESSIONS = 200;
|
|
3
|
+
class Tally {
|
|
4
|
+
completed = 0;
|
|
5
|
+
fallbacks = 0;
|
|
6
|
+
provider;
|
|
7
|
+
lastFallback;
|
|
8
|
+
packs = new Map();
|
|
9
|
+
/** Ring of the last completed latencies. */
|
|
10
|
+
latencies = [];
|
|
11
|
+
next = 0;
|
|
12
|
+
add(entry, at) {
|
|
13
|
+
this.provider = entry.provider;
|
|
14
|
+
if (entry.pack)
|
|
15
|
+
this.packs.set(entry.pack.id, (this.packs.get(entry.pack.id) ?? 0) + 1);
|
|
16
|
+
if (entry.status === "completed") {
|
|
17
|
+
this.completed++;
|
|
18
|
+
if (this.latencies.length < LATENCY_RING)
|
|
19
|
+
this.latencies.push(entry.latencyMs);
|
|
20
|
+
else
|
|
21
|
+
this.latencies[this.next] = entry.latencyMs;
|
|
22
|
+
this.next = (this.next + 1) % LATENCY_RING;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
this.fallbacks++;
|
|
26
|
+
this.lastFallback = { reason: entry.reason, decisionId: entry.decisionId, at };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
snapshot() {
|
|
30
|
+
const out = {
|
|
31
|
+
requests: this.completed + this.fallbacks,
|
|
32
|
+
completed: this.completed,
|
|
33
|
+
fallbacks: this.fallbacks,
|
|
34
|
+
packs: Object.fromEntries(this.packs),
|
|
35
|
+
};
|
|
36
|
+
if (this.latencies.length) {
|
|
37
|
+
const sorted = [...this.latencies].sort((a, b) => a - b);
|
|
38
|
+
out.avgLatencyMs = Math.round(sorted.reduce((sum, v) => sum + v, 0) / sorted.length);
|
|
39
|
+
out.p95LatencyMs = sorted[Math.ceil(0.95 * sorted.length) - 1];
|
|
40
|
+
}
|
|
41
|
+
if (this.lastFallback)
|
|
42
|
+
out.lastFallback = { ...this.lastFallback };
|
|
43
|
+
if (this.provider)
|
|
44
|
+
out.provider = this.provider;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** In-memory, per-process decision metrics with an optional per-session view. Never persisted. */
|
|
49
|
+
export class DecisionMetrics {
|
|
50
|
+
now;
|
|
51
|
+
process = new Tally();
|
|
52
|
+
sessions = new Map();
|
|
53
|
+
constructor(now = () => Date.now()) {
|
|
54
|
+
this.now = now;
|
|
55
|
+
}
|
|
56
|
+
record(entry, sessionId) {
|
|
57
|
+
const at = new Date(this.now()).toISOString();
|
|
58
|
+
this.process.add(entry, at);
|
|
59
|
+
if (!sessionId)
|
|
60
|
+
return;
|
|
61
|
+
let tally = this.sessions.get(sessionId);
|
|
62
|
+
if (!tally) {
|
|
63
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
64
|
+
const oldest = this.sessions.keys().next().value;
|
|
65
|
+
if (oldest !== undefined)
|
|
66
|
+
this.sessions.delete(oldest);
|
|
67
|
+
}
|
|
68
|
+
tally = new Tally();
|
|
69
|
+
this.sessions.set(sessionId, tally);
|
|
70
|
+
}
|
|
71
|
+
tally.add(entry, at);
|
|
72
|
+
}
|
|
73
|
+
/** The session's view, or the whole process when no session is given. */
|
|
74
|
+
snapshot(sessionId) {
|
|
75
|
+
if (sessionId === undefined)
|
|
76
|
+
return this.process.snapshot();
|
|
77
|
+
return (this.sessions.get(sessionId) ?? new Tally()).snapshot();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DecisionProvider } from "@alisio/sdk";
|
|
2
|
+
export interface RegisteredDecisionProvider {
|
|
3
|
+
owner: string;
|
|
4
|
+
provider: DecisionProvider;
|
|
5
|
+
}
|
|
6
|
+
/** Catalog of decision providers. Registering never activates: the configuration picks one. */
|
|
7
|
+
export declare class DecisionRegistry {
|
|
8
|
+
private entries;
|
|
9
|
+
private listeners;
|
|
10
|
+
/** Notified after every successful registration and removal (never throws into callers). */
|
|
11
|
+
onChange(listener: () => void): () => void;
|
|
12
|
+
private changed;
|
|
13
|
+
/** Returns the function that removes this registration (a no-op once it was replaced). */
|
|
14
|
+
register(owner: string, provider: DecisionProvider): () => void;
|
|
15
|
+
get(id: string): RegisteredDecisionProvider | undefined;
|
|
16
|
+
list(): Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
owner: string;
|
|
20
|
+
capabilities: DecisionProvider["capabilities"];
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const PROVIDER_ID = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
2
|
+
/** Catalog of decision providers. Registering never activates: the configuration picks one. */
|
|
3
|
+
export class DecisionRegistry {
|
|
4
|
+
entries = new Map();
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
/** Notified after every successful registration and removal (never throws into callers). */
|
|
7
|
+
onChange(listener) {
|
|
8
|
+
this.listeners.add(listener);
|
|
9
|
+
return () => {
|
|
10
|
+
this.listeners.delete(listener);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
changed() {
|
|
14
|
+
for (const listener of this.listeners) {
|
|
15
|
+
try {
|
|
16
|
+
listener();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* Observers cannot break registration. */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Returns the function that removes this registration (a no-op once it was replaced). */
|
|
24
|
+
register(owner, provider) {
|
|
25
|
+
if (!provider || typeof provider !== "object")
|
|
26
|
+
throw new Error("Invalid decision provider");
|
|
27
|
+
if (typeof provider.id !== "string" || !PROVIDER_ID.test(provider.id))
|
|
28
|
+
throw new Error(`Invalid decision provider id: ${String(provider.id)}`);
|
|
29
|
+
if (typeof provider.name !== "string" || !provider.name.trim())
|
|
30
|
+
throw new Error(`Decision provider ${provider.id} needs a name`);
|
|
31
|
+
const capabilities = provider.capabilities;
|
|
32
|
+
if (!capabilities ||
|
|
33
|
+
typeof capabilities.select !== "boolean" ||
|
|
34
|
+
typeof capabilities.boolean !== "boolean" ||
|
|
35
|
+
typeof capabilities.ordinal !== "boolean")
|
|
36
|
+
throw new Error(`Decision provider ${provider.id} needs boolean capabilities`);
|
|
37
|
+
if (typeof provider.decide !== "function")
|
|
38
|
+
throw new Error(`Decision provider ${provider.id} needs a decide function`);
|
|
39
|
+
if (this.entries.has(provider.id))
|
|
40
|
+
throw new Error(`Duplicate decision provider: ${provider.id}`);
|
|
41
|
+
const entry = { owner, provider };
|
|
42
|
+
this.entries.set(provider.id, entry);
|
|
43
|
+
this.changed();
|
|
44
|
+
return () => {
|
|
45
|
+
if (this.entries.get(provider.id) !== entry)
|
|
46
|
+
return;
|
|
47
|
+
this.entries.delete(provider.id);
|
|
48
|
+
this.changed();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
get(id) {
|
|
52
|
+
return this.entries.get(id);
|
|
53
|
+
}
|
|
54
|
+
list() {
|
|
55
|
+
return [...this.entries.values()]
|
|
56
|
+
.map(({ owner, provider }) => ({
|
|
57
|
+
id: provider.id,
|
|
58
|
+
name: provider.name,
|
|
59
|
+
owner,
|
|
60
|
+
capabilities: { ...provider.capabilities },
|
|
61
|
+
}))
|
|
62
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type DecisionFallbackReason, type DecisionOptions, type DecisionProvider, type DecisionRequest, type DecisionResponse, type DecisionsApi } from "@alisio/sdk";
|
|
2
|
+
import { type CircuitBreakerOptions, type CircuitState } from "./breaker.ts";
|
|
3
|
+
import type { DecisionMetrics } from "./metrics.ts";
|
|
4
|
+
import type { DecisionRegistry } from "./registry.ts";
|
|
5
|
+
/** The slice of the `decisions` configuration block the service reads (live, per call). */
|
|
6
|
+
export interface DecisionsConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
/** Id of the active provider; `null` disables the feature. */
|
|
9
|
+
provider: string | null;
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
minConfidence: number;
|
|
12
|
+
telemetry: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_DECISIONS_CONFIG: DecisionsConfig;
|
|
15
|
+
/** `/decisions` asks the provider's health with its own short limit. */
|
|
16
|
+
export declare const HEALTH_TIMEOUT_MS = 1000;
|
|
17
|
+
type Pack = {
|
|
18
|
+
id: string;
|
|
19
|
+
version: number;
|
|
20
|
+
};
|
|
21
|
+
export type DecisionOutcome = {
|
|
22
|
+
status: "completed";
|
|
23
|
+
decisionId: string;
|
|
24
|
+
pack?: Pack;
|
|
25
|
+
provider: string;
|
|
26
|
+
latencyMs: number;
|
|
27
|
+
decisionCount: number;
|
|
28
|
+
rejectedCount: number;
|
|
29
|
+
confidenceMin: number;
|
|
30
|
+
} | {
|
|
31
|
+
status: "fallback";
|
|
32
|
+
decisionId: string;
|
|
33
|
+
pack?: Pack;
|
|
34
|
+
provider: string;
|
|
35
|
+
latencyMs: number;
|
|
36
|
+
reason: DecisionFallbackReason;
|
|
37
|
+
};
|
|
38
|
+
export interface DecisionAttempt {
|
|
39
|
+
response: DecisionResponse | null;
|
|
40
|
+
/** Absent when no attempt happened (disabled or no active provider). */
|
|
41
|
+
outcome?: DecisionOutcome;
|
|
42
|
+
}
|
|
43
|
+
/** `decide()` failure: the typed counterpart of a `null` from `tryDecide`. */
|
|
44
|
+
export declare class DecisionError extends Error {
|
|
45
|
+
readonly reason: DecisionFallbackReason | "disabled" | "no_provider";
|
|
46
|
+
constructor(reason: DecisionFallbackReason | "disabled" | "no_provider", message?: string);
|
|
47
|
+
}
|
|
48
|
+
export interface DecisionStatus {
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
/** The configured provider id, whether or not it is registered. */
|
|
51
|
+
configured: string | null;
|
|
52
|
+
active: {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
capabilities: DecisionProvider["capabilities"];
|
|
56
|
+
} | null;
|
|
57
|
+
registered: ReturnType<DecisionRegistry["list"]>;
|
|
58
|
+
health?: {
|
|
59
|
+
status: "ready" | "starting" | "unavailable";
|
|
60
|
+
detail?: string;
|
|
61
|
+
};
|
|
62
|
+
circuit: CircuitState;
|
|
63
|
+
/** Last `activate`/`deactivate` failure of any provider (cleared by a later successful activate). */
|
|
64
|
+
lastLifecycleError?: {
|
|
65
|
+
provider: string;
|
|
66
|
+
phase: "activate" | "deactivate";
|
|
67
|
+
message: string;
|
|
68
|
+
at: number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface DecisionServiceDeps {
|
|
72
|
+
registry: DecisionRegistry;
|
|
73
|
+
/** Read on every call so configuration changes apply to the next decision. */
|
|
74
|
+
config: () => DecisionsConfig;
|
|
75
|
+
metrics: DecisionMetrics;
|
|
76
|
+
now?: () => number;
|
|
77
|
+
breaker?: CircuitBreakerOptions;
|
|
78
|
+
/** Provider lifecycle (ADR-11): the limit of each call and where failures are logged. */
|
|
79
|
+
lifecycle?: {
|
|
80
|
+
timeoutMs?: () => number;
|
|
81
|
+
log?: (message: string) => void;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decision Intelligence service: one active provider (chosen by configuration), request and answer
|
|
86
|
+
* validation per decision, a core-enforced time limit and a circuit breaker. Without an active
|
|
87
|
+
* provider it is a silent null object: no metrics, no events.
|
|
88
|
+
*/
|
|
89
|
+
export declare class DecisionService implements Omit<DecisionsApi, "registerProvider"> {
|
|
90
|
+
private readonly registry;
|
|
91
|
+
private readonly config;
|
|
92
|
+
private readonly metrics;
|
|
93
|
+
private readonly now;
|
|
94
|
+
private readonly breaker;
|
|
95
|
+
/** Registration the breaker state belongs to; a change of provider resets it. */
|
|
96
|
+
private breakerOwner;
|
|
97
|
+
private readonly lifecycle;
|
|
98
|
+
/** The registration `activate` was last requested for (the one that is active, lifecycle-wise). */
|
|
99
|
+
private lifecycleEntry;
|
|
100
|
+
private lifecycleOn;
|
|
101
|
+
private closed;
|
|
102
|
+
/** Per-registration queue: `deactivate` never crosses a still-running `activate`. */
|
|
103
|
+
private queues;
|
|
104
|
+
private lifecycleError;
|
|
105
|
+
constructor(deps: DecisionServiceDeps);
|
|
106
|
+
/**
|
|
107
|
+
* Lifecycle (ADR-11): makes `activate`/`deactivate` follow what the configuration and the
|
|
108
|
+
* registry say is the active provider. Idempotent, synchronous and never throws: the hooks run
|
|
109
|
+
* in the background (bounded, serialized per provider, failures recorded). The first call (after
|
|
110
|
+
* the plugins loaded) also arms the follow-up on registry changes.
|
|
111
|
+
*/
|
|
112
|
+
syncActive(): void;
|
|
113
|
+
/** Close: deactivates the active provider (bounded by `timeoutMs`) and stops following changes. */
|
|
114
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
115
|
+
private enqueue;
|
|
116
|
+
/** Runs one hook with a limit; resolves always (a failure is recorded, never propagated). */
|
|
117
|
+
private runLifecycle;
|
|
118
|
+
private active;
|
|
119
|
+
available(): boolean;
|
|
120
|
+
activeProvider(): {
|
|
121
|
+
id: string;
|
|
122
|
+
name: string;
|
|
123
|
+
} | null;
|
|
124
|
+
tryDecide(req: DecisionRequest, opts?: DecisionOptions): Promise<DecisionResponse | null>;
|
|
125
|
+
/** Like `tryDecide`, but a missing response throws a typed `DecisionError`. */
|
|
126
|
+
decide(req: DecisionRequest, opts?: DecisionOptions): Promise<DecisionResponse>;
|
|
127
|
+
/**
|
|
128
|
+
* The full result of one call, with the outcome the run integration turns into an event.
|
|
129
|
+
* Throws `DecisionRequestError` for a malformed request and rethrows the caller's abort.
|
|
130
|
+
*/
|
|
131
|
+
attempt(req: DecisionRequest, opts?: DecisionOptions, ctx?: {
|
|
132
|
+
sessionId?: string;
|
|
133
|
+
}): Promise<DecisionAttempt>;
|
|
134
|
+
/** For `/decisions` only: the one place `health()` is called, with its own short limit. */
|
|
135
|
+
status(signal?: AbortSignal): Promise<DecisionStatus>;
|
|
136
|
+
}
|
|
137
|
+
export {};
|