@useconductor/conductor 1.0.0 → 1.0.1

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.
Files changed (145) hide show
  1. package/.github/README.md +374 -7
  2. package/.github/workflows/ci.yml +3 -1
  3. package/.github/workflows/claude-code-review.yml +1 -15
  4. package/.github/workflows/publish.yml +43 -0
  5. package/README.md +290 -121
  6. package/dist/cli/commands/audit.d.ts +40 -0
  7. package/dist/cli/commands/audit.d.ts.map +1 -0
  8. package/dist/cli/commands/audit.js +272 -0
  9. package/dist/cli/commands/audit.js.map +1 -0
  10. package/dist/cli/commands/circuit.d.ts +13 -0
  11. package/dist/cli/commands/circuit.d.ts.map +1 -0
  12. package/dist/cli/commands/circuit.js +53 -0
  13. package/dist/cli/commands/circuit.js.map +1 -0
  14. package/dist/cli/commands/config.d.ts +31 -0
  15. package/dist/cli/commands/config.d.ts.map +1 -0
  16. package/dist/cli/commands/config.js +152 -0
  17. package/dist/cli/commands/config.js.map +1 -0
  18. package/dist/cli/commands/init.d.ts +5 -8
  19. package/dist/cli/commands/init.d.ts.map +1 -1
  20. package/dist/cli/commands/init.js +86 -123
  21. package/dist/cli/commands/init.js.map +1 -1
  22. package/dist/cli/commands/marketplace.js +1 -1
  23. package/dist/cli/commands/onboard.d.ts.map +1 -1
  24. package/dist/cli/commands/onboard.js +33 -11
  25. package/dist/cli/commands/onboard.js.map +1 -1
  26. package/dist/cli/commands/release.d.ts.map +1 -1
  27. package/dist/cli/commands/release.js +1 -1
  28. package/dist/cli/commands/release.js.map +1 -1
  29. package/dist/cli/index.js +146 -10
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/core/audit.d.ts.map +1 -1
  32. package/dist/core/audit.js +5 -2
  33. package/dist/core/audit.js.map +1 -1
  34. package/dist/core/conductor.d.ts.map +1 -1
  35. package/dist/core/conductor.js +12 -0
  36. package/dist/core/conductor.js.map +1 -1
  37. package/dist/core/config.d.ts +3 -0
  38. package/dist/core/config.d.ts.map +1 -1
  39. package/dist/core/config.js +46 -2
  40. package/dist/core/config.js.map +1 -1
  41. package/dist/core/database.d.ts +3 -0
  42. package/dist/core/database.d.ts.map +1 -1
  43. package/dist/core/database.js +26 -0
  44. package/dist/core/database.js.map +1 -1
  45. package/dist/core/encryption.d.ts +34 -0
  46. package/dist/core/encryption.d.ts.map +1 -0
  47. package/dist/core/encryption.js +96 -0
  48. package/dist/core/encryption.js.map +1 -0
  49. package/dist/core/zero-config.d.ts.map +1 -1
  50. package/dist/core/zero-config.js +1 -4
  51. package/dist/core/zero-config.js.map +1 -1
  52. package/dist/dashboard/server.d.ts.map +1 -1
  53. package/dist/dashboard/server.js +112 -16
  54. package/dist/dashboard/server.js.map +1 -1
  55. package/dist/mcp/server.d.ts.map +1 -1
  56. package/dist/mcp/server.js +30 -2
  57. package/dist/mcp/server.js.map +1 -1
  58. package/dist/plugins/builtin/aws.d.ts +31 -0
  59. package/dist/plugins/builtin/aws.d.ts.map +1 -0
  60. package/dist/plugins/builtin/aws.js +149 -0
  61. package/dist/plugins/builtin/aws.js.map +1 -0
  62. package/dist/plugins/builtin/database.d.ts +1 -0
  63. package/dist/plugins/builtin/database.d.ts.map +1 -1
  64. package/dist/plugins/builtin/database.js +26 -1
  65. package/dist/plugins/builtin/database.js.map +1 -1
  66. package/dist/plugins/builtin/docker.d.ts +4 -0
  67. package/dist/plugins/builtin/docker.d.ts.map +1 -1
  68. package/dist/plugins/builtin/docker.js +20 -1
  69. package/dist/plugins/builtin/docker.js.map +1 -1
  70. package/dist/plugins/builtin/gcp.d.ts +28 -0
  71. package/dist/plugins/builtin/gcp.d.ts.map +1 -0
  72. package/dist/plugins/builtin/gcp.js +135 -0
  73. package/dist/plugins/builtin/gcp.js.map +1 -0
  74. package/dist/plugins/builtin/index.d.ts.map +1 -1
  75. package/dist/plugins/builtin/index.js +4 -0
  76. package/dist/plugins/builtin/index.js.map +1 -1
  77. package/dist/plugins/builtin/jira.d.ts.map +1 -1
  78. package/dist/plugins/builtin/jira.js +4 -2
  79. package/dist/plugins/builtin/jira.js.map +1 -1
  80. package/dist/plugins/builtin/linear.js +1 -1
  81. package/dist/plugins/builtin/linear.js.map +1 -1
  82. package/dist/plugins/builtin/shell.js +1 -1
  83. package/dist/plugins/builtin/shell.js.map +1 -1
  84. package/dist/plugins/builtin/slack.d.ts +1 -0
  85. package/dist/plugins/builtin/slack.d.ts.map +1 -1
  86. package/dist/plugins/builtin/slack.js +9 -1
  87. package/dist/plugins/builtin/slack.js.map +1 -1
  88. package/dist/plugins/builtin/spotify.js +1 -1
  89. package/dist/plugins/builtin/spotify.js.map +1 -1
  90. package/dist/plugins/builtin/vercel.d.ts.map +1 -1
  91. package/dist/plugins/builtin/vercel.js +3 -1
  92. package/dist/plugins/builtin/vercel.js.map +1 -1
  93. package/dist/security/sso.d.ts +37 -0
  94. package/dist/security/sso.d.ts.map +1 -0
  95. package/dist/security/sso.js +92 -0
  96. package/dist/security/sso.js.map +1 -0
  97. package/docs/deployment.md +201 -0
  98. package/docs/plugin-sdk.md +212 -0
  99. package/package.json +11 -8
  100. package/src/cli/commands/audit.ts +318 -0
  101. package/src/cli/commands/circuit.ts +63 -0
  102. package/src/cli/commands/config.ts +176 -0
  103. package/src/cli/commands/init.ts +87 -145
  104. package/src/cli/commands/marketplace.ts +1 -1
  105. package/src/cli/commands/onboard.ts +33 -11
  106. package/src/cli/commands/release.ts +13 -6
  107. package/src/cli/index.ts +165 -11
  108. package/src/core/audit.ts +5 -2
  109. package/src/core/conductor.ts +11 -0
  110. package/src/core/config.ts +47 -2
  111. package/src/core/database.ts +32 -0
  112. package/src/core/encryption.ts +110 -0
  113. package/src/core/zero-config.ts +1 -5
  114. package/src/dashboard/server.ts +135 -16
  115. package/src/mcp/server.ts +40 -2
  116. package/src/plugins/builtin/aws.ts +162 -0
  117. package/src/plugins/builtin/database.ts +19 -1
  118. package/src/plugins/builtin/docker.ts +17 -1
  119. package/src/plugins/builtin/gcp.ts +145 -0
  120. package/src/plugins/builtin/index.ts +4 -0
  121. package/src/plugins/builtin/jira.ts +23 -19
  122. package/src/plugins/builtin/linear.ts +1 -1
  123. package/src/plugins/builtin/shell.ts +1 -1
  124. package/src/plugins/builtin/slack.ts +6 -1
  125. package/src/plugins/builtin/spotify.ts +1 -1
  126. package/src/plugins/builtin/vercel.ts +3 -1
  127. package/src/security/sso.ts +124 -0
  128. package/tests/audit.test.ts +185 -0
  129. package/tests/circuit-breaker.test.ts +125 -0
  130. package/tests/docker.test.ts +244 -39
  131. package/tests/errors.test.ts +122 -0
  132. package/tests/github.test.ts.skip +392 -0
  133. package/tests/jira.test.ts +310 -0
  134. package/tests/linear.test.ts +366 -0
  135. package/tests/mcp.test.ts.skip +243 -0
  136. package/tests/notion.test.ts +257 -0
  137. package/tests/retry.test.ts +104 -0
  138. package/tests/shell.test.ts +262 -30
  139. package/tests/slack.test.ts +250 -0
  140. package/tests/stripe.test.ts +272 -0
  141. package/tests/validation.test.ts +173 -0
  142. package/tests/vercel.test.ts +368 -0
  143. package/tests/zero-config.test.ts +566 -0
  144. package/C.png +0 -0
  145. package/tests/mcp.test.ts +0 -14
