tissues 0.6.1 → 0.6.2
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/README.md +78 -1
- package/package.json +4 -2
- package/src/cli.js +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +60 -0
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for safety.js
|
|
3
|
+
*
|
|
4
|
+
* safety.js depends heavily on db.js (SQLite). These tests focus on the
|
|
5
|
+
* pure/isolated behaviors: formatCooldownRemaining logic is internal, so we
|
|
6
|
+
* test the exported interface surface that does NOT require a live DB.
|
|
7
|
+
*
|
|
8
|
+
* For the DB-dependent functions (checkSafety, recordSuccess, recordFailure,
|
|
9
|
+
* getSafetyStatus, forceReset) we use a real SQLite DB in a temp directory so
|
|
10
|
+
* the tests remain self-contained and clean up after themselves.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe, before, after } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Set up an isolated temp directory that looks like a git repo so db.js
|
|
21
|
+
// writes tissues.db there instead of into the project root.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let tmpDir
|
|
25
|
+
|
|
26
|
+
before(() => {
|
|
27
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-safety-test-'))
|
|
28
|
+
// Create .git so findRepoRootForDb resolves here
|
|
29
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
30
|
+
// Point the process cwd so db.js resolves correctly when the module is loaded
|
|
31
|
+
process.chdir(tmpDir)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
after(() => {
|
|
35
|
+
// Restore cwd to something safe
|
|
36
|
+
try { process.chdir(os.tmpdir()) } catch { /* ignore */ }
|
|
37
|
+
// Clean up temp dir
|
|
38
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Import AFTER before() sets up tmpDir so db.js picks up the right cwd.
|
|
42
|
+
// Node ESM caches modules so we import lazily in each test instead.
|
|
43
|
+
// We use dynamic import inside the tests to defer resolution.
|
|
44
|
+
// However, since ESM imports are hoisted, we need a workaround:
|
|
45
|
+
// we define a helper that loads the module on demand via dynamic import.
|
|
46
|
+
async function getSafety() {
|
|
47
|
+
return import('./safety.js')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getDb() {
|
|
51
|
+
return import('./db.js')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Tests
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('checkSafety – allowed when no events recorded', () => {
|
|
59
|
+
test('returns allowed=true and circuitState=closed for fresh repo/agent', async () => {
|
|
60
|
+
const { checkSafety } = await getSafety()
|
|
61
|
+
const result = checkSafety('test/repo-fresh', 'agent-alpha')
|
|
62
|
+
assert.equal(result.allowed, true)
|
|
63
|
+
assert.equal(result.circuitState, 'closed')
|
|
64
|
+
assert.ok(result.rateInfo)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('rateInfo contains rate counters', async () => {
|
|
68
|
+
const { checkSafety } = await getSafety()
|
|
69
|
+
const result = checkSafety('test/repo-fresh2', 'agent-beta')
|
|
70
|
+
assert.ok('agentHourCount' in result.rateInfo)
|
|
71
|
+
assert.ok('agentBurstCount' in result.rateInfo)
|
|
72
|
+
assert.ok('globalHourCount' in result.rateInfo)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('checkSafety – rate limit enforcement', () => {
|
|
77
|
+
test('blocks when per-agent hourly limit is reached', async () => {
|
|
78
|
+
const { checkSafety } = await getSafety()
|
|
79
|
+
const { recordRateEvent } = await getDb()
|
|
80
|
+
const repo = 'test/repo-hourly'
|
|
81
|
+
const agent = 'agent-hourly'
|
|
82
|
+
|
|
83
|
+
// Simulate maxPerHour (default 10) create events
|
|
84
|
+
for (let i = 0; i < 10; i++) {
|
|
85
|
+
recordRateEvent(repo, agent, 'create')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = checkSafety(repo, agent)
|
|
89
|
+
assert.equal(result.allowed, false)
|
|
90
|
+
assert.ok(result.reason.includes('Hourly rate limit'))
|
|
91
|
+
assert.equal(result.circuitState, 'closed')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('blocks when burst limit is reached', async () => {
|
|
95
|
+
const { checkSafety } = await getSafety()
|
|
96
|
+
const { recordRateEvent } = await getDb()
|
|
97
|
+
const repo = 'test/repo-burst'
|
|
98
|
+
const agent = 'agent-burst'
|
|
99
|
+
|
|
100
|
+
// Simulate burstLimit (default 5) create events in burst window
|
|
101
|
+
for (let i = 0; i < 5; i++) {
|
|
102
|
+
recordRateEvent(repo, agent, 'create')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = checkSafety(repo, agent)
|
|
106
|
+
assert.equal(result.allowed, false)
|
|
107
|
+
assert.ok(result.reason.includes('Burst rate limit') || result.reason.includes('Hourly rate limit'))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('custom config overrides default limits', async () => {
|
|
111
|
+
const { checkSafety } = await getSafety()
|
|
112
|
+
const { recordRateEvent } = await getDb()
|
|
113
|
+
const repo = 'test/repo-custom'
|
|
114
|
+
const agent = 'agent-custom'
|
|
115
|
+
|
|
116
|
+
// Record 2 events — should be blocked with maxPerHour=2
|
|
117
|
+
recordRateEvent(repo, agent, 'create')
|
|
118
|
+
recordRateEvent(repo, agent, 'create')
|
|
119
|
+
|
|
120
|
+
const result = checkSafety(repo, agent, { maxPerHour: 2, burstLimit: 10 })
|
|
121
|
+
assert.equal(result.allowed, false)
|
|
122
|
+
assert.ok(result.reason.includes('Hourly rate limit'))
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('checkSafety – circuit breaker', () => {
|
|
127
|
+
test('circuit is open after being tripped', async () => {
|
|
128
|
+
const { checkSafety } = await getSafety()
|
|
129
|
+
const { tripCircuit } = await getDb()
|
|
130
|
+
const repo = 'test/repo-circuit'
|
|
131
|
+
const agent = 'agent-circuit'
|
|
132
|
+
|
|
133
|
+
tripCircuit(repo, agent, 30)
|
|
134
|
+
|
|
135
|
+
const result = checkSafety(repo, agent)
|
|
136
|
+
assert.equal(result.allowed, false)
|
|
137
|
+
assert.equal(result.circuitState, 'open')
|
|
138
|
+
assert.ok(result.reason.includes('Circuit breaker is open'))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('circuit resets to closed after forceReset', async () => {
|
|
142
|
+
const { checkSafety, forceReset } = await getSafety()
|
|
143
|
+
const { tripCircuit } = await getDb()
|
|
144
|
+
const repo = 'test/repo-reset'
|
|
145
|
+
const agent = 'agent-reset'
|
|
146
|
+
|
|
147
|
+
tripCircuit(repo, agent, 30)
|
|
148
|
+
forceReset(repo, agent)
|
|
149
|
+
|
|
150
|
+
const result = checkSafety(repo, agent)
|
|
151
|
+
assert.equal(result.circuitState, 'closed')
|
|
152
|
+
assert.equal(result.allowed, true)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('recordSuccess', () => {
|
|
157
|
+
test('recording success does not throw', async () => {
|
|
158
|
+
const { recordSuccess } = await getSafety()
|
|
159
|
+
assert.doesNotThrow(() => recordSuccess('test/repo-ok', 'agent-ok'))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('recording success increments rate event count', async () => {
|
|
163
|
+
const { recordSuccess } = await getSafety()
|
|
164
|
+
const { countRecentEvents } = await getDb()
|
|
165
|
+
const repo = 'test/repo-success-count'
|
|
166
|
+
const agent = 'agent-sc'
|
|
167
|
+
|
|
168
|
+
const before = countRecentEvents(repo, agent, 60)
|
|
169
|
+
recordSuccess(repo, agent)
|
|
170
|
+
const after = countRecentEvents(repo, agent, 60)
|
|
171
|
+
assert.equal(after, before + 1)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('recordFailure', () => {
|
|
176
|
+
test('recording failures does not throw', async () => {
|
|
177
|
+
const { recordFailure } = await getSafety()
|
|
178
|
+
assert.doesNotThrow(() => recordFailure('test/repo-fail', 'agent-fail'))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('circuit trips after tripThreshold failures', async () => {
|
|
182
|
+
const { recordFailure, checkSafety } = await getSafety()
|
|
183
|
+
const repo = 'test/repo-trip'
|
|
184
|
+
const agent = 'agent-trip'
|
|
185
|
+
const config = { tripThreshold: 3, cooldownMinutes: 30 }
|
|
186
|
+
|
|
187
|
+
recordFailure(repo, agent, config)
|
|
188
|
+
recordFailure(repo, agent, config)
|
|
189
|
+
recordFailure(repo, agent, config) // should trip
|
|
190
|
+
|
|
191
|
+
const result = checkSafety(repo, agent)
|
|
192
|
+
assert.equal(result.circuitState, 'open')
|
|
193
|
+
assert.equal(result.allowed, false)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('getSafetyStatus', () => {
|
|
198
|
+
test('returns a formatted string with status info', async () => {
|
|
199
|
+
const { getSafetyStatus } = await getSafety()
|
|
200
|
+
const status = getSafetyStatus('test/repo-status', 'agent-status')
|
|
201
|
+
assert.ok(typeof status === 'string')
|
|
202
|
+
assert.ok(status.includes('Circuit breaker'))
|
|
203
|
+
assert.ok(status.includes('Hourly'))
|
|
204
|
+
assert.ok(status.includes('Burst'))
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('shows OPEN in status when circuit is tripped', async () => {
|
|
208
|
+
const { getSafetyStatus } = await getSafety()
|
|
209
|
+
const { tripCircuit } = await getDb()
|
|
210
|
+
const repo = 'test/repo-status-open'
|
|
211
|
+
const agent = 'agent-status-open'
|
|
212
|
+
|
|
213
|
+
tripCircuit(repo, agent, 30)
|
|
214
|
+
const status = getSafetyStatus(repo, agent)
|
|
215
|
+
assert.ok(status.includes('OPEN'))
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-gathering functions for the storage overview.
|
|
3
|
+
*
|
|
4
|
+
* Provides row counts, file counts, sizes, and freshness info
|
|
5
|
+
* for all persistent data tissues stores.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { getDb, getDbPath } from './db.js'
|
|
11
|
+
import { getSyncMeta } from './cache.js'
|
|
12
|
+
import { findRepoRoot } from './defaults.js'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a table exists in the database.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} tableName
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function tableExists(tableName) {
|
|
25
|
+
try {
|
|
26
|
+
const db = getDb()
|
|
27
|
+
const row = db.prepare(
|
|
28
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
29
|
+
).get(tableName)
|
|
30
|
+
return !!row
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Count rows in a table, optionally filtered.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} table
|
|
40
|
+
* @param {string} [where]
|
|
41
|
+
* @param {any[]} [params]
|
|
42
|
+
* @returns {number}
|
|
43
|
+
*/
|
|
44
|
+
function countRows(table, where, params = []) {
|
|
45
|
+
if (!tableExists(table)) return 0
|
|
46
|
+
try {
|
|
47
|
+
const db = getDb()
|
|
48
|
+
const sql = where
|
|
49
|
+
? `SELECT COUNT(*) as cnt FROM ${table} WHERE ${where}`
|
|
50
|
+
: `SELECT COUNT(*) as cnt FROM ${table}`
|
|
51
|
+
return db.prepare(sql).get(...params)?.cnt || 0
|
|
52
|
+
} catch {
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the size of a file in bytes, or 0 if it doesn't exist.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} filePath
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
function fileSize(filePath) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.statSync(filePath).size
|
|
66
|
+
} catch {
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Count files in a directory (non-recursive).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} dir
|
|
75
|
+
* @param {string} [ext] - filter by extension
|
|
76
|
+
* @returns {{ count: number, totalSize: number }}
|
|
77
|
+
*/
|
|
78
|
+
function countFiles(dir, ext) {
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.existsSync(dir)) return { count: 0, totalSize: 0 }
|
|
81
|
+
const entries = fs.readdirSync(dir)
|
|
82
|
+
const files = ext ? entries.filter(f => f.endsWith(ext)) : entries
|
|
83
|
+
let totalSize = 0
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
totalSize += fileSize(path.join(dir, f))
|
|
86
|
+
}
|
|
87
|
+
return { count: files.length, totalSize }
|
|
88
|
+
} catch {
|
|
89
|
+
return { count: 0, totalSize: 0 }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format a relative time string (e.g. "2 min ago", "3 hours ago").
|
|
95
|
+
*
|
|
96
|
+
* @param {string|null} isoDate
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function timeAgo(isoDate) {
|
|
100
|
+
if (!isoDate) return 'never'
|
|
101
|
+
const ms = Date.now() - new Date(isoDate + (isoDate.endsWith('Z') ? '' : 'Z')).getTime()
|
|
102
|
+
if (ms < 0) return 'just now'
|
|
103
|
+
const sec = Math.floor(ms / 1000)
|
|
104
|
+
if (sec < 60) return `${sec}s ago`
|
|
105
|
+
const min = Math.floor(sec / 60)
|
|
106
|
+
if (min < 60) return `${min} min ago`
|
|
107
|
+
const hr = Math.floor(min / 60)
|
|
108
|
+
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`
|
|
109
|
+
const days = Math.floor(hr / 24)
|
|
110
|
+
return `${days} day${days === 1 ? '' : 's'} ago`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Format bytes as a human-readable string.
|
|
115
|
+
*
|
|
116
|
+
* @param {number} bytes
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function formatBytes(bytes) {
|
|
120
|
+
if (bytes === 0) return '0 B'
|
|
121
|
+
if (bytes < 1024) return `${bytes} B`
|
|
122
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
|
123
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Overview data gathering
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Gather a full storage overview.
|
|
132
|
+
*
|
|
133
|
+
* @param {{ repo?: string }} [opts]
|
|
134
|
+
* @returns {object}
|
|
135
|
+
*/
|
|
136
|
+
export function getStorageOverview(opts = {}) {
|
|
137
|
+
const dbPath = getDbPath()
|
|
138
|
+
const dbSize = fileSize(dbPath)
|
|
139
|
+
const repoRoot = findRepoRoot()
|
|
140
|
+
|
|
141
|
+
// Table row counts
|
|
142
|
+
const fingerprints = countRows('fingerprints')
|
|
143
|
+
const idempotencyKeys = countRows('idempotency_keys')
|
|
144
|
+
const circuitBreakers = countRows('circuit_breaker')
|
|
145
|
+
const rateEvents = countRows('rate_events')
|
|
146
|
+
const cachedIssues = countRows('cached_issues')
|
|
147
|
+
const cachedLabels = countRows('cached_labels')
|
|
148
|
+
const cachedRepos = countRows('cached_repos')
|
|
149
|
+
const syncMeta = countRows('sync_meta')
|
|
150
|
+
|
|
151
|
+
// Sync freshness
|
|
152
|
+
const repo = opts.repo || null
|
|
153
|
+
const issuesMeta = repo ? getSyncMeta(repo, 'issues') : null
|
|
154
|
+
const labelsMeta = repo ? getSyncMeta(repo, 'labels') : null
|
|
155
|
+
const reposMeta = getSyncMeta('_global', 'repos')
|
|
156
|
+
|
|
157
|
+
// File counts
|
|
158
|
+
const tissuesDir = repoRoot ? path.join(repoRoot, '.tissues') : null
|
|
159
|
+
const draftsDir = tissuesDir ? path.join(tissuesDir, 'drafts') : null
|
|
160
|
+
const imagesDir = tissuesDir ? path.join(tissuesDir, 'images') : null
|
|
161
|
+
const templatesDir = tissuesDir ? path.join(tissuesDir, 'templates') : null
|
|
162
|
+
const enhancementsDir = tissuesDir ? path.join(tissuesDir, 'enhancements') : null
|
|
163
|
+
|
|
164
|
+
const drafts = draftsDir ? countFiles(draftsDir, '.json') : { count: 0, totalSize: 0 }
|
|
165
|
+
const images = imagesDir ? countFiles(imagesDir) : { count: 0, totalSize: 0 }
|
|
166
|
+
const templates = templatesDir ? countFiles(templatesDir, '.md') : { count: 0, totalSize: 0 }
|
|
167
|
+
const enhancements = enhancementsDir ? countFiles(enhancementsDir, '.md') : { count: 0, totalSize: 0 }
|
|
168
|
+
|
|
169
|
+
const totalDiskUsage = dbSize + drafts.totalSize + images.totalSize + templates.totalSize + enhancements.totalSize
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
database: {
|
|
173
|
+
path: dbPath,
|
|
174
|
+
size: dbSize,
|
|
175
|
+
tables: {
|
|
176
|
+
fingerprints: { rows: fingerprints, lastSync: null },
|
|
177
|
+
idempotency_keys: { rows: idempotencyKeys },
|
|
178
|
+
circuit_breaker: { rows: circuitBreakers },
|
|
179
|
+
rate_events: { rows: rateEvents },
|
|
180
|
+
cached_issues: { rows: cachedIssues, lastSync: issuesMeta?.lastSyncedAt || null },
|
|
181
|
+
cached_labels: { rows: cachedLabels, lastSync: labelsMeta?.lastSyncedAt || null },
|
|
182
|
+
cached_repos: { rows: cachedRepos, lastSync: reposMeta?.lastSyncedAt || null },
|
|
183
|
+
sync_meta: { rows: syncMeta },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
files: {
|
|
187
|
+
drafts,
|
|
188
|
+
images,
|
|
189
|
+
templates,
|
|
190
|
+
enhancements,
|
|
191
|
+
},
|
|
192
|
+
totalDiskUsage,
|
|
193
|
+
repoRoot,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get per-repo issue cache breakdown.
|
|
199
|
+
*
|
|
200
|
+
* @returns {Array<{ repo: string, open: number, closed: number, lastSync: string|null }>}
|
|
201
|
+
*/
|
|
202
|
+
export function getIssueBreakdown() {
|
|
203
|
+
if (!tableExists('cached_issues')) return []
|
|
204
|
+
try {
|
|
205
|
+
const db = getDb()
|
|
206
|
+
const repos = db.prepare(
|
|
207
|
+
"SELECT DISTINCT repo FROM cached_issues ORDER BY repo",
|
|
208
|
+
).all()
|
|
209
|
+
|
|
210
|
+
return repos.map(r => {
|
|
211
|
+
const open = db.prepare(
|
|
212
|
+
"SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ? AND state = 'open'",
|
|
213
|
+
).get(r.repo)?.cnt || 0
|
|
214
|
+
const closed = db.prepare(
|
|
215
|
+
"SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ? AND state = 'closed'",
|
|
216
|
+
).get(r.repo)?.cnt || 0
|
|
217
|
+
const meta = getSyncMeta(r.repo, 'issues')
|
|
218
|
+
return {
|
|
219
|
+
repo: r.repo,
|
|
220
|
+
open,
|
|
221
|
+
closed,
|
|
222
|
+
lastSync: meta?.lastSyncedAt || null,
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
} catch {
|
|
226
|
+
return []
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Clear cached data by target.
|
|
232
|
+
*
|
|
233
|
+
* @param {'issues' | 'labels' | 'repos' | 'fingerprints' | 'all'} target
|
|
234
|
+
* @param {{ repo?: string, includeSafety?: boolean }} [opts]
|
|
235
|
+
* @returns {{ cleared: string[] }}
|
|
236
|
+
*/
|
|
237
|
+
export function clearCachedData(target, opts = {}) {
|
|
238
|
+
const db = getDb()
|
|
239
|
+
const cleared = []
|
|
240
|
+
const repo = opts.repo
|
|
241
|
+
|
|
242
|
+
const clearTable = (table, repoCol) => {
|
|
243
|
+
if (!tableExists(table)) return
|
|
244
|
+
if (repo && repoCol) {
|
|
245
|
+
db.prepare(`DELETE FROM ${table} WHERE ${repoCol} = ?`).run(repo)
|
|
246
|
+
} else {
|
|
247
|
+
db.prepare(`DELETE FROM ${table}`).run()
|
|
248
|
+
}
|
|
249
|
+
cleared.push(table)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (target === 'issues' || target === 'all') {
|
|
253
|
+
clearTable('cached_issues', 'repo')
|
|
254
|
+
if (repo) {
|
|
255
|
+
db.prepare("DELETE FROM sync_meta WHERE repo = ? AND resource = 'issues'").run(repo)
|
|
256
|
+
} else {
|
|
257
|
+
db.prepare("DELETE FROM sync_meta WHERE resource = 'issues'").run()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (target === 'labels' || target === 'all') {
|
|
262
|
+
clearTable('cached_labels', 'repo')
|
|
263
|
+
if (repo) {
|
|
264
|
+
db.prepare("DELETE FROM sync_meta WHERE repo = ? AND resource = 'labels'").run(repo)
|
|
265
|
+
} else {
|
|
266
|
+
db.prepare("DELETE FROM sync_meta WHERE resource = 'labels'").run()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (target === 'repos' || target === 'all') {
|
|
271
|
+
clearTable('cached_repos', null)
|
|
272
|
+
db.prepare("DELETE FROM sync_meta WHERE resource = 'repos'").run()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (target === 'fingerprints' || target === 'all') {
|
|
276
|
+
clearTable('fingerprints', 'repo')
|
|
277
|
+
clearTable('idempotency_keys', 'repo')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (target === 'all' && opts.includeSafety) {
|
|
281
|
+
clearTable('circuit_breaker', 'repo')
|
|
282
|
+
clearTable('rate_events', 'repo')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { cleared }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Export all storage metadata as JSON (for debugging).
|
|
290
|
+
*
|
|
291
|
+
* @param {{ repo?: string }} [opts]
|
|
292
|
+
* @returns {object}
|
|
293
|
+
*/
|
|
294
|
+
export function exportStorageJson(opts = {}) {
|
|
295
|
+
const overview = getStorageOverview(opts)
|
|
296
|
+
const issueBreakdown = getIssueBreakdown()
|
|
297
|
+
return { ...overview, issueBreakdown }
|
|
298
|
+
}
|