@renseiai/agentfactory-linear 0.8.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 +91 -0
- package/dist/src/agent-client-project-repo.test.d.ts +2 -0
- package/dist/src/agent-client-project-repo.test.d.ts.map +1 -0
- package/dist/src/agent-client-project-repo.test.js +153 -0
- package/dist/src/agent-client.d.ts +261 -0
- package/dist/src/agent-client.d.ts.map +1 -0
- package/dist/src/agent-client.js +902 -0
- package/dist/src/agent-session.d.ts +303 -0
- package/dist/src/agent-session.d.ts.map +1 -0
- package/dist/src/agent-session.js +969 -0
- package/dist/src/checkbox-utils.d.ts +88 -0
- package/dist/src/checkbox-utils.d.ts.map +1 -0
- package/dist/src/checkbox-utils.js +120 -0
- package/dist/src/circuit-breaker.d.ts +76 -0
- package/dist/src/circuit-breaker.d.ts.map +1 -0
- package/dist/src/circuit-breaker.js +229 -0
- package/dist/src/circuit-breaker.test.d.ts +2 -0
- package/dist/src/circuit-breaker.test.d.ts.map +1 -0
- package/dist/src/circuit-breaker.test.js +292 -0
- package/dist/src/constants.d.ts +87 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +101 -0
- package/dist/src/defaults/auto-trigger.d.ts +35 -0
- package/dist/src/defaults/auto-trigger.d.ts.map +1 -0
- package/dist/src/defaults/auto-trigger.js +36 -0
- package/dist/src/defaults/index.d.ts +12 -0
- package/dist/src/defaults/index.d.ts.map +1 -0
- package/dist/src/defaults/index.js +11 -0
- package/dist/src/defaults/priority.d.ts +20 -0
- package/dist/src/defaults/priority.d.ts.map +1 -0
- package/dist/src/defaults/priority.js +37 -0
- package/dist/src/defaults/prompts.d.ts +42 -0
- package/dist/src/defaults/prompts.d.ts.map +1 -0
- package/dist/src/defaults/prompts.js +310 -0
- package/dist/src/defaults/prompts.test.d.ts +2 -0
- package/dist/src/defaults/prompts.test.d.ts.map +1 -0
- package/dist/src/defaults/prompts.test.js +263 -0
- package/dist/src/defaults/work-type-detection.d.ts +19 -0
- package/dist/src/defaults/work-type-detection.d.ts.map +1 -0
- package/dist/src/defaults/work-type-detection.js +93 -0
- package/dist/src/errors.d.ts +91 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +173 -0
- package/dist/src/frontend-adapter.d.ts +168 -0
- package/dist/src/frontend-adapter.d.ts.map +1 -0
- package/dist/src/frontend-adapter.js +314 -0
- package/dist/src/frontend-adapter.test.d.ts +2 -0
- package/dist/src/frontend-adapter.test.d.ts.map +1 -0
- package/dist/src/frontend-adapter.test.js +545 -0
- package/dist/src/index.d.ts +28 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +30 -0
- package/dist/src/issue-tracker-proxy.d.ts +140 -0
- package/dist/src/issue-tracker-proxy.d.ts.map +1 -0
- package/dist/src/issue-tracker-proxy.js +10 -0
- package/dist/src/platform-adapter.d.ts +132 -0
- package/dist/src/platform-adapter.d.ts.map +1 -0
- package/dist/src/platform-adapter.js +260 -0
- package/dist/src/platform-adapter.test.d.ts +2 -0
- package/dist/src/platform-adapter.test.d.ts.map +1 -0
- package/dist/src/platform-adapter.test.js +468 -0
- package/dist/src/proxy-client.d.ts +103 -0
- package/dist/src/proxy-client.d.ts.map +1 -0
- package/dist/src/proxy-client.js +191 -0
- package/dist/src/rate-limiter.d.ts +64 -0
- package/dist/src/rate-limiter.d.ts.map +1 -0
- package/dist/src/rate-limiter.js +163 -0
- package/dist/src/rate-limiter.test.d.ts +2 -0
- package/dist/src/rate-limiter.test.d.ts.map +1 -0
- package/dist/src/rate-limiter.test.js +217 -0
- package/dist/src/retry.d.ts +59 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +82 -0
- package/dist/src/types.d.ts +492 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +143 -0
- package/dist/src/utils.d.ts +52 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +277 -0
- package/dist/src/webhook-types.d.ts +308 -0
- package/dist/src/webhook-types.d.ts.map +1 -0
- package/dist/src/webhook-types.js +46 -0
- package/package.json +70 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox Utilities
|
|
3
|
+
*
|
|
4
|
+
* Parse and update markdown checkboxes in Linear issue descriptions.
|
|
5
|
+
* Checkboxes follow the standard markdown format:
|
|
6
|
+
* - [ ] Unchecked item
|
|
7
|
+
* - [x] Checked item
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Represents a checkbox item parsed from markdown
|
|
11
|
+
*/
|
|
12
|
+
export interface CheckboxItem {
|
|
13
|
+
/** Zero-based index among all checkboxes in the document */
|
|
14
|
+
index: number;
|
|
15
|
+
/** Line number in the markdown (zero-based) */
|
|
16
|
+
line: number;
|
|
17
|
+
/** Whether the checkbox is checked */
|
|
18
|
+
checked: boolean;
|
|
19
|
+
/** The text content after the checkbox */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Indentation level (number of leading spaces) */
|
|
22
|
+
indentLevel: number;
|
|
23
|
+
/** The raw line text */
|
|
24
|
+
raw: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Checkbox update specification
|
|
28
|
+
*/
|
|
29
|
+
export interface CheckboxUpdate {
|
|
30
|
+
/** Update checkbox by its index */
|
|
31
|
+
index?: number;
|
|
32
|
+
/** Update checkbox by matching text pattern */
|
|
33
|
+
textPattern?: string | RegExp;
|
|
34
|
+
/** New checked state */
|
|
35
|
+
checked: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse markdown and extract checkbox items
|
|
39
|
+
*
|
|
40
|
+
* @param markdown - The markdown content to parse
|
|
41
|
+
* @returns Array of checkbox items found in the markdown
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseCheckboxes(markdown: string): CheckboxItem[];
|
|
44
|
+
/**
|
|
45
|
+
* Update a checkbox by its index
|
|
46
|
+
*
|
|
47
|
+
* @param markdown - The markdown content
|
|
48
|
+
* @param index - The checkbox index to update
|
|
49
|
+
* @param checked - The new checked state
|
|
50
|
+
* @returns The updated markdown, or the original if checkbox not found
|
|
51
|
+
*/
|
|
52
|
+
export declare function updateCheckbox(markdown: string, index: number, checked: boolean): string;
|
|
53
|
+
/**
|
|
54
|
+
* Update a checkbox by matching its text content
|
|
55
|
+
*
|
|
56
|
+
* @param markdown - The markdown content
|
|
57
|
+
* @param textPattern - String or regex to match against checkbox text
|
|
58
|
+
* @param checked - The new checked state
|
|
59
|
+
* @returns The updated markdown, or the original if no match found
|
|
60
|
+
*/
|
|
61
|
+
export declare function updateCheckboxByText(markdown: string, textPattern: string | RegExp, checked: boolean): string;
|
|
62
|
+
/**
|
|
63
|
+
* Apply multiple checkbox updates at once
|
|
64
|
+
*
|
|
65
|
+
* @param markdown - The markdown content
|
|
66
|
+
* @param updates - Array of updates to apply
|
|
67
|
+
* @returns The updated markdown
|
|
68
|
+
*/
|
|
69
|
+
export declare function updateCheckboxes(markdown: string, updates: CheckboxUpdate[]): string;
|
|
70
|
+
/**
|
|
71
|
+
* Check if markdown contains any checkboxes
|
|
72
|
+
*
|
|
73
|
+
* @param markdown - The markdown content to check
|
|
74
|
+
* @returns True if at least one checkbox is found
|
|
75
|
+
*/
|
|
76
|
+
export declare function hasCheckboxes(markdown: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Get summary of checkbox states
|
|
79
|
+
*
|
|
80
|
+
* @param markdown - The markdown content
|
|
81
|
+
* @returns Object with counts of checked and unchecked items
|
|
82
|
+
*/
|
|
83
|
+
export declare function getCheckboxSummary(markdown: string): {
|
|
84
|
+
total: number;
|
|
85
|
+
checked: number;
|
|
86
|
+
unchecked: number;
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=checkbox-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkbox-utils.d.ts","sourceRoot":"","sources":["../../src/checkbox-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAA;IACZ,sCAAsC;IACtC,OAAO,EAAE,OAAO,CAAA;IAChB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAA;IACZ,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IAC7B,wBAAwB;IACxB,OAAO,EAAE,OAAO,CAAA;CACjB;AAMD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAuBhE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,GACf,MAAM,CAaR;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAAG,MAAM,EAC5B,OAAO,EAAE,OAAO,GACf,MAAM,CAeR;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,cAAc,EAAE,GACxB,MAAM,CAYR;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGvD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG;IACpD,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;CAClB,CASA"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox Utilities
|
|
3
|
+
*
|
|
4
|
+
* Parse and update markdown checkboxes in Linear issue descriptions.
|
|
5
|
+
* Checkboxes follow the standard markdown format:
|
|
6
|
+
* - [ ] Unchecked item
|
|
7
|
+
* - [x] Checked item
|
|
8
|
+
*/
|
|
9
|
+
// Regex to match markdown checkbox lines
|
|
10
|
+
// Captures: (leading whitespace)(checkbox mark)(text content)
|
|
11
|
+
const CHECKBOX_REGEX = /^(\s*)- \[([ xX])\] (.*)$/;
|
|
12
|
+
/**
|
|
13
|
+
* Parse markdown and extract checkbox items
|
|
14
|
+
*
|
|
15
|
+
* @param markdown - The markdown content to parse
|
|
16
|
+
* @returns Array of checkbox items found in the markdown
|
|
17
|
+
*/
|
|
18
|
+
export function parseCheckboxes(markdown) {
|
|
19
|
+
const lines = markdown.split('\n');
|
|
20
|
+
const checkboxes = [];
|
|
21
|
+
let checkboxIndex = 0;
|
|
22
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
23
|
+
const line = lines[lineNum];
|
|
24
|
+
const match = line.match(CHECKBOX_REGEX);
|
|
25
|
+
if (match) {
|
|
26
|
+
const [, indent, mark, text] = match;
|
|
27
|
+
checkboxes.push({
|
|
28
|
+
index: checkboxIndex++,
|
|
29
|
+
line: lineNum,
|
|
30
|
+
indentLevel: indent.length,
|
|
31
|
+
checked: mark.toLowerCase() === 'x',
|
|
32
|
+
text: text.trim(),
|
|
33
|
+
raw: line,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return checkboxes;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Update a checkbox by its index
|
|
41
|
+
*
|
|
42
|
+
* @param markdown - The markdown content
|
|
43
|
+
* @param index - The checkbox index to update
|
|
44
|
+
* @param checked - The new checked state
|
|
45
|
+
* @returns The updated markdown, or the original if checkbox not found
|
|
46
|
+
*/
|
|
47
|
+
export function updateCheckbox(markdown, index, checked) {
|
|
48
|
+
const checkboxes = parseCheckboxes(markdown);
|
|
49
|
+
const checkbox = checkboxes.find((c) => c.index === index);
|
|
50
|
+
if (!checkbox) {
|
|
51
|
+
return markdown;
|
|
52
|
+
}
|
|
53
|
+
const lines = markdown.split('\n');
|
|
54
|
+
const newCheckmark = checked ? 'x' : ' ';
|
|
55
|
+
lines[checkbox.line] = checkbox.raw.replace(/\[([ xX])\]/, `[${newCheckmark}]`);
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Update a checkbox by matching its text content
|
|
60
|
+
*
|
|
61
|
+
* @param markdown - The markdown content
|
|
62
|
+
* @param textPattern - String or regex to match against checkbox text
|
|
63
|
+
* @param checked - The new checked state
|
|
64
|
+
* @returns The updated markdown, or the original if no match found
|
|
65
|
+
*/
|
|
66
|
+
export function updateCheckboxByText(markdown, textPattern, checked) {
|
|
67
|
+
const checkboxes = parseCheckboxes(markdown);
|
|
68
|
+
const pattern = typeof textPattern === 'string'
|
|
69
|
+
? new RegExp(textPattern, 'i')
|
|
70
|
+
: textPattern;
|
|
71
|
+
const checkbox = checkboxes.find((c) => pattern.test(c.text));
|
|
72
|
+
if (!checkbox) {
|
|
73
|
+
return markdown;
|
|
74
|
+
}
|
|
75
|
+
return updateCheckbox(markdown, checkbox.index, checked);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Apply multiple checkbox updates at once
|
|
79
|
+
*
|
|
80
|
+
* @param markdown - The markdown content
|
|
81
|
+
* @param updates - Array of updates to apply
|
|
82
|
+
* @returns The updated markdown
|
|
83
|
+
*/
|
|
84
|
+
export function updateCheckboxes(markdown, updates) {
|
|
85
|
+
let result = markdown;
|
|
86
|
+
for (const update of updates) {
|
|
87
|
+
if (update.index !== undefined) {
|
|
88
|
+
result = updateCheckbox(result, update.index, update.checked);
|
|
89
|
+
}
|
|
90
|
+
else if (update.textPattern) {
|
|
91
|
+
result = updateCheckboxByText(result, update.textPattern, update.checked);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if markdown contains any checkboxes
|
|
98
|
+
*
|
|
99
|
+
* @param markdown - The markdown content to check
|
|
100
|
+
* @returns True if at least one checkbox is found
|
|
101
|
+
*/
|
|
102
|
+
export function hasCheckboxes(markdown) {
|
|
103
|
+
const lines = markdown.split('\n');
|
|
104
|
+
return lines.some((line) => CHECKBOX_REGEX.test(line));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get summary of checkbox states
|
|
108
|
+
*
|
|
109
|
+
* @param markdown - The markdown content
|
|
110
|
+
* @returns Object with counts of checked and unchecked items
|
|
111
|
+
*/
|
|
112
|
+
export function getCheckboxSummary(markdown) {
|
|
113
|
+
const checkboxes = parseCheckboxes(markdown);
|
|
114
|
+
const checked = checkboxes.filter((c) => c.checked).length;
|
|
115
|
+
return {
|
|
116
|
+
total: checkboxes.length,
|
|
117
|
+
checked,
|
|
118
|
+
unchecked: checkboxes.length - checked,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker for Linear API calls
|
|
3
|
+
*
|
|
4
|
+
* Prevents wasting rate limit quota on requests that are guaranteed to fail
|
|
5
|
+
* (e.g., expired OAuth tokens, revoked access). Implements the standard
|
|
6
|
+
* closed → open → half-open state machine.
|
|
7
|
+
*
|
|
8
|
+
* State machine:
|
|
9
|
+
* closed → all calls proceed; auth failures increment counter;
|
|
10
|
+
* at threshold → open
|
|
11
|
+
* open → all calls throw CircuitOpenError immediately (zero quota);
|
|
12
|
+
* after resetTimeoutMs → half-open
|
|
13
|
+
* half-open → one probe call allowed; success → closed;
|
|
14
|
+
* failure → open (with exponential backoff on reset timeout)
|
|
15
|
+
*/
|
|
16
|
+
import { CircuitOpenError } from './errors.js';
|
|
17
|
+
import type { CircuitBreakerConfig, CircuitBreakerStrategy } from './types.js';
|
|
18
|
+
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
19
|
+
export declare const DEFAULT_CIRCUIT_BREAKER_CONFIG: Required<CircuitBreakerConfig>;
|
|
20
|
+
export declare class CircuitBreaker implements CircuitBreakerStrategy {
|
|
21
|
+
private _state;
|
|
22
|
+
private consecutiveFailures;
|
|
23
|
+
private openedAt;
|
|
24
|
+
private currentResetTimeoutMs;
|
|
25
|
+
private probeInFlight;
|
|
26
|
+
private readonly config;
|
|
27
|
+
constructor(config?: Partial<CircuitBreakerConfig>);
|
|
28
|
+
get state(): CircuitState;
|
|
29
|
+
/**
|
|
30
|
+
* Check if a call is allowed to proceed.
|
|
31
|
+
* In half-open state, only one probe call is allowed at a time.
|
|
32
|
+
*/
|
|
33
|
+
canProceed(): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Record a successful API call. Resets the circuit to closed.
|
|
36
|
+
*/
|
|
37
|
+
recordSuccess(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Record an auth failure. May trip the circuit to open.
|
|
40
|
+
* Called after isAuthError() returns true, so the error is already vetted.
|
|
41
|
+
*/
|
|
42
|
+
recordAuthFailure(_statusCode?: number): void;
|
|
43
|
+
/**
|
|
44
|
+
* Check if an error is an auth/rate-limit error that should count as a circuit failure.
|
|
45
|
+
*
|
|
46
|
+
* Detects:
|
|
47
|
+
* - HTTP status codes in authErrorCodes (400, 401, 403)
|
|
48
|
+
* - Linear GraphQL RATELIMITED error code in response body
|
|
49
|
+
* - Linear SDK error objects with nested error details
|
|
50
|
+
*/
|
|
51
|
+
isAuthError(error: unknown): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Extract the status code from an auth error, or 0 if not determinable.
|
|
54
|
+
*/
|
|
55
|
+
extractStatusCode(error: unknown): number;
|
|
56
|
+
/**
|
|
57
|
+
* Reset the circuit breaker to closed state.
|
|
58
|
+
*/
|
|
59
|
+
reset(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Get diagnostic info for logging/monitoring.
|
|
62
|
+
*/
|
|
63
|
+
getStatus(): {
|
|
64
|
+
state: CircuitState;
|
|
65
|
+
consecutiveFailures: number;
|
|
66
|
+
currentResetTimeoutMs: number;
|
|
67
|
+
msSinceOpened: number | null;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Create a CircuitOpenError with current diagnostic info.
|
|
71
|
+
*/
|
|
72
|
+
createOpenError(): CircuitOpenError;
|
|
73
|
+
private trip;
|
|
74
|
+
private shouldTransitionToHalfOpen;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=circuit-breaker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"circuit-breaker.d.ts","sourceRoot":"","sources":["../../src/circuit-breaker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAA;AAE9E,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAA;AAE1D,eAAO,MAAM,8BAA8B,EAAE,QAAQ,CAAC,oBAAoB,CAMzE,CAAA;AAED,qBAAa,cAAe,YAAW,sBAAsB;IAC3D,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,mBAAmB,CAAI;IAC/B,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,qBAAqB,CAAQ;IACrC,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;gBAE3C,MAAM,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC;IAKlD,IAAI,KAAK,IAAI,YAAY,CAOxB;IAED;;;OAGG;IACH,UAAU,IAAI,OAAO;IAgBrB;;OAEG;IACH,aAAa,IAAI,IAAI;IASrB;;;OAGG;IACH,iBAAiB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI;IAmB7C;;;;;;;OAOG;IACH,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO;IAyBpC;;OAEG;IACH,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM;IAKzC;;OAEG;IACH,KAAK,IAAI,IAAI;IAOb;;OAEG;IACH,SAAS,IAAI;QACX,KAAK,EAAE,YAAY,CAAA;QACnB,mBAAmB,EAAE,MAAM,CAAA;QAC3B,qBAAqB,EAAE,MAAM,CAAA;QAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;KAC7B;IASD;;OAEG;IACH,eAAe,IAAI,gBAAgB;IAgBnC,OAAO,CAAC,IAAI;IAKZ,OAAO,CAAC,0BAA0B;CAGnC"}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker for Linear API calls
|
|
3
|
+
*
|
|
4
|
+
* Prevents wasting rate limit quota on requests that are guaranteed to fail
|
|
5
|
+
* (e.g., expired OAuth tokens, revoked access). Implements the standard
|
|
6
|
+
* closed → open → half-open state machine.
|
|
7
|
+
*
|
|
8
|
+
* State machine:
|
|
9
|
+
* closed → all calls proceed; auth failures increment counter;
|
|
10
|
+
* at threshold → open
|
|
11
|
+
* open → all calls throw CircuitOpenError immediately (zero quota);
|
|
12
|
+
* after resetTimeoutMs → half-open
|
|
13
|
+
* half-open → one probe call allowed; success → closed;
|
|
14
|
+
* failure → open (with exponential backoff on reset timeout)
|
|
15
|
+
*/
|
|
16
|
+
import { CircuitOpenError } from './errors.js';
|
|
17
|
+
export const DEFAULT_CIRCUIT_BREAKER_CONFIG = {
|
|
18
|
+
failureThreshold: 2,
|
|
19
|
+
resetTimeoutMs: 60_000,
|
|
20
|
+
maxResetTimeoutMs: 300_000,
|
|
21
|
+
backoffMultiplier: 2,
|
|
22
|
+
authErrorCodes: [400, 401, 403],
|
|
23
|
+
};
|
|
24
|
+
export class CircuitBreaker {
|
|
25
|
+
_state = 'closed';
|
|
26
|
+
consecutiveFailures = 0;
|
|
27
|
+
openedAt = 0;
|
|
28
|
+
currentResetTimeoutMs;
|
|
29
|
+
probeInFlight = false;
|
|
30
|
+
config;
|
|
31
|
+
constructor(config) {
|
|
32
|
+
this.config = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
|
|
33
|
+
this.currentResetTimeoutMs = this.config.resetTimeoutMs;
|
|
34
|
+
}
|
|
35
|
+
get state() {
|
|
36
|
+
// Check if open circuit should transition to half-open
|
|
37
|
+
if (this._state === 'open' && this.shouldTransitionToHalfOpen()) {
|
|
38
|
+
this._state = 'half-open';
|
|
39
|
+
this.probeInFlight = false;
|
|
40
|
+
}
|
|
41
|
+
return this._state;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if a call is allowed to proceed.
|
|
45
|
+
* In half-open state, only one probe call is allowed at a time.
|
|
46
|
+
*/
|
|
47
|
+
canProceed() {
|
|
48
|
+
const currentState = this.state; // triggers open → half-open check
|
|
49
|
+
switch (currentState) {
|
|
50
|
+
case 'closed':
|
|
51
|
+
return true;
|
|
52
|
+
case 'open':
|
|
53
|
+
return false;
|
|
54
|
+
case 'half-open':
|
|
55
|
+
// Allow exactly one probe call
|
|
56
|
+
if (this.probeInFlight)
|
|
57
|
+
return false;
|
|
58
|
+
this.probeInFlight = true;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Record a successful API call. Resets the circuit to closed.
|
|
64
|
+
*/
|
|
65
|
+
recordSuccess() {
|
|
66
|
+
this.consecutiveFailures = 0;
|
|
67
|
+
this.probeInFlight = false;
|
|
68
|
+
if (this._state !== 'closed') {
|
|
69
|
+
this._state = 'closed';
|
|
70
|
+
this.currentResetTimeoutMs = this.config.resetTimeoutMs;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Record an auth failure. May trip the circuit to open.
|
|
75
|
+
* Called after isAuthError() returns true, so the error is already vetted.
|
|
76
|
+
*/
|
|
77
|
+
recordAuthFailure(_statusCode) {
|
|
78
|
+
this.probeInFlight = false;
|
|
79
|
+
this.consecutiveFailures++;
|
|
80
|
+
if (this._state === 'half-open') {
|
|
81
|
+
// Probe failed — reopen with exponential backoff
|
|
82
|
+
this.trip();
|
|
83
|
+
this.currentResetTimeoutMs = Math.min(this.currentResetTimeoutMs * this.config.backoffMultiplier, this.config.maxResetTimeoutMs);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (this.consecutiveFailures >= this.config.failureThreshold) {
|
|
87
|
+
this.trip();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check if an error is an auth/rate-limit error that should count as a circuit failure.
|
|
92
|
+
*
|
|
93
|
+
* Detects:
|
|
94
|
+
* - HTTP status codes in authErrorCodes (400, 401, 403)
|
|
95
|
+
* - Linear GraphQL RATELIMITED error code in response body
|
|
96
|
+
* - Linear SDK error objects with nested error details
|
|
97
|
+
*/
|
|
98
|
+
isAuthError(error) {
|
|
99
|
+
if (typeof error !== 'object' || error === null)
|
|
100
|
+
return false;
|
|
101
|
+
const err = error;
|
|
102
|
+
// Check HTTP status code
|
|
103
|
+
const statusCode = extractStatusCode(err);
|
|
104
|
+
if (statusCode !== null && this.config.authErrorCodes.includes(statusCode)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
// Check for Linear GraphQL RATELIMITED error
|
|
108
|
+
if (isGraphQLRateLimited(err)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// Check error message for known auth failure patterns
|
|
112
|
+
const message = err.message ?? '';
|
|
113
|
+
if (/access denied|unauthorized|forbidden/i.test(message)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Extract the status code from an auth error, or 0 if not determinable.
|
|
120
|
+
*/
|
|
121
|
+
extractStatusCode(error) {
|
|
122
|
+
if (typeof error !== 'object' || error === null)
|
|
123
|
+
return 0;
|
|
124
|
+
return extractStatusCode(error) ?? 0;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Reset the circuit breaker to closed state.
|
|
128
|
+
*/
|
|
129
|
+
reset() {
|
|
130
|
+
this._state = 'closed';
|
|
131
|
+
this.consecutiveFailures = 0;
|
|
132
|
+
this.probeInFlight = false;
|
|
133
|
+
this.currentResetTimeoutMs = this.config.resetTimeoutMs;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Get diagnostic info for logging/monitoring.
|
|
137
|
+
*/
|
|
138
|
+
getStatus() {
|
|
139
|
+
return {
|
|
140
|
+
state: this.state,
|
|
141
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
142
|
+
currentResetTimeoutMs: this.currentResetTimeoutMs,
|
|
143
|
+
msSinceOpened: this.openedAt > 0 ? Date.now() - this.openedAt : null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a CircuitOpenError with current diagnostic info.
|
|
148
|
+
*/
|
|
149
|
+
createOpenError() {
|
|
150
|
+
const timeRemaining = Math.max(0, this.currentResetTimeoutMs - (Date.now() - this.openedAt));
|
|
151
|
+
return new CircuitOpenError(`Circuit breaker is open — Linear API calls blocked for ${Math.ceil(timeRemaining / 1000)}s. ` +
|
|
152
|
+
`${this.consecutiveFailures} consecutive auth failures detected.`, timeRemaining);
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Private
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
trip() {
|
|
158
|
+
this._state = 'open';
|
|
159
|
+
this.openedAt = Date.now();
|
|
160
|
+
}
|
|
161
|
+
shouldTransitionToHalfOpen() {
|
|
162
|
+
return Date.now() - this.openedAt >= this.currentResetTimeoutMs;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Helpers
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
/**
|
|
169
|
+
* Extract HTTP status code from various error shapes
|
|
170
|
+
*/
|
|
171
|
+
function extractStatusCode(err) {
|
|
172
|
+
// Direct status/statusCode property
|
|
173
|
+
if (typeof err.status === 'number')
|
|
174
|
+
return err.status;
|
|
175
|
+
if (typeof err.statusCode === 'number')
|
|
176
|
+
return err.statusCode;
|
|
177
|
+
// Nested in response
|
|
178
|
+
const response = err.response;
|
|
179
|
+
if (response) {
|
|
180
|
+
if (typeof response.status === 'number')
|
|
181
|
+
return response.status;
|
|
182
|
+
if (typeof response.statusCode === 'number')
|
|
183
|
+
return response.statusCode;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Check if the error contains a Linear GraphQL RATELIMITED error code.
|
|
189
|
+
*
|
|
190
|
+
* Linear returns HTTP 200 with a GraphQL error body when rate-limited:
|
|
191
|
+
* { errors: [{ extensions: { code: 'RATELIMITED' } }] }
|
|
192
|
+
*/
|
|
193
|
+
function isGraphQLRateLimited(err) {
|
|
194
|
+
// Check error.extensions.code directly (Linear SDK error shape)
|
|
195
|
+
const extensions = err.extensions;
|
|
196
|
+
if (extensions?.code === 'RATELIMITED')
|
|
197
|
+
return true;
|
|
198
|
+
// Check nested errors array (raw GraphQL response shape)
|
|
199
|
+
const errors = err.errors;
|
|
200
|
+
if (Array.isArray(errors)) {
|
|
201
|
+
for (const gqlError of errors) {
|
|
202
|
+
const ext = gqlError.extensions;
|
|
203
|
+
if (ext?.code === 'RATELIMITED')
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Check response body for GraphQL errors
|
|
208
|
+
const response = err.response;
|
|
209
|
+
if (response) {
|
|
210
|
+
const body = response.body;
|
|
211
|
+
const data = response.data;
|
|
212
|
+
const target = body ?? data;
|
|
213
|
+
if (target) {
|
|
214
|
+
const bodyErrors = target.errors;
|
|
215
|
+
if (Array.isArray(bodyErrors)) {
|
|
216
|
+
for (const gqlError of bodyErrors) {
|
|
217
|
+
const ext = gqlError.extensions;
|
|
218
|
+
if (ext?.code === 'RATELIMITED')
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Check error message as last resort
|
|
225
|
+
const message = err.message ?? '';
|
|
226
|
+
if (message.includes('RATELIMITED'))
|
|
227
|
+
return true;
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"circuit-breaker.test.d.ts","sourceRoot":"","sources":["../../src/circuit-breaker.test.ts"],"names":[],"mappings":""}
|