@@ -76,11 +76,7 @@ export class JiraPlugin implements Plugin {
76
76
  return { domain, email, token };
77
77
  }
78
78
 
79
- private async jiraFetch(
80
- path: string,
81
- body?: Record<string, unknown>,
82
- method = 'GET',
83
- ): Promise<any> {
79
+ private async jiraFetch(path: string, body?: Record<string, unknown>, method = 'GET'): Promise<any> {
84
80
  const { domain, email, token } = await this.getCredentials();
85
81
  const auth = Buffer.from(`${email}:${token}`).toString('base64');
86
82
  const base = `https://${domain}.atlassian.net/rest/api/3`;
@@ -188,7 +184,9 @@ export class JiraPlugin implements Plugin {
188
184
  required: ['key'],
189
185
  },
190
186
  handler: async ({ key }: any) => {
191
- const i = await this.jiraFetch(`/issue/${key}?fields=summary,status,priority,assignee,reporter,issuetype,project,created,updated,labels,description,comment`);
187
+ const i = await this.jiraFetch(
188
+ `/issue/${key}?fields=summary,status,priority,assignee,reporter,issuetype,project,created,updated,labels,description,comment`,
189
+ );
192
190
  const base = this.formatIssue(i);
193
191
  const comments = (i.fields?.comment?.comments ?? []).slice(-5).map((c: any) => ({
194
192
  author: c.author?.displayName,
@@ -287,13 +285,17 @@ export class JiraPlugin implements Plugin {
287
285
  },
288
286
  requiresApproval: true,
289
287
  handler: async ({ key, body }: any) => {
290
- const data = await this.jiraFetch(`/issue/${key}/comment`, {
291
- body: {
292
- type: 'doc',
293
- version: 1,
294
- content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }],
288
+ const data = await this.jiraFetch(
289
+ `/issue/${key}/comment`,
290
+ {
291
+ body: {
292
+ type: 'doc',
293
+ version: 1,
294
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }],
295
+ },
295
296
  },
296
- }, 'POST');
297
+ 'POST',
298
+ );
297
299
  return { id: data.id, author: data.author?.displayName, created: data.created };
298
300
  },
299
301
  },
@@ -360,15 +362,17 @@ export class JiraPlugin implements Plugin {
360
362
  const body: Record<string, unknown> = { transition: { id: transition_id } };
361
363
  if (comment) {
362
364
  body.update = {
363
- comment: [{
364
- add: {
365
- body: {
366
- type: 'doc',
367
- version: 1,
368
- content: [{ type: 'paragraph', content: [{ type: 'text', text: comment }] }],
365
+ comment: [
366
+ {
367
+ add: {
368
+ body: {
369
+ type: 'doc',
370
+ version: 1,
371
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: comment }] }],
372
+ },
369
373
  },
370
374
  },
371
- }],
375
+ ],
372
376
  };
373
377
  }
374
378
  await this.jiraFetch(`/issue/${key}/transitions`, body, 'POST');
@@ -42,7 +42,7 @@ export class LinearPlugin implements Plugin {
42
42
  }
43
43
 
44
44
  isConfigured(): boolean {
45
- return true; // checked at tool call time
45
+ return true; // check at tool call time
46
46
  }
47
47
 
48
48
  private async getApiKey(): Promise<string> {
@@ -89,7 +89,7 @@ const SAFE_COMMANDS = new Set([
89
89
 
90
90
  // Dangerous patterns that are never allowed even with approval
91
91
  const DANGEROUS_PATTERNS = [
92
- /\brm\s+-rf\s+\/\b/,
92
+ /\brm\s+-r[f]?\s+\/(\s|$)/,
93
93
  /\bmkfs\b/,
94
94
  /\bdd\s+if\b/,
95
95
  /\bchmod\s+[0-7]*777\s+\/\b/,
@@ -26,13 +26,18 @@ export class SlackPlugin implements Plugin {
26
26
  version = '1.0.0';
27
27
 
28
28
  private keychain!: Keychain;
29
+ private hasToken = false;
29
30
 
30
31
  async initialize(conductor: Conductor): Promise<void> {
31
32
  this.keychain = new Keychain(conductor.getConfig().getConfigDir());
33
+ try {
34
+ const t = await this.keychain.get('slack', 'bot_token');
35
+ this.hasToken = !!t;
36
+ } catch { this.hasToken = false; }
32
37
  }
33
38
 
34
39
  isConfigured(): boolean {
35
- return true;
40
+ return this.hasToken || !!process.env['SLACK_BOT_TOKEN'];
36
41
  }
37
42
 
38
43
  private async getToken(): Promise<string> {
@@ -73,7 +73,7 @@ export class SpotifyPlugin implements Plugin {
73
73
  }
74
74
 
75
75
  isConfigured(): boolean {
76
- return true;
76
+ return true; // check at tool call time
77
77
  }
78
78
 
79
79
  // ── Auth helpers ────────────────────────────────────────────────────────────
@@ -60,7 +60,9 @@ export class VercelPlugin implements Plugin {
60
60
  }
61
61
 
62
62
  isConfigured(): boolean {
63
- return true;
63
+ // Check if we have a token - return true if keychain has token
64
+ // This is called synchronously so we can't await - use sync check if available
65
+ return true; // Real check happens at tool call time
64
66
  }
65
67
 
66
68
  private async getAuth(): Promise<{ token: string; teamId: string | null }> {
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Enterprise SSO/OIDC Auth Middleware
3
+ *
4
+ * Supports:
5
+ * - OIDC (Okta, Auth0, Google Workspace, Azure AD)
6
+ * - Custom JWT
7
+ *
8
+ * Usage:
9
+ * conductor config set security.auth.provider oidc
10
+ * conductor config set security.auth.clientId <id>
11
+ * conductor config set security.auth.clientSecret <secret>
12
+ * conductor config set security.auth.issuerUrl <url>
13
+ */
14
+
15
+ import jwt from 'jsonwebtoken';
16
+
17
+ export interface AuthConfig {
18
+ provider: 'none' | 'oidc' | 'saml' | 'jwt';
19
+ issuerUrl?: string;
20
+ clientId?: string;
21
+ clientSecret?: string;
22
+ audience?: string;
23
+ jwksUri?: string;
24
+ }
25
+
26
+ export interface AuthUser {
27
+ id: string;
28
+ email: string;
29
+ name?: string;
30
+ roles: string[];
31
+ }
32
+
33
+ export class AuthMiddleware {
34
+ private config: AuthConfig;
35
+
36
+ constructor(config: any) {
37
+ this.config = config?.security?.auth ?? { provider: 'none' };
38
+ }
39
+
40
+ isEnabled(): boolean {
41
+ return this.config.provider !== 'none';
42
+ }
43
+
44
+ async verifyToken(token: string): Promise<AuthUser | null> {
45
+ if (this.config.provider === 'none' || !token) {
46
+ return null;
47
+ }
48
+
49
+ try {
50
+ if (this.config.provider === 'jwt') {
51
+ return this.verifyJWT(token);
52
+ }
53
+ return this.verifyOIDC(token);
54
+ } catch (err) {
55
+ console.error('Auth verification failed:', err);
56
+ return null;
57
+ }
58
+ }
59
+
60
+ private verifyJWT(token: string): AuthUser {
61
+ const secret = this.config.clientSecret;
62
+ if (!secret) throw new Error('JWT secret not configured');
63
+
64
+ const decoded = jwt.verify(token, secret, {
65
+ issuer: this.config.issuerUrl,
66
+ audience: this.config.audience,
67
+ }) as any;
68
+
69
+ return {
70
+ id: decoded.sub,
71
+ email: decoded.email,
72
+ name: decoded.name,
73
+ roles: decoded.roles ?? [],
74
+ };
75
+ }
76
+
77
+ private async verifyOIDC(token: string): Promise<AuthUser> {
78
+ // Introspect endpoint
79
+ const response = await fetch(this.config.issuerUrl + '/oauth/introspect', {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/x-www-form-urlencoded',
83
+ 'Authorization': `Basic ${Buffer.from(
84
+ `${this.config.clientId}:${this.config.clientSecret}`
85
+ ).toString('base64')}`,
86
+ },
87
+ body: `token=${token}`,
88
+ });
89
+
90
+ const data = await response.json() as any;
91
+ if (!data.active) throw new Error('Token inactive');
92
+
93
+ return {
94
+ id: data.sub,
95
+ email: data.email ?? data.preferred_username,
96
+ name: data.name,
97
+ roles: data.roles ?? [],
98
+ };
99
+ }
100
+ }
101
+
102
+ export function createAuthMiddleware(config: any) {
103
+ const auth = new AuthMiddleware(config);
104
+
105
+ return async (req: any, res: any, next: any) => {
106
+ if (!auth.isEnabled()) return next();
107
+
108
+ const token =
109
+ req.headers.authorization?.replace('Bearer ', '') ||
110
+ req.headers['x-forwarded-auth'] || '';
111
+
112
+ if (!token) {
113
+ return res.status(401).json({ error: 'Authentication required' });
114
+ }
115
+
116
+ const user = await auth.verifyToken(token);
117
+ if (!user) {
118
+ return res.status(401).json({ error: 'Invalid token' });
119
+ }
120
+
121
+ req.user = user;
122
+ next();
123
+ };
124
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { AuditLogger } from '../src/core/audit.js';
3
+ import { mkdtemp, rm } from 'fs/promises';
4
+ import { tmpdir } from 'os';
5
+ import path from 'path';
6
+
7
+ describe('AuditLogger', () => {
8
+ let logger: AuditLogger;
9
+ let tmpDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tmpDir = await mkdtemp(path.join(tmpdir(), 'conductor-audit-'));
13
+ logger = new AuditLogger(tmpDir, { flushIntervalMs: 50000 }); // long interval so tests control flush
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await logger.close();
18
+ await rm(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ it('logs an entry and flushes to disk', async () => {
22
+ await logger.log({
23
+ actor: 'user1',
24
+ action: 'tool_call',
25
+ resource: 'calc_math',
26
+ result: 'success',
27
+ metadata: { expression: '2+2' },
28
+ });
29
+ await logger.flush();
30
+
31
+ const entries = await logger.query({ actor: 'user1' });
32
+ expect(entries).toHaveLength(1);
33
+ expect(entries[0].actor).toBe('user1');
34
+ expect(entries[0].action).toBe('tool_call');
35
+ expect(entries[0].resource).toBe('calc_math');
36
+ expect(entries[0].result).toBe('success');
37
+ });
38
+
39
+ it('adds timestamp to each entry', async () => {
40
+ const before = new Date().toISOString();
41
+ await logger.log({
42
+ actor: 'system',
43
+ action: 'test',
44
+ resource: 'x',
45
+ result: 'success',
46
+ metadata: {},
47
+ });
48
+ const after = new Date().toISOString();
49
+ await logger.flush();
50
+
51
+ const entries = await logger.query();
52
+ expect(entries[0].timestamp >= before).toBe(true);
53
+ expect(entries[0].timestamp <= after).toBe(true);
54
+ });
55
+
56
+ it('chains SHA-256 hashes', async () => {
57
+ await logger.log({ actor: 'a', action: 'one', resource: 'r', result: 'success', metadata: {} });
58
+ await logger.log({ actor: 'b', action: 'two', resource: 'r', result: 'success', metadata: {} });
59
+ await logger.flush();
60
+
61
+ const entries = await logger.query();
62
+ expect(entries).toHaveLength(2);
63
+ expect(entries[0].hash).toBeTruthy();
64
+ expect(entries[1].previousHash).toBe(entries[0].hash);
65
+ });
66
+
67
+ it('verifyIntegrity returns valid for untampered log', async () => {
68
+ await logger.log({ actor: 'u', action: 'a', resource: 'r', result: 'success', metadata: {} });
69
+ await logger.log({ actor: 'u', action: 'b', resource: 'r', result: 'success', metadata: {} });
70
+ await logger.flush();
71
+
72
+ const { valid } = await logger.verifyIntegrity();
73
+ expect(valid).toBe(true);
74
+ });
75
+
76
+ it('verifyIntegrity returns valid when no log file exists', async () => {
77
+ const { valid } = await logger.verifyIntegrity();
78
+ expect(valid).toBe(true);
79
+ });
80
+
81
+ describe('toolCall convenience', () => {
82
+ it('logs tool calls correctly', async () => {
83
+ await logger.toolCall('user1', 'calc_math', { expression: '1+1' }, 'success');
84
+ await logger.flush();
85
+
86
+ const entries = await logger.query({ action: 'tool_call' });
87
+ expect(entries).toHaveLength(1);
88
+ expect(entries[0].resource).toBe('calc_math');
89
+ });
90
+
91
+ it('redacts tokens in input', async () => {
92
+ await logger.toolCall('user1', 'some_tool', { token: 'ghp_secrettoken' }, 'success');
93
+ await logger.flush();
94
+
95
+ const entries = await logger.query({ action: 'tool_call' });
96
+ const input = entries[0].metadata.input as Record<string, string>;
97
+ expect(input.token).toBe('[REDACTED]');
98
+ });
99
+ });
100
+
101
+ describe('authEvent convenience', () => {
102
+ it('logs auth_login for successful auth', async () => {
103
+ await logger.authEvent('user1', 'google', true);
104
+ await logger.flush();
105
+
106
+ const entries = await logger.query({ action: 'auth_login' });
107
+ expect(entries).toHaveLength(1);
108
+ expect(entries[0].result).toBe('success');
109
+ });
110
+
111
+ it('logs auth_failure for failed auth', async () => {
112
+ await logger.authEvent('user1', 'github', false);
113
+ await logger.flush();
114
+
115
+ const entries = await logger.query({ action: 'auth_failure' });
116
+ expect(entries).toHaveLength(1);
117
+ expect(entries[0].result).toBe('failure');
118
+ });
119
+ });
120
+
121
+ describe('configChange convenience', () => {
122
+ it('logs config changes immediately (flush on config_set)', async () => {
123
+ await logger.configChange('admin', 'ai.provider', 'openai', 'claude');
124
+ // No manual flush needed — config_set triggers immediate flush
125
+ const entries = await logger.query({ action: 'config_set' });
126
+ expect(entries).toHaveLength(1);
127
+ expect(entries[0].resource).toBe('ai.provider');
128
+ });
129
+
130
+ it('redacts sensitive config values', async () => {
131
+ await logger.configChange('admin', 'github.token', 'ghp_oldtoken', 'ghp_newtoken');
132
+ const entries = await logger.query({ action: 'config_set' });
133
+ const meta = entries[0].metadata as { old_value: string; new_value: string };
134
+ expect(meta.old_value).toBe('[REDACTED]');
135
+ expect(meta.new_value).toBe('[REDACTED]');
136
+ });
137
+ });
138
+
139
+ describe('pluginEvent convenience', () => {
140
+ it('logs plugin lifecycle events', async () => {
141
+ await logger.pluginEvent('admin', 'github', 'enable');
142
+ await logger.flush();
143
+
144
+ const entries = await logger.query({ action: 'plugin_enable' });
145
+ expect(entries).toHaveLength(1);
146
+ expect(entries[0].resource).toBe('github');
147
+ });
148
+ });
149
+
150
+ describe('query filters', () => {
151
+ beforeEach(async () => {
152
+ await logger.log({ actor: 'user1', action: 'tool_call', resource: 'calc_math', result: 'success', metadata: {} });
153
+ await logger.log({ actor: 'user2', action: 'tool_call', resource: 'shell_run', result: 'failure', metadata: {} });
154
+ await logger.log({ actor: 'user1', action: 'config_set', resource: 'ai.provider', result: 'success', metadata: {} });
155
+ await logger.flush();
156
+ });
157
+
158
+ it('filters by actor', async () => {
159
+ const entries = await logger.query({ actor: 'user1' });
160
+ expect(entries).toHaveLength(2);
161
+ expect(entries.every((e) => e.actor === 'user1')).toBe(true);
162
+ });
163
+
164
+ it('filters by action', async () => {
165
+ const entries = await logger.query({ action: 'tool_call' });
166
+ expect(entries).toHaveLength(2);
167
+ });
168
+
169
+ it('filters by result', async () => {
170
+ const entries = await logger.query({ result: 'failure' });
171
+ expect(entries).toHaveLength(1);
172
+ expect(entries[0].actor).toBe('user2');
173
+ });
174
+
175
+ it('filters by resource', async () => {
176
+ const entries = await logger.query({ resource: 'calc_math' });
177
+ expect(entries).toHaveLength(1);
178
+ });
179
+
180
+ it('respects limit', async () => {
181
+ const entries = await logger.query({ limit: 2 });
182
+ expect(entries).toHaveLength(2);
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { CircuitBreaker, CircuitOpenError } from '../src/core/circuit-breaker.js';
3
+
4
+ describe('CircuitBreaker', () => {
5
+ let cb: CircuitBreaker;
6
+
7
+ beforeEach(() => {
8
+ cb = new CircuitBreaker({ failureThreshold: 3, recoveryTimeout: 100, successThreshold: 2 });
9
+ });
10
+
11
+ it('starts in closed state', () => {
12
+ expect(cb.getState()).toBe('closed');
13
+ });
14
+
15
+ it('executes successfully in closed state', async () => {
16
+ const result = await cb.execute(async () => 'ok');
17
+ expect(result).toBe('ok');
18
+ expect(cb.getState()).toBe('closed');
19
+ });
20
+
21
+ it('opens after hitting failure threshold', async () => {
22
+ const fail = async () => { throw new Error('fail'); };
23
+ await expect(cb.execute(fail)).rejects.toThrow('fail');
24
+ await expect(cb.execute(fail)).rejects.toThrow('fail');
25
+ await expect(cb.execute(fail)).rejects.toThrow('fail');
26
+ expect(cb.getState()).toBe('open');
27
+ });
28
+
29
+ it('throws CircuitOpenError when open', async () => {
30
+ const fail = async () => { throw new Error('fail'); };
31
+ for (let i = 0; i < 3; i++) {
32
+ await expect(cb.execute(fail)).rejects.toThrow();
33
+ }
34
+ await expect(cb.execute(async () => 'ok')).rejects.toThrow(CircuitOpenError);
35
+ });
36
+
37
+ it('transitions to half_open after recovery timeout', async () => {
38
+ const fail = async () => { throw new Error('fail'); };
39
+ for (let i = 0; i < 3; i++) {
40
+ await expect(cb.execute(fail)).rejects.toThrow();
41
+ }
42
+ expect(cb.getState()).toBe('open');
43
+
44
+ // Mock time passing
45
+ vi.useFakeTimers();
46
+ vi.advanceTimersByTime(200);
47
+ expect(cb.getState()).toBe('half_open');
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ it('closes circuit after successThreshold successes in half_open', async () => {
52
+ const fail = async () => { throw new Error('fail'); };
53
+ for (let i = 0; i < 3; i++) {
54
+ await expect(cb.execute(fail)).rejects.toThrow();
55
+ }
56
+
57
+ vi.useFakeTimers();
58
+ vi.advanceTimersByTime(200);
59
+ // Must call getState() while fake timers are active to trigger the open→half_open transition
60
+ expect(cb.getState()).toBe('half_open');
61
+ vi.useRealTimers();
62
+
63
+ await cb.execute(async () => 'ok');
64
+ await cb.execute(async () => 'ok');
65
+ expect(cb.getState()).toBe('closed');
66
+ });
67
+
68
+ it('re-opens on failure in half_open state', async () => {
69
+ const fail = async () => { throw new Error('fail'); };
70
+ for (let i = 0; i < 3; i++) {
71
+ await expect(cb.execute(fail)).rejects.toThrow();
72
+ }
73
+
74
+ vi.useFakeTimers();
75
+ vi.advanceTimersByTime(200);
76
+ // Must call getState() while fake timers are active to trigger the open→half_open transition
77
+ expect(cb.getState()).toBe('half_open');
78
+ vi.useRealTimers();
79
+
80
+ await expect(cb.execute(fail)).rejects.toThrow();
81
+ expect(cb.getState()).toBe('open');
82
+ });
83
+
84
+ it('reset() returns circuit to closed state', async () => {
85
+ const fail = async () => { throw new Error('fail'); };
86
+ for (let i = 0; i < 3; i++) {
87
+ await expect(cb.execute(fail)).rejects.toThrow();
88
+ }
89
+ expect(cb.getState()).toBe('open');
90
+ cb.reset();
91
+ expect(cb.getState()).toBe('closed');
92
+ });
93
+
94
+ it('getStatus() returns state and failure counts', async () => {
95
+ const fail = async () => { throw new Error('fail'); };
96
+ await expect(cb.execute(fail)).rejects.toThrow();
97
+ await expect(cb.execute(fail)).rejects.toThrow();
98
+
99
+ const status = cb.getStatus();
100
+ expect(status.state).toBe('closed');
101
+ expect(status.failures).toBe(2);
102
+ });
103
+
104
+ it('resets failure count on success', async () => {
105
+ const fail = async () => { throw new Error('fail'); };
106
+ await expect(cb.execute(fail)).rejects.toThrow();
107
+ await expect(cb.execute(fail)).rejects.toThrow();
108
+ await cb.execute(async () => 'ok'); // success resets failures
109
+ const status = cb.getStatus();
110
+ expect(status.failures).toBe(0);
111
+ });
112
+
113
+ it('uses default options when none provided', () => {
114
+ const defaultCb = new CircuitBreaker();
115
+ expect(defaultCb.getState()).toBe('closed');
116
+ });
117
+
118
+ it('handles concurrent executions', async () => {
119
+ const results = await Promise.all(
120
+ Array.from({ length: 10 }, () => cb.execute(async () => 42)),
121
+ );
122
+ expect(results).toHaveLength(10);
123
+ expect(results.every((r) => r === 42)).toBe(true);
124
+ });
125
+ });