@driftwatch/sdk 0.2.1 → 0.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/LICENSE +21 -0
- package/README.md +22 -6
- package/dist/agent/runner.js +1 -1
- package/dist/autopilot/actions.d.ts +24 -0
- package/dist/autopilot/actions.js +125 -0
- package/dist/autopilot/approval-service.d.ts +45 -0
- package/dist/autopilot/approval-service.js +108 -0
- package/dist/autopilot/memory-store.d.ts +29 -0
- package/dist/autopilot/memory-store.js +84 -0
- package/dist/autopilot/notify-dispatch.d.ts +27 -0
- package/dist/autopilot/notify-dispatch.js +27 -0
- package/dist/autopilot/redis-store.d.ts +19 -0
- package/dist/autopilot/redis-store.js +126 -0
- package/dist/autopilot/scheduler.d.ts +48 -0
- package/dist/autopilot/scheduler.js +144 -0
- package/dist/autopilot/types.d.ts +13 -5
- package/dist/drift/detector.js +58 -4
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/redis.d.ts +8 -0
- package/dist/redis.js +8 -0
- package/dist/telemetry/otel.d.ts +1 -1
- package/dist/telemetry/otel.js +1 -1
- package/package.json +84 -71
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victory Lucky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -138,12 +138,28 @@ const config = DriftWatchConfigSchema.parse({
|
|
|
138
138
|
labelled span + the `agent.tool.calls` / `agent.tool.duration` metrics.
|
|
139
139
|
- `DriftWatchConfigSchema` / `loadDriftWatchConfigFromEnv` — Zod-validated
|
|
140
140
|
typed config for telemetry, agent, and drift-detection settings.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
- `ApprovalService` / `AutopilotScheduler` / `executeControlAction` — the full
|
|
142
|
+
perceive→reason→act orchestration engine: runs drift detection on a
|
|
143
|
+
schedule, evaluates policies, dispatches notifications, and manages
|
|
144
|
+
human-in-the-loop approvals for control actions (pause/rollback/throttle/
|
|
145
|
+
switch_model). Built entirely on the `StateStore`/`Notifier` interfaces
|
|
146
|
+
below — no concrete I/O, so it costs nothing to import.
|
|
147
|
+
- `MemoryStateStore` — the zero-dependency `StateStore` implementation
|
|
148
|
+
(single-process). For multi-process, `RedisStateStore` lives at the isolated
|
|
149
|
+
subpath `@driftwatch/sdk/redis`, with `ioredis` as an optional peer
|
|
150
|
+
dependency — importing the package root never pulls it in.
|
|
151
|
+
- `evaluatePolicies` — the pure function mapping a `DriftReport` + policy
|
|
152
|
+
config to a list of action intents.
|
|
153
|
+
|
|
154
|
+
Concrete Slack/Telegram/webhook notifiers and inbound-webhook signature
|
|
155
|
+
verification are a separate companion package,
|
|
156
|
+
[`@driftwatch/autopilot`](https://www.npmjs.com/package/@driftwatch/autopilot) —
|
|
157
|
+
install it only if you use those channels.
|
|
158
|
+
|
|
159
|
+
Full docs, architecture, and the reference Fastify server that uses this SDK
|
|
160
|
+
are at **[drift-watch-docs.vercel.app](https://drift-watch-docs.vercel.app)**;
|
|
161
|
+
the [root README](https://github.com/codewithveek/drift-watch#readme) has the
|
|
162
|
+
full picture.
|
|
147
163
|
|
|
148
164
|
## License
|
|
149
165
|
|
package/dist/agent/runner.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ActionType, AgentRuntimeState, DriftSeverity, StateStore } from './types.js';
|
|
2
|
+
export interface ControlActionContext {
|
|
3
|
+
/** Free-text reason recorded to the audit log. */
|
|
4
|
+
reason: string;
|
|
5
|
+
/** Who triggered it (e.g. a Slack user id, "console", "scheduler"). */
|
|
6
|
+
actor?: string;
|
|
7
|
+
/** Which surface triggered it: 'console' | 'slack' | 'telegram' | 'system'. */
|
|
8
|
+
channel?: string;
|
|
9
|
+
severity?: DriftSeverity;
|
|
10
|
+
/** Target model id for switch_model. */
|
|
11
|
+
targetModel?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ControlActionResult {
|
|
14
|
+
action: ActionType;
|
|
15
|
+
applied: boolean;
|
|
16
|
+
state: AgentRuntimeState;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Apply a single control action to the shared agent state and append an audit
|
|
20
|
+
* record. Returns the resulting state. Notify actions are rejected here — they
|
|
21
|
+
* do not belong on the control path.
|
|
22
|
+
*/
|
|
23
|
+
export declare function executeControlAction(store: StateStore, action: ActionType, context: ControlActionContext): Promise<ControlActionResult>;
|
|
24
|
+
//# sourceMappingURL=actions.d.ts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-action executor — the "act" half of Loop 2.
|
|
3
|
+
*
|
|
4
|
+
* These functions mutate the *shared* runtime state (via the StateStore) so
|
|
5
|
+
* that every process and the console converge on the same posture. Control
|
|
6
|
+
* actions are only ever executed here: the scheduler enqueues them behind an
|
|
7
|
+
* approval, and the approval service calls back into this module once a human
|
|
8
|
+
* (from the console, Slack, or Telegram) has said yes.
|
|
9
|
+
*
|
|
10
|
+
* Notify actions are NOT handled here — they are safe side effects dispatched
|
|
11
|
+
* directly through the notifiers by the scheduler/approval service.
|
|
12
|
+
*/
|
|
13
|
+
import { trace, metrics } from '@opentelemetry/api';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
const tracer = trace.getTracer('driftwatch');
|
|
16
|
+
/**
|
|
17
|
+
* Lazily created (see instrument.ts for why module-load creation binds to a
|
|
18
|
+
* no-op meter): counts Autopilot model switches so the drift detector can tell
|
|
19
|
+
* an *intentional* switch from unexplained drift, and so a switch is visible on
|
|
20
|
+
* a dashboard. Paired with the `agent.model.switch` span below, which puts the
|
|
21
|
+
* same event on the trace timeline for correlation and for SigNoz MCP.
|
|
22
|
+
*/
|
|
23
|
+
let cachedSwitchCounter;
|
|
24
|
+
function getModelSwitchCounter() {
|
|
25
|
+
if (!cachedSwitchCounter) {
|
|
26
|
+
cachedSwitchCounter = metrics
|
|
27
|
+
.getMeter('driftwatch')
|
|
28
|
+
.createCounter('agent.model.switches', {
|
|
29
|
+
description: 'Count of Autopilot-initiated model switches, by from/to model',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return cachedSwitchCounter;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Emit the observability marker for an applied model switch: a short span (for
|
|
36
|
+
* trace-timeline correlation — you can see the switch right before the behavior
|
|
37
|
+
* change, and SigNoz MCP can explain it) plus a counter increment (the
|
|
38
|
+
* low-cardinality signal the drift detector queries to avoid flagging the
|
|
39
|
+
* intended change as drift). `from` is the model before the switch, `to` after.
|
|
40
|
+
*/
|
|
41
|
+
function recordModelSwitchMarker(from, to, reason) {
|
|
42
|
+
const attributes = {
|
|
43
|
+
'agent.model.from': from ?? 'default',
|
|
44
|
+
'agent.model.to': to,
|
|
45
|
+
'agent.control.reason': reason,
|
|
46
|
+
};
|
|
47
|
+
tracer.startSpan('agent.model.switch', { attributes }).end();
|
|
48
|
+
getModelSwitchCounter().add(1, { from_model: from ?? 'default', to_model: to });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Apply a single control action to the shared agent state and append an audit
|
|
52
|
+
* record. Returns the resulting state. Notify actions are rejected here — they
|
|
53
|
+
* do not belong on the control path.
|
|
54
|
+
*/
|
|
55
|
+
export async function executeControlAction(store, action, context) {
|
|
56
|
+
const current = await store.getAgentState();
|
|
57
|
+
const next = applyAction(current, action, context);
|
|
58
|
+
const applied = next !== current;
|
|
59
|
+
if (applied) {
|
|
60
|
+
await store.setAgentState(next);
|
|
61
|
+
if (action === 'switch_model') {
|
|
62
|
+
recordModelSwitchMarker(current.activeModel, next.activeModel ?? '', context.reason);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await store.recordAction(toLogEntry(action, applied, context));
|
|
66
|
+
return { action, applied, state: next };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Pure state transition for a control action. Returns the SAME object when the
|
|
70
|
+
* action is a no-op (e.g. resuming an already-running agent) so callers can
|
|
71
|
+
* detect whether anything changed.
|
|
72
|
+
*/
|
|
73
|
+
function applyAction(current, action, context) {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const base = { ...current, updatedAt: now, reason: context.reason };
|
|
76
|
+
switch (action) {
|
|
77
|
+
case 'pause_agent':
|
|
78
|
+
if (current.status === 'paused')
|
|
79
|
+
return current;
|
|
80
|
+
return { ...base, status: 'paused' };
|
|
81
|
+
case 'resume_agent':
|
|
82
|
+
if (current.status === 'running')
|
|
83
|
+
return current;
|
|
84
|
+
return { ...base, status: 'running' };
|
|
85
|
+
case 'throttle':
|
|
86
|
+
if (current.status === 'throttled')
|
|
87
|
+
return current;
|
|
88
|
+
return { ...base, status: 'throttled' };
|
|
89
|
+
case 'rollback': {
|
|
90
|
+
// Roll back to the last-known-good version pointer (floor at 1).
|
|
91
|
+
const target = Math.max(1, current.activeVersion - 1);
|
|
92
|
+
if (target === current.activeVersion)
|
|
93
|
+
return current;
|
|
94
|
+
return { ...base, activeVersion: target };
|
|
95
|
+
}
|
|
96
|
+
case 'switch_model': {
|
|
97
|
+
const target = context.targetModel;
|
|
98
|
+
if (!target || target === current.activeModel)
|
|
99
|
+
return current;
|
|
100
|
+
return { ...base, activeModel: target };
|
|
101
|
+
}
|
|
102
|
+
// Notify actions are not control actions and must not reach this path.
|
|
103
|
+
case 'notify_slack':
|
|
104
|
+
case 'notify_telegram':
|
|
105
|
+
case 'notify_webhook':
|
|
106
|
+
throw new Error(`executeControlAction called with notify action: ${action}`);
|
|
107
|
+
default: {
|
|
108
|
+
const exhaustive = action;
|
|
109
|
+
throw new Error(`Unhandled control action: ${String(exhaustive)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function toLogEntry(action, applied, context) {
|
|
114
|
+
return {
|
|
115
|
+
id: randomUUID(),
|
|
116
|
+
at: Date.now(),
|
|
117
|
+
action,
|
|
118
|
+
category: 'control',
|
|
119
|
+
outcome: applied ? 'executed' : 'skipped_cooldown',
|
|
120
|
+
reason: applied ? context.reason : `${context.reason} (no-op)`,
|
|
121
|
+
actor: context.actor,
|
|
122
|
+
channel: context.channel,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=actions.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ActionIntent, Approval, StateStore } from './types.js';
|
|
2
|
+
import { type DispatchLogger, type NotifierRegistry } from './notify-dispatch.js';
|
|
3
|
+
export type ApprovalDecision = 'approved' | 'rejected';
|
|
4
|
+
export interface ApprovalServiceOptions {
|
|
5
|
+
store: StateStore;
|
|
6
|
+
notifiers: NotifierRegistry;
|
|
7
|
+
approvalTimeoutMs: number;
|
|
8
|
+
timeoutDecision: ApprovalDecision;
|
|
9
|
+
logger?: DispatchLogger;
|
|
10
|
+
/**
|
|
11
|
+
* Model id an approved `switch_model` action switches the agent to. The
|
|
12
|
+
* target is deployment config ("when you downshift, go here"), not per-drift
|
|
13
|
+
* data, so it lives on the service rather than being threaded through every
|
|
14
|
+
* intent/approval. Empty = `switch_model` is a no-op (nothing to switch to).
|
|
15
|
+
*/
|
|
16
|
+
switchModelTo?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class ApprovalService {
|
|
19
|
+
private readonly store;
|
|
20
|
+
private readonly notifiers;
|
|
21
|
+
private readonly approvalTimeoutMs;
|
|
22
|
+
private readonly timeoutDecision;
|
|
23
|
+
private readonly logger?;
|
|
24
|
+
private readonly switchModelTo?;
|
|
25
|
+
/** Live timeout handles, so we can cancel on resolve and on shutdown. */
|
|
26
|
+
private readonly timers;
|
|
27
|
+
constructor(options: ApprovalServiceOptions);
|
|
28
|
+
/**
|
|
29
|
+
* Create a pending approval for a control action intent, broadcast it to
|
|
30
|
+
* every notifier (with Approve/Reject affordances), and arm the timeout.
|
|
31
|
+
*/
|
|
32
|
+
requestApproval(intent: ActionIntent): Promise<Approval>;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve an approval from any channel. Idempotent: only the first caller to
|
|
35
|
+
* win the store's atomic CAS executes the action; later calls return
|
|
36
|
+
* undefined. Returns the resolved approval, or undefined if it was missing
|
|
37
|
+
* or already resolved.
|
|
38
|
+
*/
|
|
39
|
+
resolve(id: string, decision: ApprovalDecision, actor: string, channel: string): Promise<Approval | undefined>;
|
|
40
|
+
/** Cancel all pending timers (called on ordered shutdown). */
|
|
41
|
+
stop(): void;
|
|
42
|
+
private armTimeout;
|
|
43
|
+
private clearTimeout;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=approval-service.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval service — creates and resolves human-in-the-loop approvals for
|
|
3
|
+
* control actions, and executes the action once approved.
|
|
4
|
+
*
|
|
5
|
+
* Resolution is channel-agnostic: the console, a Slack button, and a Telegram
|
|
6
|
+
* button all call `resolve(...)`, which delegates to the StateStore's ATOMIC
|
|
7
|
+
* resolveApproval (a Redis Lua CAS, or a guarded map write in memory). That
|
|
8
|
+
* atomicity is what makes approvals safe across processes and idempotent under
|
|
9
|
+
* double-clicks — whoever wins the CAS executes the action exactly once.
|
|
10
|
+
*
|
|
11
|
+
* On creation we arm a timeout so a forgotten approval falls back to a safe
|
|
12
|
+
* default (reject, by default) rather than hanging a paused agent forever.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { executeControlAction } from './actions.js';
|
|
16
|
+
import { notifyAll } from './notify-dispatch.js';
|
|
17
|
+
export class ApprovalService {
|
|
18
|
+
store;
|
|
19
|
+
notifiers;
|
|
20
|
+
approvalTimeoutMs;
|
|
21
|
+
timeoutDecision;
|
|
22
|
+
logger;
|
|
23
|
+
switchModelTo;
|
|
24
|
+
/** Live timeout handles, so we can cancel on resolve and on shutdown. */
|
|
25
|
+
timers = new Map();
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.store = options.store;
|
|
28
|
+
this.notifiers = options.notifiers;
|
|
29
|
+
this.approvalTimeoutMs = options.approvalTimeoutMs;
|
|
30
|
+
this.timeoutDecision = options.timeoutDecision;
|
|
31
|
+
this.logger = options.logger;
|
|
32
|
+
this.switchModelTo = options.switchModelTo;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a pending approval for a control action intent, broadcast it to
|
|
36
|
+
* every notifier (with Approve/Reject affordances), and arm the timeout.
|
|
37
|
+
*/
|
|
38
|
+
async requestApproval(intent) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const approval = {
|
|
41
|
+
id: randomUUID(),
|
|
42
|
+
action: intent.type,
|
|
43
|
+
severity: intent.severity,
|
|
44
|
+
reasons: intent.reason ? [intent.reason] : [],
|
|
45
|
+
recommendedAction: `Autopilot proposes: ${intent.type}`,
|
|
46
|
+
status: 'pending',
|
|
47
|
+
createdAt: now,
|
|
48
|
+
expiresAt: now + this.approvalTimeoutMs,
|
|
49
|
+
};
|
|
50
|
+
await this.store.createApproval(approval);
|
|
51
|
+
await notifyAll(this.notifiers, {
|
|
52
|
+
title: `Approval needed: ${intent.type}`,
|
|
53
|
+
severity: intent.severity,
|
|
54
|
+
reasons: approval.reasons,
|
|
55
|
+
recommendedAction: approval.recommendedAction,
|
|
56
|
+
approvalId: approval.id,
|
|
57
|
+
action: intent.type,
|
|
58
|
+
}, this.logger);
|
|
59
|
+
this.armTimeout(approval.id);
|
|
60
|
+
return approval;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve an approval from any channel. Idempotent: only the first caller to
|
|
64
|
+
* win the store's atomic CAS executes the action; later calls return
|
|
65
|
+
* undefined. Returns the resolved approval, or undefined if it was missing
|
|
66
|
+
* or already resolved.
|
|
67
|
+
*/
|
|
68
|
+
async resolve(id, decision, actor, channel) {
|
|
69
|
+
const resolved = await this.store.resolveApproval(id, decision, actor, channel);
|
|
70
|
+
if (!resolved)
|
|
71
|
+
return undefined;
|
|
72
|
+
this.clearTimeout(id);
|
|
73
|
+
if (decision === 'approved') {
|
|
74
|
+
await executeControlAction(this.store, resolved.action, {
|
|
75
|
+
reason: `approved via ${channel} by ${actor}`,
|
|
76
|
+
actor,
|
|
77
|
+
channel,
|
|
78
|
+
severity: resolved.severity,
|
|
79
|
+
targetModel: this.switchModelTo,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
/** Cancel all pending timers (called on ordered shutdown). */
|
|
85
|
+
stop() {
|
|
86
|
+
for (const timer of this.timers.values())
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
this.timers.clear();
|
|
89
|
+
}
|
|
90
|
+
armTimeout(id) {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
this.timers.delete(id);
|
|
93
|
+
// Resolve with the configured safe default; 'timeout' marks the channel.
|
|
94
|
+
void this.resolve(id, this.timeoutDecision, 'autopilot', 'timeout').catch((error) => this.logger?.error({ error, id }, 'approval timeout resolve failed'));
|
|
95
|
+
}, this.approvalTimeoutMs);
|
|
96
|
+
// Don't keep the event loop alive solely for a pending approval.
|
|
97
|
+
timer.unref?.();
|
|
98
|
+
this.timers.set(id, timer);
|
|
99
|
+
}
|
|
100
|
+
clearTimeout(id) {
|
|
101
|
+
const timer = this.timers.get(id);
|
|
102
|
+
if (timer) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
this.timers.delete(id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=approval-service.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory StateStore — the zero-dependency default. Perfect for single-
|
|
3
|
+
* process use (a standalone script, a single container); NOT suitable across
|
|
4
|
+
* multiple processes, since each keeps its own state. For multi-process
|
|
5
|
+
* deployments, use `RedisStateStore` from `@driftwatch/sdk/redis` instead.
|
|
6
|
+
*/
|
|
7
|
+
import type { ActionLogEntry, AgentRuntimeState, Approval, ApprovalStatus, DriftHistoryEntry, StateStore } from './types.js';
|
|
8
|
+
export declare class MemoryStateStore implements StateStore {
|
|
9
|
+
private agentState;
|
|
10
|
+
private readonly approvals;
|
|
11
|
+
private readonly driftHistory;
|
|
12
|
+
private readonly actionLog;
|
|
13
|
+
private readonly cooldowns;
|
|
14
|
+
private readonly leaderLocks;
|
|
15
|
+
getAgentState(): Promise<AgentRuntimeState>;
|
|
16
|
+
setAgentState(state: AgentRuntimeState): Promise<void>;
|
|
17
|
+
createApproval(approval: Approval): Promise<void>;
|
|
18
|
+
getApproval(id: string): Promise<Approval | undefined>;
|
|
19
|
+
listPendingApprovals(): Promise<Approval[]>;
|
|
20
|
+
resolveApproval(id: string, status: Exclude<ApprovalStatus, 'pending'>, resolvedBy: string, channel: string): Promise<Approval | undefined>;
|
|
21
|
+
recordDriftVerdict(entry: DriftHistoryEntry): Promise<void>;
|
|
22
|
+
listDriftHistory(limit: number): Promise<DriftHistoryEntry[]>;
|
|
23
|
+
recordAction(entry: ActionLogEntry): Promise<void>;
|
|
24
|
+
listActionLog(limit: number): Promise<ActionLogEntry[]>;
|
|
25
|
+
checkAndSetCooldown(key: string, ttlMs: number): Promise<boolean>;
|
|
26
|
+
acquireLeaderLock(key: string, ttlMs: number): Promise<boolean>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=memory-store.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const HISTORY_CAP = 500;
|
|
2
|
+
export class MemoryStateStore {
|
|
3
|
+
agentState = {
|
|
4
|
+
status: 'running',
|
|
5
|
+
activeVersion: 1,
|
|
6
|
+
updatedAt: Date.now(),
|
|
7
|
+
};
|
|
8
|
+
approvals = new Map();
|
|
9
|
+
driftHistory = [];
|
|
10
|
+
actionLog = [];
|
|
11
|
+
cooldowns = new Map();
|
|
12
|
+
leaderLocks = new Map();
|
|
13
|
+
async getAgentState() {
|
|
14
|
+
return { ...this.agentState };
|
|
15
|
+
}
|
|
16
|
+
async setAgentState(state) {
|
|
17
|
+
this.agentState = { ...state };
|
|
18
|
+
}
|
|
19
|
+
async createApproval(approval) {
|
|
20
|
+
this.approvals.set(approval.id, { ...approval });
|
|
21
|
+
}
|
|
22
|
+
async getApproval(id) {
|
|
23
|
+
const approval = this.approvals.get(id);
|
|
24
|
+
return approval ? { ...approval } : undefined;
|
|
25
|
+
}
|
|
26
|
+
async listPendingApprovals() {
|
|
27
|
+
return Array.from(this.approvals.values())
|
|
28
|
+
.filter((approval) => approval.status === 'pending')
|
|
29
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
30
|
+
.map((approval) => ({ ...approval }));
|
|
31
|
+
}
|
|
32
|
+
async resolveApproval(id, status, resolvedBy, channel) {
|
|
33
|
+
const approval = this.approvals.get(id);
|
|
34
|
+
if (!approval || approval.status !== 'pending')
|
|
35
|
+
return undefined;
|
|
36
|
+
const resolved = {
|
|
37
|
+
...approval,
|
|
38
|
+
status,
|
|
39
|
+
resolvedBy,
|
|
40
|
+
channel,
|
|
41
|
+
resolvedAt: Date.now(),
|
|
42
|
+
};
|
|
43
|
+
this.approvals.set(id, resolved);
|
|
44
|
+
return { ...resolved };
|
|
45
|
+
}
|
|
46
|
+
async recordDriftVerdict(entry) {
|
|
47
|
+
this.driftHistory.unshift({ ...entry });
|
|
48
|
+
if (this.driftHistory.length > HISTORY_CAP) {
|
|
49
|
+
this.driftHistory.length = HISTORY_CAP;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async listDriftHistory(limit) {
|
|
53
|
+
return this.driftHistory.slice(0, limit).map((entry) => ({ ...entry }));
|
|
54
|
+
}
|
|
55
|
+
async recordAction(entry) {
|
|
56
|
+
this.actionLog.unshift({ ...entry });
|
|
57
|
+
if (this.actionLog.length > HISTORY_CAP) {
|
|
58
|
+
this.actionLog.length = HISTORY_CAP;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async listActionLog(limit) {
|
|
62
|
+
return this.actionLog.slice(0, limit).map((entry) => ({ ...entry }));
|
|
63
|
+
}
|
|
64
|
+
async checkAndSetCooldown(key, ttlMs) {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const existingExpiry = this.cooldowns.get(key);
|
|
67
|
+
if (existingExpiry !== undefined && existingExpiry > now)
|
|
68
|
+
return false;
|
|
69
|
+
this.cooldowns.set(key, now + ttlMs);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
async acquireLeaderLock(key, ttlMs) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const existingExpiry = this.leaderLocks.get(key);
|
|
75
|
+
if (existingExpiry !== undefined && existingExpiry > now)
|
|
76
|
+
return false;
|
|
77
|
+
this.leaderLocks.set(key, now + ttlMs);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
async close() {
|
|
81
|
+
// nothing to release for the in-memory store
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=memory-store.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifier registry + fire-and-forget dispatch. Pure orchestration over the
|
|
3
|
+
* `Notifier` interface — no concrete channel (Slack/Telegram/webhook) lives
|
|
4
|
+
* here. Concrete notifiers are in the companion `@driftwatch/autopilot`
|
|
5
|
+
* package (or bring your own — anything implementing `Notifier`).
|
|
6
|
+
*
|
|
7
|
+
* Dispatch never throws into the caller: a channel failure is logged and
|
|
8
|
+
* swallowed so one broken webhook can't stall the drift loop.
|
|
9
|
+
*/
|
|
10
|
+
import type { ActionType, NotificationMessage, Notifier } from './types.js';
|
|
11
|
+
export interface NotifierRegistry {
|
|
12
|
+
slack?: Notifier;
|
|
13
|
+
telegram?: Notifier;
|
|
14
|
+
webhook?: Notifier;
|
|
15
|
+
/** All configured notifiers, in a stable order. */
|
|
16
|
+
list: Notifier[];
|
|
17
|
+
}
|
|
18
|
+
/** Map a notify_* action to its concrete channel notifier, if configured. */
|
|
19
|
+
export declare function notifierForAction(registry: NotifierRegistry, action: ActionType): Notifier | undefined;
|
|
20
|
+
export interface DispatchLogger {
|
|
21
|
+
error: (obj: unknown, msg?: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/** Fire-and-forget send to a single notifier; failures are logged, not thrown. */
|
|
24
|
+
export declare function safeNotify(notifier: Notifier, message: NotificationMessage, logger?: DispatchLogger): Promise<void>;
|
|
25
|
+
/** Broadcast a message to every configured notifier concurrently. */
|
|
26
|
+
export declare function notifyAll(registry: NotifierRegistry, message: NotificationMessage, logger?: DispatchLogger): Promise<void>;
|
|
27
|
+
//# sourceMappingURL=notify-dispatch.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Map a notify_* action to its concrete channel notifier, if configured. */
|
|
2
|
+
export function notifierForAction(registry, action) {
|
|
3
|
+
switch (action) {
|
|
4
|
+
case 'notify_slack':
|
|
5
|
+
return registry.slack;
|
|
6
|
+
case 'notify_telegram':
|
|
7
|
+
return registry.telegram;
|
|
8
|
+
case 'notify_webhook':
|
|
9
|
+
return registry.webhook;
|
|
10
|
+
default:
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Fire-and-forget send to a single notifier; failures are logged, not thrown. */
|
|
15
|
+
export async function safeNotify(notifier, message, logger) {
|
|
16
|
+
try {
|
|
17
|
+
await notifier.notify(message);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
logger?.error({ error, channel: notifier.channel }, 'notification failed');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Broadcast a message to every configured notifier concurrently. */
|
|
24
|
+
export async function notifyAll(registry, message, logger) {
|
|
25
|
+
await Promise.all(registry.list.map((notifier) => safeNotify(notifier, message, logger)));
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=notify-dispatch.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ActionLogEntry, AgentRuntimeState, Approval, ApprovalStatus, DriftHistoryEntry, StateStore } from './types.js';
|
|
2
|
+
export declare class RedisStateStore implements StateStore {
|
|
3
|
+
private readonly redis;
|
|
4
|
+
constructor(redisUrl: string);
|
|
5
|
+
getAgentState(): Promise<AgentRuntimeState>;
|
|
6
|
+
setAgentState(state: AgentRuntimeState): Promise<void>;
|
|
7
|
+
createApproval(approval: Approval): Promise<void>;
|
|
8
|
+
getApproval(id: string): Promise<Approval | undefined>;
|
|
9
|
+
listPendingApprovals(): Promise<Approval[]>;
|
|
10
|
+
resolveApproval(id: string, status: Exclude<ApprovalStatus, 'pending'>, resolvedBy: string, channel: string): Promise<Approval | undefined>;
|
|
11
|
+
recordDriftVerdict(entry: DriftHistoryEntry): Promise<void>;
|
|
12
|
+
listDriftHistory(limit: number): Promise<DriftHistoryEntry[]>;
|
|
13
|
+
recordAction(entry: ActionLogEntry): Promise<void>;
|
|
14
|
+
listActionLog(limit: number): Promise<ActionLogEntry[]>;
|
|
15
|
+
checkAndSetCooldown(key: string, ttlMs: number): Promise<boolean>;
|
|
16
|
+
acquireLeaderLock(key: string, ttlMs: number): Promise<boolean>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=redis-store.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed StateStore for multi-process operation. When several server
|
|
3
|
+
* processes run behind a load balancer, they share one view of agent state,
|
|
4
|
+
* pending approvals, drift history, and the action log — and a leader lock
|
|
5
|
+
* ensures only one process runs each scheduled drift cycle.
|
|
6
|
+
*
|
|
7
|
+
* Not exported from the package root — import from `@driftwatch/sdk/redis`.
|
|
8
|
+
* `ioredis` is an OPTIONAL peer dependency: installing the core SDK never
|
|
9
|
+
* pulls it in, so this is the one place in the SDK a consumer opts into a
|
|
10
|
+
* concrete I/O dependency, and only if they import this subpath.
|
|
11
|
+
*/
|
|
12
|
+
import { Redis } from 'ioredis';
|
|
13
|
+
const HISTORY_CAP = 500;
|
|
14
|
+
const KEY = {
|
|
15
|
+
agentState: 'dw:agent:state',
|
|
16
|
+
pendingApprovals: 'dw:approvals:pending',
|
|
17
|
+
approval: (id) => `dw:approval:${id}`,
|
|
18
|
+
driftHistory: 'dw:drift:history',
|
|
19
|
+
actionLog: 'dw:action:log',
|
|
20
|
+
cooldown: (key) => `dw:cooldown:${key}`,
|
|
21
|
+
leader: (key) => `dw:leader:${key}`,
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_AGENT_STATE = {
|
|
24
|
+
status: 'running',
|
|
25
|
+
activeVersion: 1,
|
|
26
|
+
updatedAt: 0,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Atomically resolve a still-pending approval. Returns the updated JSON, or an
|
|
30
|
+
* empty string when the approval is missing or already resolved. Running this
|
|
31
|
+
* as a single Lua script guarantees two channels (console + Slack + Telegram)
|
|
32
|
+
* can't both "win" the same approval.
|
|
33
|
+
*/
|
|
34
|
+
const RESOLVE_APPROVAL_LUA = `
|
|
35
|
+
local raw = redis.call('GET', KEYS[1])
|
|
36
|
+
if not raw then return '' end
|
|
37
|
+
local approval = cjson.decode(raw)
|
|
38
|
+
if approval.status ~= 'pending' then return '' end
|
|
39
|
+
approval.status = ARGV[1]
|
|
40
|
+
approval.resolvedBy = ARGV[2]
|
|
41
|
+
approval.channel = ARGV[3]
|
|
42
|
+
approval.resolvedAt = tonumber(ARGV[4])
|
|
43
|
+
local updated = cjson.encode(approval)
|
|
44
|
+
redis.call('SET', KEYS[1], updated)
|
|
45
|
+
redis.call('SREM', KEYS[2], ARGV[5])
|
|
46
|
+
return updated
|
|
47
|
+
`;
|
|
48
|
+
export class RedisStateStore {
|
|
49
|
+
redis;
|
|
50
|
+
constructor(redisUrl) {
|
|
51
|
+
this.redis = new Redis(redisUrl, { lazyConnect: false, maxRetriesPerRequest: 3 });
|
|
52
|
+
}
|
|
53
|
+
async getAgentState() {
|
|
54
|
+
const raw = await this.redis.get(KEY.agentState);
|
|
55
|
+
if (!raw)
|
|
56
|
+
return { ...DEFAULT_AGENT_STATE, updatedAt: Date.now() };
|
|
57
|
+
return JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
async setAgentState(state) {
|
|
60
|
+
await this.redis.set(KEY.agentState, JSON.stringify(state));
|
|
61
|
+
}
|
|
62
|
+
async createApproval(approval) {
|
|
63
|
+
await this.redis
|
|
64
|
+
.multi()
|
|
65
|
+
.set(KEY.approval(approval.id), JSON.stringify(approval))
|
|
66
|
+
.sadd(KEY.pendingApprovals, approval.id)
|
|
67
|
+
.exec();
|
|
68
|
+
}
|
|
69
|
+
async getApproval(id) {
|
|
70
|
+
const raw = await this.redis.get(KEY.approval(id));
|
|
71
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
72
|
+
}
|
|
73
|
+
async listPendingApprovals() {
|
|
74
|
+
const ids = await this.redis.smembers(KEY.pendingApprovals);
|
|
75
|
+
if (ids.length === 0)
|
|
76
|
+
return [];
|
|
77
|
+
const raws = await this.redis.mget(ids.map((id) => KEY.approval(id)));
|
|
78
|
+
const approvals = [];
|
|
79
|
+
for (const raw of raws) {
|
|
80
|
+
if (!raw)
|
|
81
|
+
continue;
|
|
82
|
+
const approval = JSON.parse(raw);
|
|
83
|
+
if (approval.status === 'pending')
|
|
84
|
+
approvals.push(approval);
|
|
85
|
+
}
|
|
86
|
+
return approvals.sort((a, b) => a.createdAt - b.createdAt);
|
|
87
|
+
}
|
|
88
|
+
async resolveApproval(id, status, resolvedBy, channel) {
|
|
89
|
+
const result = (await this.redis.eval(RESOLVE_APPROVAL_LUA, 2, KEY.approval(id), KEY.pendingApprovals, status, resolvedBy, channel, String(Date.now()), id));
|
|
90
|
+
return result ? JSON.parse(result) : undefined;
|
|
91
|
+
}
|
|
92
|
+
async recordDriftVerdict(entry) {
|
|
93
|
+
await this.redis
|
|
94
|
+
.multi()
|
|
95
|
+
.lpush(KEY.driftHistory, JSON.stringify(entry))
|
|
96
|
+
.ltrim(KEY.driftHistory, 0, HISTORY_CAP - 1)
|
|
97
|
+
.exec();
|
|
98
|
+
}
|
|
99
|
+
async listDriftHistory(limit) {
|
|
100
|
+
const raws = await this.redis.lrange(KEY.driftHistory, 0, limit - 1);
|
|
101
|
+
return raws.map((raw) => JSON.parse(raw));
|
|
102
|
+
}
|
|
103
|
+
async recordAction(entry) {
|
|
104
|
+
await this.redis
|
|
105
|
+
.multi()
|
|
106
|
+
.lpush(KEY.actionLog, JSON.stringify(entry))
|
|
107
|
+
.ltrim(KEY.actionLog, 0, HISTORY_CAP - 1)
|
|
108
|
+
.exec();
|
|
109
|
+
}
|
|
110
|
+
async listActionLog(limit) {
|
|
111
|
+
const raws = await this.redis.lrange(KEY.actionLog, 0, limit - 1);
|
|
112
|
+
return raws.map((raw) => JSON.parse(raw));
|
|
113
|
+
}
|
|
114
|
+
async checkAndSetCooldown(key, ttlMs) {
|
|
115
|
+
const result = await this.redis.set(KEY.cooldown(key), '1', 'PX', ttlMs, 'NX');
|
|
116
|
+
return result === 'OK';
|
|
117
|
+
}
|
|
118
|
+
async acquireLeaderLock(key, ttlMs) {
|
|
119
|
+
const result = await this.redis.set(KEY.leader(key), '1', 'PX', ttlMs, 'NX');
|
|
120
|
+
return result === 'OK';
|
|
121
|
+
}
|
|
122
|
+
async close() {
|
|
123
|
+
await this.redis.quit();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=redis-store.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type DriftReport } from '../drift/detector.js';
|
|
2
|
+
import type { ModelClient } from '../model-client.js';
|
|
3
|
+
import type { DriftDetectionConfig } from '../config/schema.js';
|
|
4
|
+
import { type PolicyConfig } from './policy.js';
|
|
5
|
+
import type { ActionIntent, StateStore } from './types.js';
|
|
6
|
+
import type { ApprovalService } from './approval-service.js';
|
|
7
|
+
import { type NotifierRegistry } from './notify-dispatch.js';
|
|
8
|
+
export interface SchedulerLogger {
|
|
9
|
+
info: (obj: unknown, msg?: string) => void;
|
|
10
|
+
error: (obj: unknown, msg?: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface AutopilotSchedulerOptions {
|
|
13
|
+
store: StateStore;
|
|
14
|
+
notifiers: NotifierRegistry;
|
|
15
|
+
approvalService: ApprovalService;
|
|
16
|
+
modelClient: ModelClient;
|
|
17
|
+
policyConfig: PolicyConfig;
|
|
18
|
+
driftDetectionConfig: DriftDetectionConfig;
|
|
19
|
+
isDryRun: boolean;
|
|
20
|
+
scanIntervalMs: number;
|
|
21
|
+
cooldownMs: number;
|
|
22
|
+
logger: SchedulerLogger;
|
|
23
|
+
}
|
|
24
|
+
export declare class AutopilotScheduler {
|
|
25
|
+
private readonly options;
|
|
26
|
+
private timer?;
|
|
27
|
+
private running;
|
|
28
|
+
constructor(options: AutopilotSchedulerOptions);
|
|
29
|
+
/** Begin the periodic loop. The first cycle runs after one interval. */
|
|
30
|
+
start(): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
/** One guarded tick: only the elected leader runs a cycle. */
|
|
33
|
+
private tick;
|
|
34
|
+
/**
|
|
35
|
+
* Run a single drift cycle end to end. Exposed so the control-plane's
|
|
36
|
+
* POST /drift/scan can trigger a manual run. Returns the report + intents
|
|
37
|
+
* for the caller to surface.
|
|
38
|
+
*/
|
|
39
|
+
runCycle(trigger: string): Promise<{
|
|
40
|
+
report: DriftReport;
|
|
41
|
+
intents: ActionIntent[];
|
|
42
|
+
}>;
|
|
43
|
+
private dispatchIntent;
|
|
44
|
+
private dispatchNotify;
|
|
45
|
+
private dispatchControl;
|
|
46
|
+
private recordOutcome;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=scheduler.d.ts.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autopilot scheduler — the autonomous perceive→reason→act loop (Loop 2).
|
|
3
|
+
*
|
|
4
|
+
* Every SCAN_INTERVAL_MS the leader process (elected via the StateStore's
|
|
5
|
+
* leader lock so only ONE process acts per cycle in a multi-process
|
|
6
|
+
* deployment):
|
|
7
|
+
* 1. perceive — detectBehavioralDrift over the SigNoz windows (or fixtures),
|
|
8
|
+
* 2. reason — evaluatePolicies maps the report to ActionIntents,
|
|
9
|
+
* 3. act — notify_* intents fire immediately; control intents open an
|
|
10
|
+
* approval. A per-action cooldown dedups storms.
|
|
11
|
+
*
|
|
12
|
+
* In `shadow` mode nothing is executed: intended actions are logged only, which
|
|
13
|
+
* is the safe default and the CI/demo path.
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { detectBehavioralDrift } from '../drift/detector.js';
|
|
17
|
+
import { evaluatePolicies } from './policy.js';
|
|
18
|
+
import { notifierForAction, safeNotify, } from './notify-dispatch.js';
|
|
19
|
+
const LEADER_LOCK_KEY = 'scheduler:leader';
|
|
20
|
+
export class AutopilotScheduler {
|
|
21
|
+
options;
|
|
22
|
+
timer;
|
|
23
|
+
running = false;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
/** Begin the periodic loop. The first cycle runs after one interval. */
|
|
28
|
+
start() {
|
|
29
|
+
if (this.timer)
|
|
30
|
+
return;
|
|
31
|
+
this.timer = setInterval(() => {
|
|
32
|
+
void this.tick();
|
|
33
|
+
}, this.options.scanIntervalMs);
|
|
34
|
+
this.timer.unref?.();
|
|
35
|
+
this.options.logger.info({ intervalMs: this.options.scanIntervalMs, mode: this.options.policyConfig.mode }, 'autopilot scheduler started');
|
|
36
|
+
}
|
|
37
|
+
stop() {
|
|
38
|
+
if (this.timer) {
|
|
39
|
+
clearInterval(this.timer);
|
|
40
|
+
this.timer = undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** One guarded tick: only the elected leader runs a cycle. */
|
|
44
|
+
async tick() {
|
|
45
|
+
if (this.running)
|
|
46
|
+
return; // never overlap cycles within a process
|
|
47
|
+
const isLeader = await this.options.store.acquireLeaderLock(LEADER_LOCK_KEY, this.options.scanIntervalMs);
|
|
48
|
+
if (!isLeader)
|
|
49
|
+
return;
|
|
50
|
+
this.running = true;
|
|
51
|
+
try {
|
|
52
|
+
await this.runCycle('scheduler');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
this.options.logger.error({ error }, 'autopilot cycle failed');
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
this.running = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run a single drift cycle end to end. Exposed so the control-plane's
|
|
63
|
+
* POST /drift/scan can trigger a manual run. Returns the report + intents
|
|
64
|
+
* for the caller to surface.
|
|
65
|
+
*/
|
|
66
|
+
async runCycle(trigger) {
|
|
67
|
+
const { store, modelClient, driftDetectionConfig, isDryRun, policyConfig } = this.options;
|
|
68
|
+
const report = await detectBehavioralDrift({
|
|
69
|
+
modelClient,
|
|
70
|
+
isDryRun,
|
|
71
|
+
driftDetectionConfig,
|
|
72
|
+
});
|
|
73
|
+
await store.recordDriftVerdict({
|
|
74
|
+
id: randomUUID(),
|
|
75
|
+
at: Date.now(),
|
|
76
|
+
drift: report.verdict.drift,
|
|
77
|
+
severity: report.verdict.severity,
|
|
78
|
+
reasons: report.verdict.reasons,
|
|
79
|
+
recommendedAction: report.verdict.recommended_action,
|
|
80
|
+
baselineTokenSpend: report.baselineWindowStats.tokenSpend,
|
|
81
|
+
currentTokenSpend: report.currentWindowStats.tokenSpend,
|
|
82
|
+
});
|
|
83
|
+
const intents = evaluatePolicies(report, policyConfig);
|
|
84
|
+
for (const intent of intents) {
|
|
85
|
+
await this.dispatchIntent(intent, trigger);
|
|
86
|
+
}
|
|
87
|
+
return { report, intents };
|
|
88
|
+
}
|
|
89
|
+
async dispatchIntent(intent, trigger) {
|
|
90
|
+
const { store, policyConfig, cooldownMs, logger } = this.options;
|
|
91
|
+
const isShadow = policyConfig.mode === 'shadow';
|
|
92
|
+
// Cooldown dedup — one entry per action type per window.
|
|
93
|
+
const mayProceed = await store.checkAndSetCooldown(`action:${intent.type}`, cooldownMs);
|
|
94
|
+
if (!mayProceed) {
|
|
95
|
+
await this.recordOutcome(intent, 'skipped_cooldown', trigger);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (isShadow) {
|
|
99
|
+
logger.info({ intent, trigger }, 'autopilot shadow: intended action (not executed)');
|
|
100
|
+
await this.recordOutcome(intent, 'shadowed', trigger);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (intent.category === 'notify') {
|
|
104
|
+
await this.dispatchNotify(intent, trigger);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await this.dispatchControl(intent, trigger);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async dispatchNotify(intent, trigger) {
|
|
111
|
+
const { notifiers, logger } = this.options;
|
|
112
|
+
const notifier = notifierForAction(notifiers, intent.type);
|
|
113
|
+
if (!notifier) {
|
|
114
|
+
logger.info({ intent }, 'notify action skipped: channel not configured');
|
|
115
|
+
await this.recordOutcome(intent, 'failed', trigger);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await safeNotify(notifier, {
|
|
119
|
+
title: 'Behavioral drift detected',
|
|
120
|
+
severity: intent.severity,
|
|
121
|
+
reasons: intent.reason ? [intent.reason] : [],
|
|
122
|
+
recommendedAction: intent.reason || 'Review the drift report.',
|
|
123
|
+
action: intent.type,
|
|
124
|
+
}, logger);
|
|
125
|
+
await this.recordOutcome(intent, 'executed', trigger);
|
|
126
|
+
}
|
|
127
|
+
async dispatchControl(intent, trigger) {
|
|
128
|
+
await this.options.approvalService.requestApproval(intent);
|
|
129
|
+
await this.recordOutcome(intent, 'pending_approval', trigger);
|
|
130
|
+
}
|
|
131
|
+
async recordOutcome(intent, outcome, trigger) {
|
|
132
|
+
await this.options.store.recordAction({
|
|
133
|
+
id: randomUUID(),
|
|
134
|
+
at: Date.now(),
|
|
135
|
+
action: intent.type,
|
|
136
|
+
category: intent.category,
|
|
137
|
+
outcome,
|
|
138
|
+
reason: intent.reason,
|
|
139
|
+
actor: 'autopilot',
|
|
140
|
+
channel: trigger,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for the Autopilot layer — Loop 2 of DriftWatch.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The orchestration built on these types — `MemoryStateStore`,
|
|
5
|
+
* `ApprovalService`, `AutopilotScheduler`, `executeControlAction`, and the
|
|
6
|
+
* notify-dispatch helpers — lives alongside them in this package, since none
|
|
7
|
+
* of it does concrete I/O: it only calls the `StateStore`/`Notifier`
|
|
8
|
+
* interfaces defined here. Two things that DO require concrete I/O are kept
|
|
9
|
+
* out of the package root:
|
|
10
|
+
* - `RedisStateStore` needs `ioredis` — it's an isolated subpath export at
|
|
11
|
+
* `@driftwatch/sdk/redis` with ioredis as an optional peer dependency, so
|
|
12
|
+
* importing the core SDK never pulls it in.
|
|
13
|
+
* - Concrete Slack/Telegram/webhook `Notifier`s and inbound-webhook
|
|
14
|
+
* signature verification live in the companion `@driftwatch/autopilot`
|
|
15
|
+
* package, since they track those providers' APIs independently of this
|
|
16
|
+
* package's release cadence.
|
|
9
17
|
*/
|
|
10
18
|
import type { DriftVerdict } from '../drift/detector.js';
|
|
11
19
|
/** Every remediation action Autopilot knows how to intend. */
|
package/dist/drift/detector.js
CHANGED
|
@@ -37,11 +37,18 @@ export async function detectBehavioralDrift(options) {
|
|
|
37
37
|
const [baselineWindowStats, currentWindowStats] = isDryRun
|
|
38
38
|
? loadFixtureWindows()
|
|
39
39
|
: await queryLiveWindows(driftDetectionConfig);
|
|
40
|
+
// If Autopilot switched the agent's model this window, the resulting behavior
|
|
41
|
+
// change is intentional — tell the judge so it doesn't flag the cure as the
|
|
42
|
+
// disease. Fixtures have no such context. See queryModelSwitchNote.
|
|
43
|
+
const modelSwitchNote = isDryRun
|
|
44
|
+
? undefined
|
|
45
|
+
: await queryModelSwitchNote(driftDetectionConfig);
|
|
40
46
|
const modelClientDescriptor = describeModelClient(modelClient);
|
|
41
47
|
const { verdict, judgeTokenUsage, judgeAttempts } = await judgeDriftVerdict({
|
|
42
48
|
baselineWindowStats,
|
|
43
49
|
currentWindowStats,
|
|
44
50
|
modelClient,
|
|
51
|
+
modelSwitchNote,
|
|
45
52
|
});
|
|
46
53
|
return {
|
|
47
54
|
baselineWindowStats,
|
|
@@ -161,6 +168,48 @@ function buildTokenSpendQuery() {
|
|
|
161
168
|
expression: 'C',
|
|
162
169
|
});
|
|
163
170
|
}
|
|
171
|
+
function buildModelSwitchQuery() {
|
|
172
|
+
return buildMetricBuilderQuery({
|
|
173
|
+
metricKey: 'agent.model.switches',
|
|
174
|
+
instrumentType: 'Sum',
|
|
175
|
+
timeAggregation: 'sum',
|
|
176
|
+
spaceAggregation: 'sum',
|
|
177
|
+
expression: 'A',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Count Autopilot model switches in the current window (the `agent.model.switches`
|
|
182
|
+
* counter emitted by executeControlAction) and, if any, return a note the judge
|
|
183
|
+
* uses to avoid classifying the *intended* behavior change as drift.
|
|
184
|
+
*
|
|
185
|
+
* Fail-safe by design: any query error resolves to "no switch" (undefined), so a
|
|
186
|
+
* hiccup here never breaks drift detection — the worst case is a switch isn't
|
|
187
|
+
* discounted, i.e. exactly today's behavior.
|
|
188
|
+
*/
|
|
189
|
+
async function queryModelSwitchNote(driftDetectionConfig) {
|
|
190
|
+
const endTimeMs = Date.now();
|
|
191
|
+
const startTimeMs = endTimeMs - ONE_HOUR_MS;
|
|
192
|
+
try {
|
|
193
|
+
const signozResponseBody = await querySignozRange({
|
|
194
|
+
driftDetectionConfig,
|
|
195
|
+
startTimeMs,
|
|
196
|
+
endTimeMs,
|
|
197
|
+
builderQueries: { A: buildModelSwitchQuery() },
|
|
198
|
+
});
|
|
199
|
+
const switchCount = extractTokenSpend(findBuilderResult(signozResponseBody.data?.result ?? [], 'A'));
|
|
200
|
+
if (switchCount <= 0)
|
|
201
|
+
return undefined;
|
|
202
|
+
const times = switchCount >= 2 ? `${Math.round(switchCount)} times` : 'once';
|
|
203
|
+
return (`Autopilot switched the monitored agent's model ${times} during the CURRENT window ` +
|
|
204
|
+
`(an intentional remediation, not organic behavior). Changes attributable to a model ` +
|
|
205
|
+
`switch — especially token-spend and per-tool latency shifts — are EXPECTED and must ` +
|
|
206
|
+
`NOT be classified as drift on their own. Flag drift only for changes beyond what a ` +
|
|
207
|
+
`model switch explains (e.g. a real error-rate spike or a tool-mix collapse).`);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
164
213
|
async function querySignozRange(options) {
|
|
165
214
|
const { driftDetectionConfig, startTimeMs, endTimeMs, builderQueries } = options;
|
|
166
215
|
// SigNoz v4 requires every builder query to carry a `queryName` that matches
|
|
@@ -302,11 +351,15 @@ function buildFixtureWindowStats(windowLabel, driftMultiplier) {
|
|
|
302
351
|
*/
|
|
303
352
|
const MAX_JUDGE_ATTEMPTS = 3;
|
|
304
353
|
async function judgeDriftVerdict(options) {
|
|
305
|
-
const { baselineWindowStats, currentWindowStats, modelClient } = options;
|
|
354
|
+
const { baselineWindowStats, currentWindowStats, modelClient, modelSwitchNote } = options;
|
|
306
355
|
const messages = [
|
|
307
356
|
{
|
|
308
357
|
role: 'user',
|
|
309
|
-
content: buildDriftJudgePrompt({
|
|
358
|
+
content: buildDriftJudgePrompt({
|
|
359
|
+
baselineWindowStats,
|
|
360
|
+
currentWindowStats,
|
|
361
|
+
modelSwitchNote,
|
|
362
|
+
}),
|
|
310
363
|
},
|
|
311
364
|
];
|
|
312
365
|
const usageTotals = {
|
|
@@ -433,11 +486,12 @@ The JSON object must have exactly these keys:
|
|
|
433
486
|
Example of a correctly formatted reply (structure only — do not reuse these values):
|
|
434
487
|
{"drift":true,"severity":"high","reasons":["p95 latency 180ms -> 450ms","error rate 2% -> 9%","search_docs share 40% -> 75%"],"recommended_action":"Page the on-call engineer to investigate the search_docs regression."}`;
|
|
435
488
|
function buildDriftJudgePrompt(options) {
|
|
436
|
-
const { baselineWindowStats, currentWindowStats } = options;
|
|
489
|
+
const { baselineWindowStats, currentWindowStats, modelSwitchNote } = options;
|
|
490
|
+
const switchContext = modelSwitchNote ? `\n\nCONTEXT: ${modelSwitchNote}` : '';
|
|
437
491
|
return `Compare the CURRENT window against the BASELINE and decide whether the agent's behavior has drifted enough to warrant a human alert. Consider shifts in tool-call mix, rising error rate, latency regressions, and token-spend spikes.
|
|
438
492
|
|
|
439
493
|
BASELINE: ${JSON.stringify(baselineWindowStats)}
|
|
440
|
-
CURRENT: ${JSON.stringify(currentWindowStats)}
|
|
494
|
+
CURRENT: ${JSON.stringify(currentWindowStats)}${switchContext}
|
|
441
495
|
|
|
442
496
|
Reply with ONLY the JSON object described in your instructions.`;
|
|
443
497
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,9 @@ export { withSkillExecutionSpan, type WithSkillExecutionSpanOptions, } from './t
|
|
|
9
9
|
export { summarizeTokenUsage, recordUsageOnSpan, type TokenUsageSummary, } from './telemetry/usage-tracking.js';
|
|
10
10
|
export { ACTION_TYPES, CONTROL_ACTIONS, categorizeAction, type ActionType, type ActionCategory, type ActionIntent, type DriftSeverity, type Approval, type ApprovalStatus, type AgentRuntimeState, type AgentStatus, type DriftHistoryEntry, type ActionLogEntry, type ActionOutcome, type NotificationMessage, type StateStore, type Notifier, } from './autopilot/types.js';
|
|
11
11
|
export { evaluatePolicies, computeWindowDeltas, PolicyConfigSchema, PolicyRuleSchema, PolicyConditionSchema, type PolicyConfig, type PolicyRule, type PolicyCondition, type WindowDeltas, } from './autopilot/policy.js';
|
|
12
|
+
export { MemoryStateStore } from './autopilot/memory-store.js';
|
|
13
|
+
export { notifierForAction, safeNotify, notifyAll, type NotifierRegistry, type DispatchLogger, } from './autopilot/notify-dispatch.js';
|
|
14
|
+
export { executeControlAction, type ControlActionContext, type ControlActionResult, } from './autopilot/actions.js';
|
|
15
|
+
export { ApprovalService, type ApprovalDecision, type ApprovalServiceOptions, } from './autopilot/approval-service.js';
|
|
16
|
+
export { AutopilotScheduler, type SchedulerLogger, type AutopilotSchedulerOptions, } from './autopilot/scheduler.js';
|
|
12
17
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -15,4 +15,9 @@ export { summarizeTokenUsage, recordUsageOnSpan, } from './telemetry/usage-track
|
|
|
15
15
|
// --- autopilot (Loop 2: drift-triggered remediation, pure decision layer) ---
|
|
16
16
|
export { ACTION_TYPES, CONTROL_ACTIONS, categorizeAction, } from './autopilot/types.js';
|
|
17
17
|
export { evaluatePolicies, computeWindowDeltas, PolicyConfigSchema, PolicyRuleSchema, PolicyConditionSchema, } from './autopilot/policy.js';
|
|
18
|
+
export { MemoryStateStore } from './autopilot/memory-store.js';
|
|
19
|
+
export { notifierForAction, safeNotify, notifyAll, } from './autopilot/notify-dispatch.js';
|
|
20
|
+
export { executeControlAction, } from './autopilot/actions.js';
|
|
21
|
+
export { ApprovalService, } from './autopilot/approval-service.js';
|
|
22
|
+
export { AutopilotScheduler, } from './autopilot/scheduler.js';
|
|
18
23
|
//# sourceMappingURL=index.js.map
|
package/dist/redis.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subpath entry: `import { RedisStateStore } from '@driftwatch/sdk/redis'`.
|
|
3
|
+
* Isolated from the package root so core SDK consumers never pull in
|
|
4
|
+
* `ioredis` — only importers of this subpath need it installed (it's an
|
|
5
|
+
* optional peer dependency; see package.json).
|
|
6
|
+
*/
|
|
7
|
+
export { RedisStateStore } from './autopilot/redis-store.js';
|
|
8
|
+
//# sourceMappingURL=redis.d.ts.map
|
package/dist/redis.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subpath entry: `import { RedisStateStore } from '@driftwatch/sdk/redis'`.
|
|
3
|
+
* Isolated from the package root so core SDK consumers never pull in
|
|
4
|
+
* `ioredis` — only importers of this subpath need it installed (it's an
|
|
5
|
+
* optional peer dependency; see package.json).
|
|
6
|
+
*/
|
|
7
|
+
export { RedisStateStore } from './autopilot/redis-store.js';
|
|
8
|
+
//# sourceMappingURL=redis.js.map
|
package/dist/telemetry/otel.d.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Logs: the bundled pino auto-instrumentation (enabled by default below) both
|
|
24
24
|
* injects trace_id/span_id into every Fastify log line for trace<->log
|
|
25
25
|
* correlation and emits those lines as OTel log records — which the
|
|
26
|
-
* LogRecordProcessor registered here
|
|
26
|
+
* LogRecordProcessor registered here sends to SigNoz.
|
|
27
27
|
*/
|
|
28
28
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
29
29
|
import type { TelemetryConfig } from '../config/schema.js';
|
package/dist/telemetry/otel.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Logs: the bundled pino auto-instrumentation (enabled by default below) both
|
|
24
24
|
* injects trace_id/span_id into every Fastify log line for trace<->log
|
|
25
25
|
* correlation and emits those lines as OTel log records — which the
|
|
26
|
-
* LogRecordProcessor registered here
|
|
26
|
+
* LogRecordProcessor registered here sends to SigNoz.
|
|
27
27
|
*/
|
|
28
28
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
29
29
|
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
package/package.json
CHANGED
|
@@ -1,71 +1,84 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@driftwatch/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Self-observing AI agent SDK: OpenTelemetry instrumentation for AI SDK agents plus an LLM-over-traces behavioral drift detector. Bring your own model client — no provider SDKs bundled.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Victory Lucky",
|
|
8
|
-
"homepage": "https://github.com/codewithveek/drift-watch#readme",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/codewithveek/drift-watch.git",
|
|
12
|
-
"directory": "packages/sdk"
|
|
13
|
-
},
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/codewithveek/drift-watch/issues"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"ai-agent",
|
|
19
|
-
"opentelemetry",
|
|
20
|
-
"observability",
|
|
21
|
-
"telemetry",
|
|
22
|
-
"drift-detection",
|
|
23
|
-
"ai-sdk",
|
|
24
|
-
"llm",
|
|
25
|
-
"vercel-ai-sdk",
|
|
26
|
-
"otel"
|
|
27
|
-
],
|
|
28
|
-
"main": "./dist/index.js",
|
|
29
|
-
"types": "./dist/index.d.ts",
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"@opentelemetry/
|
|
54
|
-
"@opentelemetry/
|
|
55
|
-
"@opentelemetry/exporter-
|
|
56
|
-
"@opentelemetry/exporter-
|
|
57
|
-
"@opentelemetry/
|
|
58
|
-
"@opentelemetry/
|
|
59
|
-
"@opentelemetry/sdk-
|
|
60
|
-
"@opentelemetry/sdk-
|
|
61
|
-
"@opentelemetry/
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftwatch/sdk",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Self-observing AI agent SDK: OpenTelemetry instrumentation for AI SDK agents plus an LLM-over-traces behavioral drift detector. Bring your own model client — no provider SDKs bundled.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Victory Lucky",
|
|
8
|
+
"homepage": "https://github.com/codewithveek/drift-watch#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/codewithveek/drift-watch.git",
|
|
12
|
+
"directory": "packages/sdk"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/codewithveek/drift-watch/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"opentelemetry",
|
|
20
|
+
"observability",
|
|
21
|
+
"telemetry",
|
|
22
|
+
"drift-detection",
|
|
23
|
+
"ai-sdk",
|
|
24
|
+
"llm",
|
|
25
|
+
"vercel-ai-sdk",
|
|
26
|
+
"otel"
|
|
27
|
+
],
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./redis": {
|
|
37
|
+
"types": "./dist/redis.d.ts",
|
|
38
|
+
"import": "./dist/redis.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"!dist/**/*.map"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@opentelemetry/api": "^1.9.0",
|
|
53
|
+
"@opentelemetry/auto-instrumentations-node": "^0.78.0",
|
|
54
|
+
"@opentelemetry/exporter-logs-otlp-proto": "^0.220.0",
|
|
55
|
+
"@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0",
|
|
56
|
+
"@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
|
|
57
|
+
"@opentelemetry/resources": "^2.9.0",
|
|
58
|
+
"@opentelemetry/sdk-logs": "^0.220.0",
|
|
59
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
60
|
+
"@opentelemetry/sdk-node": "^0.220.0",
|
|
61
|
+
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
62
|
+
"ai": "^7.0.31",
|
|
63
|
+
"zod": "^3.25.76"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"ioredis": "^5.4.1"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"ioredis": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/node": "^22.0.0",
|
|
75
|
+
"ioredis": "^5.4.1",
|
|
76
|
+
"typescript": "^5.6.0",
|
|
77
|
+
"vitest": "^4.1.10"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "tsc",
|
|
81
|
+
"typecheck": "tsc --noEmit",
|
|
82
|
+
"test": "vitest run"
|
|
83
|
+
}
|
|
84
|
+
}
|