tissues 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/bin/gitissues.js +11 -0
- package/package.json +56 -0
- package/src/cli.js +64 -0
- package/src/commands/auth.js +62 -0
- package/src/commands/create.js +360 -0
- package/src/commands/list.js +59 -0
- package/src/commands/open.js +17 -0
- package/src/commands/status.js +166 -0
- package/src/lib/attribution.js +216 -0
- package/src/lib/config.js +16 -0
- package/src/lib/db.js +436 -0
- package/src/lib/dedup.js +205 -0
- package/src/lib/defaults.js +252 -0
- package/src/lib/gh.js +273 -0
- package/src/lib/repo-picker.js +54 -0
- package/src/lib/safety.js +259 -0
- package/src/lib/templates.js +220 -0
package/src/lib/gh.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the `gh` CLI.
|
|
3
|
+
* All GitHub operations go through `gh` — auth, API calls, issue creation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync, execSync } from 'node:child_process'
|
|
7
|
+
import chalk from 'chalk'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// gh availability
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if `gh` is installed and return its path. Exits with install
|
|
15
|
+
* instructions if not found.
|
|
16
|
+
*/
|
|
17
|
+
export function requireGh() {
|
|
18
|
+
try {
|
|
19
|
+
const path = execFileSync('which', ['gh'], {
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
22
|
+
}).trim()
|
|
23
|
+
return path
|
|
24
|
+
} catch {
|
|
25
|
+
console.error(chalk.red('\n gh CLI is required but not installed.\n'))
|
|
26
|
+
console.error(' Install it with one of:')
|
|
27
|
+
console.error(chalk.cyan(' brew install gh ') + chalk.dim('# macOS'))
|
|
28
|
+
console.error(chalk.cyan(' sudo apt install gh ') + chalk.dim('# Debian/Ubuntu'))
|
|
29
|
+
console.error(chalk.cyan(' winget install GitHub.cli ') + chalk.dim('# Windows'))
|
|
30
|
+
console.error(chalk.cyan(' conda install -c conda-forge gh ') + chalk.dim('# conda'))
|
|
31
|
+
console.error()
|
|
32
|
+
console.error(chalk.dim(' More: https://cli.github.com'))
|
|
33
|
+
console.error()
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Auth
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the active auth token from `gh`. Returns null if not authenticated.
|
|
44
|
+
* @returns {string | null}
|
|
45
|
+
*/
|
|
46
|
+
export function getToken() {
|
|
47
|
+
// Env vars take priority (CI/CD)
|
|
48
|
+
const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
|
49
|
+
if (envToken) return envToken
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return execFileSync('gh', ['auth', 'token'], {
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
55
|
+
}).trim() || null
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Require a valid auth token. Exits with instructions if not authenticated.
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function requireAuth() {
|
|
66
|
+
const token = getToken()
|
|
67
|
+
if (token) return token
|
|
68
|
+
|
|
69
|
+
console.error(chalk.red('\n Not authenticated with GitHub.\n'))
|
|
70
|
+
console.error(' Run:')
|
|
71
|
+
console.error(chalk.cyan(' gh auth login'))
|
|
72
|
+
console.error()
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get auth status from `gh auth status`. Returns parsed account info.
|
|
78
|
+
* @returns {{ accounts: Array<{ login: string, active: boolean, scopes: string[], tokenType: string }> }}
|
|
79
|
+
*/
|
|
80
|
+
export function getAuthStatus() {
|
|
81
|
+
try {
|
|
82
|
+
const raw = execFileSync('gh', ['auth', 'status', '--json'], {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
85
|
+
}).trim()
|
|
86
|
+
return JSON.parse(raw)
|
|
87
|
+
} catch {
|
|
88
|
+
// Older gh versions may not support --json, fall back to text parsing
|
|
89
|
+
try {
|
|
90
|
+
const text = execFileSync('gh', ['auth', 'status'], {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
})
|
|
94
|
+
return { raw: text }
|
|
95
|
+
} catch {
|
|
96
|
+
return { accounts: [] }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the authenticated user's login.
|
|
103
|
+
* @returns {string | null}
|
|
104
|
+
*/
|
|
105
|
+
export function getAuthenticatedUser() {
|
|
106
|
+
try {
|
|
107
|
+
const raw = execFileSync('gh', ['api', 'user', '--jq', '.login'], {
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
110
|
+
}).trim()
|
|
111
|
+
return raw || null
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if the current token has the required scopes for issue creation.
|
|
119
|
+
* @returns {{ ok: boolean, scopes: string[], missing: string[] }}
|
|
120
|
+
*/
|
|
121
|
+
export function checkScopes() {
|
|
122
|
+
try {
|
|
123
|
+
// gh api returns headers including X-OAuth-Scopes
|
|
124
|
+
const raw = execFileSync('gh', ['api', '-i', 'user'], {
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const scopeLine = raw.split('\n').find((l) => l.toLowerCase().startsWith('x-oauth-scopes:'))
|
|
130
|
+
if (!scopeLine) {
|
|
131
|
+
// Fine-grained PATs don't have X-OAuth-Scopes header — they use permissions
|
|
132
|
+
// If the API call succeeded, the token is valid
|
|
133
|
+
return { ok: true, scopes: ['fine-grained'], missing: [] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const scopes = scopeLine
|
|
137
|
+
.replace(/^x-oauth-scopes:\s*/i, '')
|
|
138
|
+
.split(',')
|
|
139
|
+
.map((s) => s.trim())
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
|
|
142
|
+
const required = ['repo']
|
|
143
|
+
const missing = required.filter(
|
|
144
|
+
(r) => !scopes.includes(r) && !scopes.includes('admin:org'), // admin:org implies repo
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return { ok: missing.length === 0, scopes, missing }
|
|
148
|
+
} catch {
|
|
149
|
+
return { ok: false, scopes: [], missing: ['repo'] }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Auth commands (delegate to gh)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run `gh auth login` interactively.
|
|
159
|
+
*/
|
|
160
|
+
export function authLogin() {
|
|
161
|
+
execSync('gh auth login', { stdio: 'inherit' })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run `gh auth status` and print to stdout.
|
|
166
|
+
*/
|
|
167
|
+
export function authStatus() {
|
|
168
|
+
try {
|
|
169
|
+
execSync('gh auth status', { stdio: 'inherit' })
|
|
170
|
+
} catch {
|
|
171
|
+
// gh auth status exits non-zero when not logged in, output already printed
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run `gh auth switch` interactively.
|
|
177
|
+
*/
|
|
178
|
+
export function authSwitch() {
|
|
179
|
+
execSync('gh auth switch', { stdio: 'inherit' })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run `gh auth logout` interactively.
|
|
184
|
+
*/
|
|
185
|
+
export function authLogout() {
|
|
186
|
+
execSync('gh auth logout', { stdio: 'inherit' })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// GitHub API
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create an issue via `gh issue create`.
|
|
195
|
+
* @param {string} repo - owner/name
|
|
196
|
+
* @param {{ title: string, body: string, labels?: string[] }} opts
|
|
197
|
+
* @returns {{ number: number, url: string }}
|
|
198
|
+
*/
|
|
199
|
+
export function createIssue(repo, { title, body, labels }) {
|
|
200
|
+
const args = [
|
|
201
|
+
'issue', 'create',
|
|
202
|
+
'--repo', repo,
|
|
203
|
+
'--title', title,
|
|
204
|
+
'--body', body,
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
if (labels?.length) {
|
|
208
|
+
args.push('--label', labels.join(','))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const output = execFileSync('gh', args, {
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
214
|
+
}).trim()
|
|
215
|
+
|
|
216
|
+
// gh issue create prints the URL on success
|
|
217
|
+
const url = output.trim()
|
|
218
|
+
const numberMatch = url.match(/\/issues\/(\d+)$/)
|
|
219
|
+
const number = numberMatch ? parseInt(numberMatch[1], 10) : 0
|
|
220
|
+
|
|
221
|
+
return { number, url }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* List open issues for a repo.
|
|
226
|
+
* @param {string} repo - owner/name
|
|
227
|
+
* @param {{ limit?: number }} [opts]
|
|
228
|
+
* @returns {Array<{ number: number, title: string, url: string, labels: string[], createdAt: string }>}
|
|
229
|
+
*/
|
|
230
|
+
export function listIssues(repo, opts = {}) {
|
|
231
|
+
const limit = opts.limit ?? 100
|
|
232
|
+
const raw = execFileSync('gh', [
|
|
233
|
+
'issue', 'list',
|
|
234
|
+
'--repo', repo,
|
|
235
|
+
'--state', 'open',
|
|
236
|
+
'--limit', String(limit),
|
|
237
|
+
'--json', 'number,title,url,labels,createdAt',
|
|
238
|
+
], {
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
241
|
+
}).trim()
|
|
242
|
+
|
|
243
|
+
if (!raw) return []
|
|
244
|
+
const issues = JSON.parse(raw)
|
|
245
|
+
return issues.map((i) => ({
|
|
246
|
+
number: i.number,
|
|
247
|
+
title: i.title,
|
|
248
|
+
url: i.url,
|
|
249
|
+
labels: (i.labels || []).map((l) => l.name),
|
|
250
|
+
createdAt: i.createdAt,
|
|
251
|
+
}))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* List repos the user has access to.
|
|
256
|
+
* @param {{ limit?: number }} [opts]
|
|
257
|
+
* @returns {string[]} - array of "owner/name"
|
|
258
|
+
*/
|
|
259
|
+
export function listRepos(opts = {}) {
|
|
260
|
+
const limit = opts.limit ?? 100
|
|
261
|
+
const raw = execFileSync('gh', [
|
|
262
|
+
'repo', 'list',
|
|
263
|
+
'--limit', String(limit),
|
|
264
|
+
'--json', 'nameWithOwner',
|
|
265
|
+
'--jq', '.[].nameWithOwner',
|
|
266
|
+
], {
|
|
267
|
+
encoding: 'utf8',
|
|
268
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
269
|
+
}).trim()
|
|
270
|
+
|
|
271
|
+
if (!raw) return []
|
|
272
|
+
return raw.split('\n').filter(Boolean)
|
|
273
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { search } from '@inquirer/prompts'
|
|
2
|
+
import { store, setConfig } from './config.js'
|
|
3
|
+
import { listRepos } from './gh.js'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
|
|
6
|
+
const USAGE_KEY = 'repoUsage'
|
|
7
|
+
|
|
8
|
+
function getRepoUsage() {
|
|
9
|
+
return store.get(USAGE_KEY) || {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function trackRepoUsage(repo) {
|
|
13
|
+
const usage = getRepoUsage()
|
|
14
|
+
usage[repo] = { count: (usage[repo]?.count || 0) + 1, lastUsed: Date.now() }
|
|
15
|
+
store.set(USAGE_KEY, usage)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sortByUsage(repos, usage) {
|
|
19
|
+
return [...repos].sort((a, b) => {
|
|
20
|
+
const ua = usage[a] || { count: 0, lastUsed: 0 }
|
|
21
|
+
const ub = usage[b] || { count: 0, lastUsed: 0 }
|
|
22
|
+
if (ub.count !== ua.count) return ub.count - ua.count
|
|
23
|
+
return ub.lastUsed - ua.lastUsed
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function pickRepo() {
|
|
28
|
+
const spinner = ora('Fetching repos...').start()
|
|
29
|
+
let repos
|
|
30
|
+
try {
|
|
31
|
+
repos = listRepos()
|
|
32
|
+
spinner.stop()
|
|
33
|
+
} catch (err) {
|
|
34
|
+
spinner.fail('Failed to fetch repos')
|
|
35
|
+
throw err
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const usage = getRepoUsage()
|
|
39
|
+
const sorted = sortByUsage(repos, usage)
|
|
40
|
+
|
|
41
|
+
const repo = await search({
|
|
42
|
+
message: 'Select a repository',
|
|
43
|
+
source: (input) => {
|
|
44
|
+
const term = (input || '').toLowerCase()
|
|
45
|
+
return sorted
|
|
46
|
+
.filter((r) => r.toLowerCase().includes(term))
|
|
47
|
+
.map((r) => ({ name: r, value: r }))
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
trackRepoUsage(repo)
|
|
52
|
+
setConfig({ activeRepo: repo })
|
|
53
|
+
return repo
|
|
54
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker and rate limiter for ghissue CLI.
|
|
3
|
+
*
|
|
4
|
+
* Prevents runaway issue creation loops by:
|
|
5
|
+
* - Rate limiting per agent (hourly + burst window)
|
|
6
|
+
* - Rate limiting globally across all agents
|
|
7
|
+
* - Circuit breaker that trips after repeated failures and enters cooldown
|
|
8
|
+
*
|
|
9
|
+
* All functions are synchronous (better-sqlite3 is sync).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getCircuitState,
|
|
14
|
+
tripCircuit,
|
|
15
|
+
resetCircuit,
|
|
16
|
+
probeCircuit,
|
|
17
|
+
recordRateEvent,
|
|
18
|
+
countRecentEvents,
|
|
19
|
+
getDb,
|
|
20
|
+
} from './db.js'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SAFETY_CONFIG = {
|
|
23
|
+
// Rate limits
|
|
24
|
+
maxPerHour: 10, // max issues per hour per agent
|
|
25
|
+
burstLimit: 5, // max issues in burst window
|
|
26
|
+
burstWindowMinutes: 5, // burst window size
|
|
27
|
+
|
|
28
|
+
// Circuit breaker
|
|
29
|
+
tripThreshold: 3, // trip after N blocked attempts (dedup hits count as failures)
|
|
30
|
+
cooldownMinutes: 30, // time before half-open probe
|
|
31
|
+
|
|
32
|
+
// Global limits
|
|
33
|
+
globalMaxPerHour: 30, // across all agents for this repo
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Format a future Date as a human-readable remaining duration.
|
|
38
|
+
* e.g. "23 minutes remaining" or "1 minute remaining"
|
|
39
|
+
*
|
|
40
|
+
* @param {Date|string|number} until - The end time
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function formatCooldownRemaining(until) {
|
|
44
|
+
const end = new Date(until)
|
|
45
|
+
const now = new Date()
|
|
46
|
+
const diffMs = end - now
|
|
47
|
+
if (diffMs <= 0) return '0 minutes remaining'
|
|
48
|
+
const minutes = Math.ceil(diffMs / 60_000)
|
|
49
|
+
return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} remaining`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Count recent issue creation events across ALL agents for a repo.
|
|
54
|
+
* Uses getDb() directly because countRecentEvents only filters by agent.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} repo - e.g. "owner/repo"
|
|
57
|
+
* @param {number} windowMinutes
|
|
58
|
+
* @returns {number}
|
|
59
|
+
*/
|
|
60
|
+
function countAllRecentEvents(repo, windowMinutes) {
|
|
61
|
+
const db = getDb()
|
|
62
|
+
const row = db
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT COUNT(*) AS cnt
|
|
65
|
+
FROM rate_events
|
|
66
|
+
WHERE repo = ? AND event_type = 'create' AND created_at > datetime('now', ?)`
|
|
67
|
+
)
|
|
68
|
+
.get(repo, `-${windowMinutes} minutes`)
|
|
69
|
+
return row ? row.cnt : 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if issue creation is allowed for the given repo/agent.
|
|
74
|
+
*
|
|
75
|
+
* Steps:
|
|
76
|
+
* 1. Probe the circuit (transitions open → half-open if cooldown expired).
|
|
77
|
+
* 2. If circuit is open, block immediately.
|
|
78
|
+
* 3. If circuit is half-open, allow exactly one probe through.
|
|
79
|
+
* 4. Check per-agent hourly rate limit.
|
|
80
|
+
* 5. Check per-agent burst rate limit.
|
|
81
|
+
* 6. Check global (all-agent) hourly rate limit.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} repo - e.g. "owner/repo"
|
|
84
|
+
* @param {string} agent - identifier for the calling agent/user
|
|
85
|
+
* @param {object} [config] - overrides for DEFAULT_SAFETY_CONFIG
|
|
86
|
+
* @returns {{ allowed: boolean, reason?: string, circuitState: string, rateInfo: object }}
|
|
87
|
+
*/
|
|
88
|
+
export function checkSafety(repo, agent, config = {}) {
|
|
89
|
+
const cfg = { ...DEFAULT_SAFETY_CONFIG, ...config }
|
|
90
|
+
|
|
91
|
+
// Step 1: probe — may transition open → half-open if cooldown expired
|
|
92
|
+
probeCircuit(repo, agent)
|
|
93
|
+
|
|
94
|
+
// Step 2: read current circuit state
|
|
95
|
+
const circuit = getCircuitState(repo, agent)
|
|
96
|
+
const circuitState = circuit.status ?? 'closed'
|
|
97
|
+
|
|
98
|
+
if (circuitState === 'open') {
|
|
99
|
+
const remaining = circuit.cooldownUntil
|
|
100
|
+
? formatCooldownRemaining(circuit.cooldownUntil)
|
|
101
|
+
: 'unknown'
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
reason: `Circuit breaker is open. Cooldown until: ${circuit.cooldownUntil} (${remaining})`,
|
|
105
|
+
circuitState,
|
|
106
|
+
rateInfo: {},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Step 3: half-open → log and allow the probe through (no rate checks)
|
|
111
|
+
if (circuitState === 'half-open') {
|
|
112
|
+
console.warn(`[safety] Circuit is half-open for ${repo}/${agent} — allowing probe request through.`)
|
|
113
|
+
return {
|
|
114
|
+
allowed: true,
|
|
115
|
+
circuitState,
|
|
116
|
+
rateInfo: { probe: true },
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Step 4: per-agent hourly limit
|
|
121
|
+
const agentHourCount = countRecentEvents(repo, agent, 60)
|
|
122
|
+
if (agentHourCount >= cfg.maxPerHour) {
|
|
123
|
+
return {
|
|
124
|
+
allowed: false,
|
|
125
|
+
reason: `Hourly rate limit reached: ${agentHourCount}/${cfg.maxPerHour} issues created in the last 60 minutes.`,
|
|
126
|
+
circuitState,
|
|
127
|
+
rateInfo: { agentHourCount, maxPerHour: cfg.maxPerHour },
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Step 5: per-agent burst limit
|
|
132
|
+
const agentBurstCount = countRecentEvents(repo, agent, cfg.burstWindowMinutes)
|
|
133
|
+
if (agentBurstCount >= cfg.burstLimit) {
|
|
134
|
+
return {
|
|
135
|
+
allowed: false,
|
|
136
|
+
reason: `Burst rate limit reached: ${agentBurstCount}/${cfg.burstLimit} issues created in the last ${cfg.burstWindowMinutes} minutes.`,
|
|
137
|
+
circuitState,
|
|
138
|
+
rateInfo: { agentBurstCount, burstLimit: cfg.burstLimit, burstWindowMinutes: cfg.burstWindowMinutes },
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Step 6: global (all-agent) hourly limit
|
|
143
|
+
const globalHourCount = countAllRecentEvents(repo, 60)
|
|
144
|
+
if (globalHourCount >= cfg.globalMaxPerHour) {
|
|
145
|
+
return {
|
|
146
|
+
allowed: false,
|
|
147
|
+
reason: `Global hourly rate limit reached: ${globalHourCount}/${cfg.globalMaxPerHour} issues created across all agents in the last 60 minutes.`,
|
|
148
|
+
circuitState,
|
|
149
|
+
rateInfo: { globalHourCount, globalMaxPerHour: cfg.globalMaxPerHour },
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
allowed: true,
|
|
155
|
+
circuitState,
|
|
156
|
+
rateInfo: {
|
|
157
|
+
agentHourCount,
|
|
158
|
+
agentBurstCount,
|
|
159
|
+
globalHourCount,
|
|
160
|
+
maxPerHour: cfg.maxPerHour,
|
|
161
|
+
burstLimit: cfg.burstLimit,
|
|
162
|
+
globalMaxPerHour: cfg.globalMaxPerHour,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record a successful issue creation.
|
|
169
|
+
*
|
|
170
|
+
* - Appends a 'create' rate event so future rate checks account for this issue.
|
|
171
|
+
* - If the circuit was half-open (probe succeeded), resets it to closed.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} repo
|
|
174
|
+
* @param {string} agent
|
|
175
|
+
*/
|
|
176
|
+
export function recordSuccess(repo, agent) {
|
|
177
|
+
recordRateEvent(repo, agent, 'create')
|
|
178
|
+
|
|
179
|
+
const circuit = getCircuitState(repo, agent)
|
|
180
|
+
if ((circuit.status ?? 'closed') === 'half-open') {
|
|
181
|
+
resetCircuit(repo, agent)
|
|
182
|
+
console.info(`[safety] Half-open probe succeeded — circuit reset to closed for ${repo}/${agent}.`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Record a safety failure (e.g., dedup block, validation error).
|
|
188
|
+
*
|
|
189
|
+
* Increments the failure count toward the trip threshold. Once
|
|
190
|
+
* tripThreshold is reached the circuit opens and enters cooldown.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} repo
|
|
193
|
+
* @param {string} agent
|
|
194
|
+
* @param {object} [config] - overrides for DEFAULT_SAFETY_CONFIG
|
|
195
|
+
*/
|
|
196
|
+
export function recordFailure(repo, agent, config = {}) {
|
|
197
|
+
const cfg = { ...DEFAULT_SAFETY_CONFIG, ...config }
|
|
198
|
+
|
|
199
|
+
const circuit = getCircuitState(repo, agent)
|
|
200
|
+
const newFailureCount = (circuit.failureCount ?? 0) + 1
|
|
201
|
+
|
|
202
|
+
if (newFailureCount >= cfg.tripThreshold) {
|
|
203
|
+
tripCircuit(repo, agent, cfg.cooldownMinutes)
|
|
204
|
+
console.warn(
|
|
205
|
+
`[safety] Circuit tripped for ${repo}/${agent} after ${newFailureCount} failures. ` +
|
|
206
|
+
`Cooldown: ${cfg.cooldownMinutes} minutes.`
|
|
207
|
+
)
|
|
208
|
+
} else {
|
|
209
|
+
// Update failure count without tripping — db.tripCircuit handles the trip;
|
|
210
|
+
// for intermediate increments we record a 'failure' rate event so the count
|
|
211
|
+
// is persisted across calls.
|
|
212
|
+
recordRateEvent(repo, agent, 'failure')
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get a human-readable safety status summary for a repo/agent pair.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} repo
|
|
220
|
+
* @param {string} agent
|
|
221
|
+
* @param {object} [config] - overrides for DEFAULT_SAFETY_CONFIG
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function getSafetyStatus(repo, agent, config = {}) {
|
|
225
|
+
const cfg = { ...DEFAULT_SAFETY_CONFIG, ...config }
|
|
226
|
+
|
|
227
|
+
probeCircuit(repo, agent)
|
|
228
|
+
const circuit = getCircuitState(repo, agent)
|
|
229
|
+
const circuitState = circuit.status ?? 'closed'
|
|
230
|
+
|
|
231
|
+
const agentHourCount = countRecentEvents(repo, agent, 60)
|
|
232
|
+
const agentBurstCount = countRecentEvents(repo, agent, cfg.burstWindowMinutes)
|
|
233
|
+
const globalHourCount = countAllRecentEvents(repo, 60)
|
|
234
|
+
|
|
235
|
+
const lines = [
|
|
236
|
+
`Safety status for ${repo} / agent: ${agent}`,
|
|
237
|
+
` Circuit breaker : ${circuitState.toUpperCase()}` +
|
|
238
|
+
(circuitState === 'open' && circuit.cooldownUntil
|
|
239
|
+
? ` (${formatCooldownRemaining(circuit.cooldownUntil)})`
|
|
240
|
+
: ''),
|
|
241
|
+
` Failures : ${circuit.failureCount ?? 0} / ${cfg.tripThreshold} (trip threshold)`,
|
|
242
|
+
` Hourly (agent) : ${agentHourCount} / ${cfg.maxPerHour}`,
|
|
243
|
+
` Burst (agent) : ${agentBurstCount} / ${cfg.burstLimit} (last ${cfg.burstWindowMinutes} min)`,
|
|
244
|
+
` Hourly (global) : ${globalHourCount} / ${cfg.globalMaxPerHour}`,
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
return lines.join('\n')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Force-reset the circuit breaker to closed state (admin override).
|
|
252
|
+
*
|
|
253
|
+
* @param {string} repo
|
|
254
|
+
* @param {string} agent
|
|
255
|
+
*/
|
|
256
|
+
export function forceReset(repo, agent) {
|
|
257
|
+
resetCircuit(repo, agent)
|
|
258
|
+
console.info(`[safety] Circuit breaker force-reset to closed for ${repo}/${agent}.`)
|
|
259
|
+
}
|