@in-the-loop-labs/pair-review 3.2.2 → 3.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/README.md +7 -6
- package/package.json +5 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
- package/public/css/repo-settings.css +347 -0
- package/public/index.html +46 -9
- package/public/js/components/AIPanel.js +79 -37
- package/public/js/components/DiffOptionsDropdown.js +84 -1
- package/public/js/index.js +31 -6
- package/public/js/modules/analysis-history.js +11 -7
- package/public/js/pr.js +22 -0
- package/public/js/repo-settings.js +334 -6
- package/public/repo-settings.html +29 -0
- package/src/ai/analyzer.js +28 -19
- package/src/ai/claude-cli.js +2 -0
- package/src/ai/claude-provider.js +4 -1
- package/src/ai/prompts/baseline/consolidation/balanced.js +6 -4
- package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
- package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
- package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
- package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
- package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
- package/src/ai/provider.js +7 -6
- package/src/chat/session-manager.js +6 -3
- package/src/config.js +230 -38
- package/src/database.js +766 -38
- package/src/git/worktree-pool-lifecycle.js +674 -0
- package/src/git/worktree-pool-usage.js +216 -0
- package/src/git/worktree.js +46 -13
- package/src/main.js +185 -26
- package/src/routes/analyses.js +48 -26
- package/src/routes/chat.js +27 -3
- package/src/routes/config.js +17 -5
- package/src/routes/executable-analysis.js +38 -19
- package/src/routes/local.js +19 -6
- package/src/routes/mcp.js +13 -2
- package/src/routes/pr.js +72 -29
- package/src/routes/setup.js +41 -4
- package/src/routes/stack-analysis.js +29 -10
- package/src/routes/worktrees.js +294 -9
- package/src/server.js +20 -3
- package/src/setup/pr-setup.js +161 -27
- package/src/ws/server.js +51 -1
package/src/database.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const Database = require('better-sqlite3');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs').promises;
|
|
5
|
+
const fsSync = require('fs');
|
|
5
6
|
const { getConfigDir } = require('./config');
|
|
6
7
|
|
|
7
8
|
let dbPath = null;
|
|
@@ -20,7 +21,7 @@ function getDbPath() {
|
|
|
20
21
|
/**
|
|
21
22
|
* Current schema version - increment this when adding new migrations
|
|
22
23
|
*/
|
|
23
|
-
const CURRENT_SCHEMA_VERSION =
|
|
24
|
+
const CURRENT_SCHEMA_VERSION = 43;
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Database schema SQL statements
|
|
@@ -142,7 +143,7 @@ const SCHEMA_SQL = {
|
|
|
142
143
|
repo_settings: `
|
|
143
144
|
CREATE TABLE IF NOT EXISTS repo_settings (
|
|
144
145
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
-
repository TEXT NOT NULL UNIQUE,
|
|
146
|
+
repository TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
146
147
|
default_instructions TEXT,
|
|
147
148
|
default_provider TEXT,
|
|
148
149
|
default_model TEXT,
|
|
@@ -151,6 +152,11 @@ const SCHEMA_SQL = {
|
|
|
151
152
|
default_chat_instructions TEXT,
|
|
152
153
|
local_path TEXT,
|
|
153
154
|
auto_branch_review INTEGER DEFAULT 0,
|
|
155
|
+
pool_size INTEGER,
|
|
156
|
+
pool_fetch_interval_minutes INTEGER,
|
|
157
|
+
pool_fetch_started_at TEXT,
|
|
158
|
+
pool_fetch_finished_at TEXT,
|
|
159
|
+
load_skills INTEGER,
|
|
154
160
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
155
161
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
156
162
|
)
|
|
@@ -277,6 +283,21 @@ const SCHEMA_SQL = {
|
|
|
277
283
|
collection TEXT NOT NULL,
|
|
278
284
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
279
285
|
)
|
|
286
|
+
`,
|
|
287
|
+
|
|
288
|
+
worktree_pool: `
|
|
289
|
+
CREATE TABLE IF NOT EXISTS worktree_pool (
|
|
290
|
+
id TEXT PRIMARY KEY,
|
|
291
|
+
repository TEXT NOT NULL,
|
|
292
|
+
path TEXT NOT NULL UNIQUE,
|
|
293
|
+
status TEXT NOT NULL DEFAULT 'available'
|
|
294
|
+
CHECK(status IN ('available', 'in_use', 'switching', 'creating')),
|
|
295
|
+
current_pr_number INTEGER,
|
|
296
|
+
current_review_id INTEGER,
|
|
297
|
+
last_switched_at TEXT,
|
|
298
|
+
last_fetched_at TEXT,
|
|
299
|
+
created_at TEXT NOT NULL
|
|
300
|
+
)
|
|
280
301
|
`
|
|
281
302
|
};
|
|
282
303
|
|
|
@@ -293,7 +314,7 @@ const INDEX_SQL = [
|
|
|
293
314
|
'CREATE INDEX IF NOT EXISTS idx_pr_metadata_last_accessed ON pr_metadata(last_accessed_at)',
|
|
294
315
|
'CREATE INDEX IF NOT EXISTS idx_worktrees_last_accessed ON worktrees(last_accessed_at)',
|
|
295
316
|
'CREATE INDEX IF NOT EXISTS idx_worktrees_repo ON worktrees(repository)',
|
|
296
|
-
'CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository)',
|
|
317
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository COLLATE NOCASE)',
|
|
297
318
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_local ON reviews(local_path, local_head_sha, local_head_branch) WHERE review_type = \'local\'',
|
|
298
319
|
// Partial unique index for PR reviews only (NULL pr_number values for local reviews should not conflict)
|
|
299
320
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_pr_unique ON reviews(pr_number, repository) WHERE review_type = \'pr\'',
|
|
@@ -315,7 +336,11 @@ const INDEX_SQL = [
|
|
|
315
336
|
// Context files indexes
|
|
316
337
|
'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
|
|
317
338
|
// GitHub PR cache indexes
|
|
318
|
-
'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)'
|
|
339
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)',
|
|
340
|
+
// Worktree pool indexes
|
|
341
|
+
'CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)',
|
|
342
|
+
'CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)',
|
|
343
|
+
'CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)'
|
|
319
344
|
];
|
|
320
345
|
|
|
321
346
|
/**
|
|
@@ -1635,6 +1660,212 @@ const MIGRATIONS = {
|
|
|
1635
1660
|
}
|
|
1636
1661
|
|
|
1637
1662
|
console.log('Migration to schema version 36 complete');
|
|
1663
|
+
},
|
|
1664
|
+
|
|
1665
|
+
37: (db) => {
|
|
1666
|
+
console.log('Running migration to schema version 37...');
|
|
1667
|
+
|
|
1668
|
+
if (!tableExists(db, 'worktree_pool')) {
|
|
1669
|
+
db.exec(SCHEMA_SQL.worktree_pool);
|
|
1670
|
+
console.log(' Created worktree_pool table');
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)');
|
|
1674
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)');
|
|
1675
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)');
|
|
1676
|
+
|
|
1677
|
+
console.log('Migration to schema version 37 complete');
|
|
1678
|
+
},
|
|
1679
|
+
|
|
1680
|
+
// Migration to version 38: Add current_review_id to worktree_pool for persistent ownership
|
|
1681
|
+
38: (db) => {
|
|
1682
|
+
console.log('Running migration to schema version 38...');
|
|
1683
|
+
|
|
1684
|
+
if (tableExists(db, 'worktree_pool') && !columnExists(db, 'worktree_pool', 'current_review_id')) {
|
|
1685
|
+
db.prepare('ALTER TABLE worktree_pool ADD COLUMN current_review_id INTEGER').run();
|
|
1686
|
+
console.log(' Added column current_review_id to worktree_pool');
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
console.log('Migration to schema version 38 complete');
|
|
1690
|
+
},
|
|
1691
|
+
|
|
1692
|
+
39: (db) => {
|
|
1693
|
+
console.log('Running migration to schema version 39: Add creating status to worktree_pool...');
|
|
1694
|
+
|
|
1695
|
+
if (!tableExists(db, 'worktree_pool')) {
|
|
1696
|
+
console.log(' worktree_pool table does not exist, skipping');
|
|
1697
|
+
console.log('Migration to schema version 39 complete');
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// SQLite does not support ALTER TABLE to modify CHECK constraints.
|
|
1702
|
+
// Rebuild the table with the updated constraint.
|
|
1703
|
+
db.pragma('foreign_keys = OFF');
|
|
1704
|
+
|
|
1705
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS worktree_pool_rebuild (
|
|
1706
|
+
id TEXT PRIMARY KEY,
|
|
1707
|
+
repository TEXT NOT NULL,
|
|
1708
|
+
path TEXT NOT NULL UNIQUE,
|
|
1709
|
+
status TEXT NOT NULL DEFAULT 'available'
|
|
1710
|
+
CHECK(status IN ('available', 'in_use', 'switching', 'creating')),
|
|
1711
|
+
current_pr_number INTEGER,
|
|
1712
|
+
current_review_id INTEGER,
|
|
1713
|
+
last_switched_at TEXT,
|
|
1714
|
+
last_fetched_at TEXT,
|
|
1715
|
+
created_at TEXT NOT NULL
|
|
1716
|
+
)`).run();
|
|
1717
|
+
|
|
1718
|
+
const cols = [
|
|
1719
|
+
'id', 'repository', 'path', 'status',
|
|
1720
|
+
'current_pr_number', 'current_review_id',
|
|
1721
|
+
'last_switched_at', 'last_fetched_at', 'created_at'
|
|
1722
|
+
].join(', ');
|
|
1723
|
+
|
|
1724
|
+
const rebuild = db.transaction(() => {
|
|
1725
|
+
db.prepare(`INSERT INTO worktree_pool_rebuild (${cols}) SELECT ${cols} FROM worktree_pool`).run();
|
|
1726
|
+
db.prepare('DROP TABLE worktree_pool').run();
|
|
1727
|
+
db.prepare('ALTER TABLE worktree_pool_rebuild RENAME TO worktree_pool').run();
|
|
1728
|
+
});
|
|
1729
|
+
rebuild();
|
|
1730
|
+
|
|
1731
|
+
db.pragma('foreign_keys = ON');
|
|
1732
|
+
|
|
1733
|
+
// Recreate indexes on the rebuilt table (must match INDEX_SQL definitions)
|
|
1734
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)').run();
|
|
1735
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)').run();
|
|
1736
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)').run();
|
|
1737
|
+
|
|
1738
|
+
console.log(' Rebuilt worktree_pool with creating status in CHECK constraint');
|
|
1739
|
+
console.log('Migration to schema version 39 complete');
|
|
1740
|
+
},
|
|
1741
|
+
40: (db) => {
|
|
1742
|
+
console.log('Running migration to schema version 40: Add pool settings to repo_settings...');
|
|
1743
|
+
const addColumnIfNotExists = (table, column, definition) => {
|
|
1744
|
+
const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
1745
|
+
const columnExists = tableInfo.some(col => col.name === column);
|
|
1746
|
+
if (!columnExists) {
|
|
1747
|
+
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
|
|
1748
|
+
console.log(` Added column ${column} to ${table}`);
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
addColumnIfNotExists('repo_settings', 'pool_size', 'INTEGER');
|
|
1752
|
+
addColumnIfNotExists('repo_settings', 'pool_fetch_interval_minutes', 'INTEGER');
|
|
1753
|
+
console.log('Migration to schema version 40 complete');
|
|
1754
|
+
},
|
|
1755
|
+
|
|
1756
|
+
41: (db) => {
|
|
1757
|
+
console.log('Running migration to schema version 41: Add pool fetch coordination columns to repo_settings...');
|
|
1758
|
+
const addColumnIfNotExists = (table, column, definition) => {
|
|
1759
|
+
const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
1760
|
+
const columnExists = tableInfo.some(col => col.name === column);
|
|
1761
|
+
if (!columnExists) {
|
|
1762
|
+
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
|
|
1763
|
+
console.log(` Added column ${column} to ${table}`);
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
addColumnIfNotExists('repo_settings', 'pool_fetch_started_at', 'TEXT');
|
|
1767
|
+
addColumnIfNotExists('repo_settings', 'pool_fetch_finished_at', 'TEXT');
|
|
1768
|
+
console.log('Migration to schema version 41 complete');
|
|
1769
|
+
},
|
|
1770
|
+
|
|
1771
|
+
42: (db) => {
|
|
1772
|
+
console.log('Running migration to schema version 42: Add COLLATE NOCASE to repo_settings.repository...');
|
|
1773
|
+
|
|
1774
|
+
if (!tableExists(db, 'repo_settings')) {
|
|
1775
|
+
console.log(' repo_settings table does not exist, skipping');
|
|
1776
|
+
console.log('Migration to schema version 42 complete');
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// SQLite does not support ALTER TABLE to modify column collation.
|
|
1781
|
+
// Rebuild the table with the updated column definition.
|
|
1782
|
+
db.pragma('foreign_keys = OFF');
|
|
1783
|
+
try {
|
|
1784
|
+
db.prepare('DROP TABLE IF EXISTS repo_settings_rebuild').run();
|
|
1785
|
+
|
|
1786
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS repo_settings_rebuild (
|
|
1787
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1788
|
+
repository TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
1789
|
+
default_instructions TEXT,
|
|
1790
|
+
default_provider TEXT,
|
|
1791
|
+
default_model TEXT,
|
|
1792
|
+
default_council_id TEXT,
|
|
1793
|
+
default_tab TEXT,
|
|
1794
|
+
default_chat_instructions TEXT,
|
|
1795
|
+
local_path TEXT,
|
|
1796
|
+
auto_branch_review INTEGER DEFAULT 0,
|
|
1797
|
+
pool_size INTEGER,
|
|
1798
|
+
pool_fetch_interval_minutes INTEGER,
|
|
1799
|
+
pool_fetch_started_at TEXT,
|
|
1800
|
+
pool_fetch_finished_at TEXT,
|
|
1801
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
1802
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1803
|
+
)`).run();
|
|
1804
|
+
|
|
1805
|
+
// Auto-merge case-duplicate repositories, keeping the most recently updated row.
|
|
1806
|
+
const dupes = db.prepare(`SELECT LOWER(repository) as repo, COUNT(*) as cnt FROM repo_settings GROUP BY LOWER(repository) HAVING cnt > 1`).all();
|
|
1807
|
+
if (dupes.length > 0) {
|
|
1808
|
+
const removedRows = [];
|
|
1809
|
+
for (const { repo } of dupes) {
|
|
1810
|
+
const rows = db.prepare(`SELECT * FROM repo_settings WHERE LOWER(repository) = ? ORDER BY julianday(updated_at) DESC, id DESC`).all(repo);
|
|
1811
|
+
const kept = rows[0];
|
|
1812
|
+
const discarded = rows.slice(1);
|
|
1813
|
+
for (const row of discarded) {
|
|
1814
|
+
removedRows.push(row);
|
|
1815
|
+
db.prepare('DELETE FROM repo_settings WHERE id = ?').run(row.id);
|
|
1816
|
+
}
|
|
1817
|
+
console.warn(` [migration 42] Case-duplicate repo "${repo}": kept id=${kept.id} (updated ${kept.updated_at}), removed ${discarded.length} older row(s)`);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Write backup of removed rows to ~/.pair-review/ so no data is truly lost
|
|
1821
|
+
try {
|
|
1822
|
+
const backupPath = path.join(getConfigDir(), `migration-42-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
|
1823
|
+
fsSync.writeFileSync(backupPath, JSON.stringify(removedRows, null, 2));
|
|
1824
|
+
console.warn(` [migration 42] Backup of ${removedRows.length} removed row(s) written to ${backupPath}`);
|
|
1825
|
+
} catch (backupErr) {
|
|
1826
|
+
console.warn(` [migration 42] Warning: could not write backup file: ${backupErr.message}`);
|
|
1827
|
+
console.warn(` [migration 42] Removed row data: ${JSON.stringify(removedRows)}`);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const cols = [
|
|
1832
|
+
'id', 'repository', 'default_instructions', 'default_provider',
|
|
1833
|
+
'default_model', 'default_council_id', 'default_tab',
|
|
1834
|
+
'default_chat_instructions', 'local_path', 'auto_branch_review',
|
|
1835
|
+
'pool_size', 'pool_fetch_interval_minutes',
|
|
1836
|
+
'pool_fetch_started_at', 'pool_fetch_finished_at',
|
|
1837
|
+
'created_at', 'updated_at'
|
|
1838
|
+
].join(', ');
|
|
1839
|
+
|
|
1840
|
+
const rebuild = db.transaction(() => {
|
|
1841
|
+
db.prepare(`INSERT INTO repo_settings_rebuild (${cols}) SELECT ${cols} FROM repo_settings`).run();
|
|
1842
|
+
db.prepare('DROP TABLE repo_settings').run();
|
|
1843
|
+
db.prepare('ALTER TABLE repo_settings_rebuild RENAME TO repo_settings').run();
|
|
1844
|
+
});
|
|
1845
|
+
rebuild();
|
|
1846
|
+
|
|
1847
|
+
// Recreate the index with COLLATE NOCASE (must match INDEX_SQL definition)
|
|
1848
|
+
db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository COLLATE NOCASE)').run();
|
|
1849
|
+
|
|
1850
|
+
console.log(' Rebuilt repo_settings with COLLATE NOCASE on repository column');
|
|
1851
|
+
console.log('Migration to schema version 42 complete');
|
|
1852
|
+
} finally {
|
|
1853
|
+
db.pragma('foreign_keys = ON');
|
|
1854
|
+
}
|
|
1855
|
+
},
|
|
1856
|
+
|
|
1857
|
+
43: (db) => {
|
|
1858
|
+
console.log('Running migration to schema version 43: Add load_skills to repo_settings...');
|
|
1859
|
+
const addColumnIfNotExists = (table, column, definition) => {
|
|
1860
|
+
const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
1861
|
+
const columnExists = tableInfo.some(col => col.name === column);
|
|
1862
|
+
if (!columnExists) {
|
|
1863
|
+
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
|
|
1864
|
+
console.log(` Added column ${column} to ${table}`);
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
addColumnIfNotExists('repo_settings', 'load_skills', 'INTEGER');
|
|
1868
|
+
console.log('Migration to schema version 43 complete');
|
|
1638
1869
|
}
|
|
1639
1870
|
};
|
|
1640
1871
|
|
|
@@ -1903,6 +2134,7 @@ function generateWorktreeId(length = 3) {
|
|
|
1903
2134
|
return `pair-review--${randomPart}`;
|
|
1904
2135
|
}
|
|
1905
2136
|
|
|
2137
|
+
|
|
1906
2138
|
/**
|
|
1907
2139
|
* WorktreeRepository class for managing worktree database records
|
|
1908
2140
|
*/
|
|
@@ -1917,29 +2149,34 @@ class WorktreeRepository {
|
|
|
1917
2149
|
|
|
1918
2150
|
/**
|
|
1919
2151
|
* Create a new worktree record
|
|
1920
|
-
* @param {Object} prInfo - PR information { prNumber, repository, branch, path }
|
|
2152
|
+
* @param {Object} prInfo - PR information { prNumber, repository, branch, path, explicitId }
|
|
1921
2153
|
* @returns {Promise<Object>} Created worktree record
|
|
1922
2154
|
*/
|
|
1923
2155
|
async create(prInfo) {
|
|
1924
|
-
const { prNumber, repository, branch, path: worktreePath } = prInfo;
|
|
2156
|
+
const { prNumber, repository, branch, path: worktreePath, explicitId } = prInfo;
|
|
1925
2157
|
|
|
1926
|
-
// Generate unique ID (retry if collision)
|
|
1927
2158
|
let id;
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
)
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
2159
|
+
if (explicitId) {
|
|
2160
|
+
// Use the caller-supplied ID (e.g. pool worktree ID)
|
|
2161
|
+
id = explicitId;
|
|
2162
|
+
} else {
|
|
2163
|
+
// Generate unique ID (retry if collision)
|
|
2164
|
+
let attempts = 0;
|
|
2165
|
+
const maxAttempts = 10;
|
|
2166
|
+
|
|
2167
|
+
while (attempts < maxAttempts) {
|
|
2168
|
+
id = generateWorktreeId();
|
|
2169
|
+
const existing = await queryOne(this.db,
|
|
2170
|
+
'SELECT id FROM worktrees WHERE id = ?',
|
|
2171
|
+
[id]
|
|
2172
|
+
);
|
|
2173
|
+
if (!existing) break;
|
|
2174
|
+
attempts++;
|
|
2175
|
+
}
|
|
1940
2176
|
|
|
1941
|
-
|
|
1942
|
-
|
|
2177
|
+
if (attempts >= maxAttempts) {
|
|
2178
|
+
throw new Error('Failed to generate unique worktree ID after maximum attempts');
|
|
2179
|
+
}
|
|
1943
2180
|
}
|
|
1944
2181
|
|
|
1945
2182
|
const now = new Date().toISOString();
|
|
@@ -2016,10 +2253,11 @@ class WorktreeRepository {
|
|
|
2016
2253
|
const dateStr = olderThan instanceof Date ? olderThan.toISOString() : olderThan;
|
|
2017
2254
|
|
|
2018
2255
|
const rows = await query(this.db, `
|
|
2019
|
-
SELECT id, pr_number, repository, branch, path, created_at, last_accessed_at
|
|
2020
|
-
FROM worktrees
|
|
2021
|
-
|
|
2022
|
-
|
|
2256
|
+
SELECT w.id, w.pr_number, w.repository, w.branch, w.path, w.created_at, w.last_accessed_at
|
|
2257
|
+
FROM worktrees w
|
|
2258
|
+
LEFT JOIN worktree_pool wp ON w.id = wp.id
|
|
2259
|
+
WHERE w.last_accessed_at < ? AND wp.id IS NULL
|
|
2260
|
+
ORDER BY w.last_accessed_at ASC
|
|
2023
2261
|
`, [dateStr]);
|
|
2024
2262
|
|
|
2025
2263
|
return rows;
|
|
@@ -2054,6 +2292,20 @@ class WorktreeRepository {
|
|
|
2054
2292
|
return rows;
|
|
2055
2293
|
}
|
|
2056
2294
|
|
|
2295
|
+
/**
|
|
2296
|
+
* Find all worktrees for a given repository.
|
|
2297
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
2298
|
+
* @returns {Promise<Array<Object>>} Array of worktree records ordered by last_accessed_at DESC
|
|
2299
|
+
*/
|
|
2300
|
+
async findAllByRepository(repository) {
|
|
2301
|
+
return await query(this.db, `
|
|
2302
|
+
SELECT id, pr_number, repository, branch, path, created_at, last_accessed_at
|
|
2303
|
+
FROM worktrees
|
|
2304
|
+
WHERE repository = ? COLLATE NOCASE
|
|
2305
|
+
ORDER BY last_accessed_at DESC
|
|
2306
|
+
`, [repository]);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2057
2309
|
/**
|
|
2058
2310
|
* Update the path of an existing worktree record
|
|
2059
2311
|
* @param {string} id - Worktree ID
|
|
@@ -2075,16 +2327,47 @@ class WorktreeRepository {
|
|
|
2075
2327
|
* Get or create a worktree record (upsert-like behavior)
|
|
2076
2328
|
* If a worktree exists for the PR, update its last_accessed_at and return it
|
|
2077
2329
|
* Otherwise, create a new record
|
|
2078
|
-
* @param {Object} prInfo - PR information { prNumber, repository, branch, path }
|
|
2330
|
+
* @param {Object} prInfo - PR information { prNumber, repository, branch, path, explicitId }
|
|
2079
2331
|
* @returns {Promise<Object>} Worktree record (existing or newly created)
|
|
2080
2332
|
*/
|
|
2081
2333
|
async getOrCreate(prInfo) {
|
|
2082
|
-
const { prNumber, repository } = prInfo;
|
|
2334
|
+
const { prNumber, repository, explicitId } = prInfo;
|
|
2083
2335
|
|
|
2084
2336
|
// Check if worktree already exists
|
|
2085
2337
|
const existing = await this.findByPR(prNumber, repository);
|
|
2086
2338
|
|
|
2087
2339
|
if (existing) {
|
|
2340
|
+
// If explicitId is provided and differs from the existing record's ID,
|
|
2341
|
+
// migrate the record to use the new ID. This happens when pool mode is
|
|
2342
|
+
// enabled for a repo that already has legacy (non-pool) worktree records:
|
|
2343
|
+
// the pool slot has its own ID that the worktrees row must match.
|
|
2344
|
+
if (explicitId && existing.id !== explicitId) {
|
|
2345
|
+
const now = new Date().toISOString();
|
|
2346
|
+
await run(this.db, 'BEGIN IMMEDIATE');
|
|
2347
|
+
try {
|
|
2348
|
+
// Delete the old record and create a new one with the pool ID.
|
|
2349
|
+
// We can't UPDATE the primary key directly in SQLite.
|
|
2350
|
+
await run(this.db, `DELETE FROM worktrees WHERE id = ?`, [existing.id]);
|
|
2351
|
+
await run(this.db, `
|
|
2352
|
+
INSERT INTO worktrees (id, pr_number, repository, branch, path, created_at, last_accessed_at)
|
|
2353
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2354
|
+
`, [explicitId, prNumber, repository, prInfo.branch, prInfo.path, existing.created_at, now]);
|
|
2355
|
+
await run(this.db, 'COMMIT');
|
|
2356
|
+
} catch (err) {
|
|
2357
|
+
await run(this.db, 'ROLLBACK');
|
|
2358
|
+
throw err;
|
|
2359
|
+
}
|
|
2360
|
+
return {
|
|
2361
|
+
id: explicitId,
|
|
2362
|
+
pr_number: prNumber,
|
|
2363
|
+
repository,
|
|
2364
|
+
branch: prInfo.branch,
|
|
2365
|
+
path: prInfo.path,
|
|
2366
|
+
created_at: existing.created_at,
|
|
2367
|
+
last_accessed_at: now
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2088
2371
|
// Update last_accessed_at and potentially the path
|
|
2089
2372
|
const now = new Date().toISOString();
|
|
2090
2373
|
await run(this.db, `
|
|
@@ -2105,6 +2388,63 @@ class WorktreeRepository {
|
|
|
2105
2388
|
return this.create(prInfo);
|
|
2106
2389
|
}
|
|
2107
2390
|
|
|
2391
|
+
/**
|
|
2392
|
+
* Switch a worktree's PR assignment (for pool worktree switching).
|
|
2393
|
+
* Unlike getOrCreate, this updates an existing record by ID rather than by PR number.
|
|
2394
|
+
* Removes any conflicting non-pool worktree record for the target PR to avoid
|
|
2395
|
+
* UNIQUE(pr_number, repository) violations when transitioning from non-pool to pool mode.
|
|
2396
|
+
* @param {string} id - Worktree ID
|
|
2397
|
+
* @param {number} prNumber - New PR number
|
|
2398
|
+
* @param {string} branch - New branch name
|
|
2399
|
+
* @returns {Promise<string[]>} Paths of deleted non-pool worktree records (for filesystem cleanup)
|
|
2400
|
+
*/
|
|
2401
|
+
async switchPR(id, prNumber, branch) {
|
|
2402
|
+
const now = new Date().toISOString();
|
|
2403
|
+
let deletedPaths = [];
|
|
2404
|
+
// Look up this worktree's repository so we can check for conflicts
|
|
2405
|
+
const self = await queryOne(this.db, `SELECT repository FROM worktrees WHERE id = ?`, [id]);
|
|
2406
|
+
if (self && self.repository) {
|
|
2407
|
+
// Wrap SELECT + DELETE + UPDATE in a transaction to avoid partial state
|
|
2408
|
+
await run(this.db, 'BEGIN IMMEDIATE');
|
|
2409
|
+
try {
|
|
2410
|
+
// Collect paths of conflicting non-pool worktree records before deleting
|
|
2411
|
+
// (the caller needs these to clean up the actual git worktree directories on disk)
|
|
2412
|
+
const conflicting = this.db.prepare(`
|
|
2413
|
+
SELECT path FROM worktrees
|
|
2414
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE AND id != ?
|
|
2415
|
+
AND id NOT IN (SELECT id FROM worktree_pool)
|
|
2416
|
+
`).all(prNumber, self.repository, id);
|
|
2417
|
+
deletedPaths = conflicting.map(row => row.path).filter(Boolean);
|
|
2418
|
+
|
|
2419
|
+
// Remove any conflicting non-pool worktree record for the target PR
|
|
2420
|
+
// (can exist when transitioning a repo from non-pool to pool mode)
|
|
2421
|
+
await run(this.db, `
|
|
2422
|
+
DELETE FROM worktrees
|
|
2423
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE AND id != ?
|
|
2424
|
+
AND id NOT IN (SELECT id FROM worktree_pool)
|
|
2425
|
+
`, [prNumber, self.repository, id]);
|
|
2426
|
+
await run(this.db, `UPDATE worktrees SET pr_number = ?, branch = ?, last_accessed_at = ? WHERE id = ?`, [prNumber, branch, now, id]);
|
|
2427
|
+
await run(this.db, 'COMMIT');
|
|
2428
|
+
} catch (err) {
|
|
2429
|
+
await run(this.db, 'ROLLBACK');
|
|
2430
|
+
throw err;
|
|
2431
|
+
}
|
|
2432
|
+
} else {
|
|
2433
|
+
await run(this.db, `UPDATE worktrees SET pr_number = ?, branch = ?, last_accessed_at = ? WHERE id = ?`, [prNumber, branch, now, id]);
|
|
2434
|
+
}
|
|
2435
|
+
return deletedPaths;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Find a worktree record by its filesystem path.
|
|
2441
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
2442
|
+
* @returns {Promise<Object|null>} Worktree record or null
|
|
2443
|
+
*/
|
|
2444
|
+
async findByPath(worktreePath) {
|
|
2445
|
+
return queryOne(this.db, `SELECT * FROM worktrees WHERE path = ?`, [worktreePath]);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2108
2448
|
/**
|
|
2109
2449
|
* Count total worktrees in the database
|
|
2110
2450
|
* @returns {Promise<number>} Total count
|
|
@@ -2115,6 +2455,333 @@ class WorktreeRepository {
|
|
|
2115
2455
|
}
|
|
2116
2456
|
}
|
|
2117
2457
|
|
|
2458
|
+
/**
|
|
2459
|
+
* WorktreePoolRepository class for managing pool worktree database records
|
|
2460
|
+
*/
|
|
2461
|
+
class WorktreePoolRepository {
|
|
2462
|
+
constructor(db) {
|
|
2463
|
+
this.db = db;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/**
|
|
2467
|
+
* Create a pool entry for a worktree.
|
|
2468
|
+
* @param {object} params
|
|
2469
|
+
* @param {string} params.id - Pool worktree ID (e.g., 'pool-abc')
|
|
2470
|
+
* @param {string} params.repository - owner/repo
|
|
2471
|
+
* @param {string} params.path - Absolute filesystem path
|
|
2472
|
+
* @param {number} [params.prNumber] - If provided, insert as 'in_use' for this PR (avoids race with claimAvailable)
|
|
2473
|
+
*/
|
|
2474
|
+
async create({ id, repository, path, prNumber }) {
|
|
2475
|
+
const now = new Date().toISOString();
|
|
2476
|
+
if (prNumber != null) {
|
|
2477
|
+
await run(this.db, `INSERT INTO worktree_pool (id, repository, path, status, current_pr_number, last_switched_at, created_at) VALUES (?, ?, ?, 'in_use', ?, ?, ?)`, [id, repository, path, prNumber, now, now]);
|
|
2478
|
+
} else {
|
|
2479
|
+
await run(this.db, `INSERT INTO worktree_pool (id, repository, path, status, created_at) VALUES (?, ?, ?, 'available', ?)`, [id, repository, path, now]);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/**
|
|
2484
|
+
* Find an available (evictable) pool worktree for a repository,
|
|
2485
|
+
* ordered by LRU (oldest last_switched_at first, NULLs first).
|
|
2486
|
+
*/
|
|
2487
|
+
async findAvailable(repository) {
|
|
2488
|
+
return await queryOne(this.db, `
|
|
2489
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2490
|
+
FROM worktree_pool
|
|
2491
|
+
WHERE repository = ? COLLATE NOCASE AND status = 'available'
|
|
2492
|
+
ORDER BY last_switched_at ASC NULLS FIRST
|
|
2493
|
+
LIMIT 1
|
|
2494
|
+
`, [repository]);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* Find a pool worktree currently assigned to a PR.
|
|
2499
|
+
*/
|
|
2500
|
+
async findByPR(prNumber, repository) {
|
|
2501
|
+
return await queryOne(this.db, `
|
|
2502
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2503
|
+
FROM worktree_pool
|
|
2504
|
+
WHERE current_pr_number = ? AND repository = ? COLLATE NOCASE
|
|
2505
|
+
`, [prNumber, repository]);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/**
|
|
2509
|
+
* Count pool worktrees for a repository.
|
|
2510
|
+
*/
|
|
2511
|
+
async countForRepo(repository) {
|
|
2512
|
+
const row = await queryOne(this.db, `SELECT COUNT(*) as count FROM worktree_pool WHERE repository = ? COLLATE NOCASE`, [repository]);
|
|
2513
|
+
return row ? row.count : 0;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
/**
|
|
2517
|
+
* Find worktrees for a repository that are NOT in the pool.
|
|
2518
|
+
* Joins reviews to get the review ID in one query (avoids N+1).
|
|
2519
|
+
*
|
|
2520
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
2521
|
+
* @returns {Promise<Array<{id: string, path: string, pr_number: number, repository: string, reviewId: number|null}>>}
|
|
2522
|
+
*/
|
|
2523
|
+
async findOrphanWorktrees(repository) {
|
|
2524
|
+
return await query(this.db, `
|
|
2525
|
+
SELECT w.id, w.path, w.pr_number, w.repository,
|
|
2526
|
+
r.id AS reviewId
|
|
2527
|
+
FROM worktrees w
|
|
2528
|
+
LEFT JOIN worktree_pool wp ON w.id = wp.id
|
|
2529
|
+
LEFT JOIN reviews r ON r.pr_number = w.pr_number AND r.repository = w.repository COLLATE NOCASE
|
|
2530
|
+
WHERE w.repository = ? COLLATE NOCASE AND wp.id IS NULL
|
|
2531
|
+
`, [repository]);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* Mark a pool worktree as in_use.
|
|
2536
|
+
*/
|
|
2537
|
+
async markInUse(id, prNumber) {
|
|
2538
|
+
const now = new Date().toISOString();
|
|
2539
|
+
await run(this.db, `UPDATE worktree_pool SET status = 'in_use', current_pr_number = ?, last_switched_at = ?, current_review_id = NULL WHERE id = ?`, [prNumber, now, id]);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
/**
|
|
2543
|
+
* Mark a pool worktree as available (evictable).
|
|
2544
|
+
* Clears current_review_id to release ownership.
|
|
2545
|
+
*/
|
|
2546
|
+
async markAvailable(id) {
|
|
2547
|
+
await run(this.db, `UPDATE worktree_pool SET status = 'available', current_review_id = NULL WHERE id = ?`, [id]);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* Mark a pool worktree as switching (transitional state during PR switch).
|
|
2552
|
+
*/
|
|
2553
|
+
async markSwitching(id) {
|
|
2554
|
+
await run(this.db, `UPDATE worktree_pool SET status = 'switching' WHERE id = ?`, [id]);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* Update last_fetched_at timestamp.
|
|
2559
|
+
*/
|
|
2560
|
+
async updateLastFetched(id) {
|
|
2561
|
+
const now = new Date().toISOString();
|
|
2562
|
+
await run(this.db, `UPDATE worktree_pool SET last_fetched_at = ? WHERE id = ?`, [now, id]);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/**
|
|
2566
|
+
* Find idle pool worktrees for a repository (status = 'available').
|
|
2567
|
+
*/
|
|
2568
|
+
async findIdleForRepo(repository) {
|
|
2569
|
+
return await query(this.db, `
|
|
2570
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2571
|
+
FROM worktree_pool
|
|
2572
|
+
WHERE repository = ? COLLATE NOCASE AND status = 'available'
|
|
2573
|
+
ORDER BY last_switched_at ASC NULLS FIRST
|
|
2574
|
+
`, [repository]);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
/**
|
|
2578
|
+
* Find all pool worktrees for a repository.
|
|
2579
|
+
*/
|
|
2580
|
+
async findAllForRepo(repository) {
|
|
2581
|
+
return await query(this.db, `
|
|
2582
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2583
|
+
FROM worktree_pool
|
|
2584
|
+
WHERE repository = ? COLLATE NOCASE
|
|
2585
|
+
`, [repository]);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* Find all pool worktrees for background fetch (excludes 'switching' status).
|
|
2590
|
+
* Ordered by last_fetched_at ASC NULLS FIRST (coldest first).
|
|
2591
|
+
*/
|
|
2592
|
+
async findAllForFetch(repository) {
|
|
2593
|
+
return await query(this.db, `
|
|
2594
|
+
SELECT id, path, last_fetched_at, status
|
|
2595
|
+
FROM worktree_pool
|
|
2596
|
+
WHERE repository = ? COLLATE NOCASE AND status != 'switching'
|
|
2597
|
+
ORDER BY last_fetched_at ASC NULLS FIRST
|
|
2598
|
+
`, [repository]);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/**
|
|
2602
|
+
* Check if a worktree ID belongs to the pool.
|
|
2603
|
+
*/
|
|
2604
|
+
async isPoolWorktree(id) {
|
|
2605
|
+
const row = await queryOne(this.db, `SELECT id FROM worktree_pool WHERE id = ?`, [id]);
|
|
2606
|
+
return !!row;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* Get a pool entry by worktree ID, returning the full row including status.
|
|
2611
|
+
* @param {string} id - Pool worktree ID
|
|
2612
|
+
* @returns {Promise<Object|undefined>} Pool entry or undefined if not found
|
|
2613
|
+
*/
|
|
2614
|
+
async getPoolEntry(id) {
|
|
2615
|
+
return await queryOne(this.db, `
|
|
2616
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2617
|
+
FROM worktree_pool WHERE id = ?
|
|
2618
|
+
`, [id]);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* Delete a pool entry.
|
|
2623
|
+
*/
|
|
2624
|
+
async delete(id) {
|
|
2625
|
+
await run(this.db, `DELETE FROM worktree_pool WHERE id = ?`, [id]);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/**
|
|
2629
|
+
* Find a pool worktree currently assigned to a review.
|
|
2630
|
+
* @param {number} reviewId - The review ID
|
|
2631
|
+
* @returns {Promise<{id: string}|undefined>} Pool entry with worktree ID, or undefined
|
|
2632
|
+
*/
|
|
2633
|
+
async findByReviewId(reviewId) {
|
|
2634
|
+
return await queryOne(this.db, `SELECT id FROM worktree_pool WHERE current_review_id = ? AND status = 'in_use'`, [reviewId]);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* Set the current review ID for a pool worktree (persistent ownership).
|
|
2639
|
+
* @param {string} id - Pool worktree ID
|
|
2640
|
+
* @param {number|null} reviewId - Review ID that owns the worktree
|
|
2641
|
+
*/
|
|
2642
|
+
async setCurrentReviewId(id, reviewId) {
|
|
2643
|
+
await run(this.db, `UPDATE worktree_pool SET current_review_id = ? WHERE id = ?`, [reviewId, id]);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Atomically reserve a new pool slot if capacity allows.
|
|
2648
|
+
* Uses BEGIN IMMEDIATE to serialize against concurrent callers, preventing
|
|
2649
|
+
* two requests from both observing spare capacity and both creating slots.
|
|
2650
|
+
*
|
|
2651
|
+
* Inserts a placeholder row with status 'creating'. The caller must:
|
|
2652
|
+
* - On success: call markInUse() to transition to 'in_use'
|
|
2653
|
+
* - On failure: call deleteReservation() to remove the placeholder
|
|
2654
|
+
*
|
|
2655
|
+
* @param {string} id - Pool worktree ID (e.g., 'pool-abc')
|
|
2656
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
2657
|
+
* @param {number} poolSize - Maximum pool slots for this repository
|
|
2658
|
+
* @returns {Promise<boolean>} true if the slot was reserved, false if at capacity
|
|
2659
|
+
*/
|
|
2660
|
+
async reserveSlot(id, repository, poolSize) {
|
|
2661
|
+
const reserveTx = this.db.transaction(() => {
|
|
2662
|
+
const row = this.db.prepare(
|
|
2663
|
+
`SELECT COUNT(*) as count FROM worktree_pool WHERE repository = ? COLLATE NOCASE`
|
|
2664
|
+
).get(repository);
|
|
2665
|
+
const currentCount = row ? row.count : 0;
|
|
2666
|
+
if (currentCount >= poolSize) {
|
|
2667
|
+
return false;
|
|
2668
|
+
}
|
|
2669
|
+
const now = new Date().toISOString();
|
|
2670
|
+
// Use a unique placeholder path to satisfy the UNIQUE constraint.
|
|
2671
|
+
// finalizeReservation will replace it with the real path.
|
|
2672
|
+
const placeholderPath = `__creating__${id}`;
|
|
2673
|
+
this.db.prepare(
|
|
2674
|
+
`INSERT INTO worktree_pool (id, repository, path, status, created_at) VALUES (?, ?, ?, 'creating', ?)`
|
|
2675
|
+
).run(id, repository, placeholderPath, now);
|
|
2676
|
+
return true;
|
|
2677
|
+
});
|
|
2678
|
+
return reserveTx.immediate();
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* Finalize a reserved pool slot after successful worktree creation.
|
|
2683
|
+
* Updates the placeholder row with the actual path and marks it in_use.
|
|
2684
|
+
*
|
|
2685
|
+
* @param {string} id - Pool worktree ID
|
|
2686
|
+
* @param {string} path - Absolute filesystem path to the created worktree
|
|
2687
|
+
* @param {number} prNumber - PR number to assign
|
|
2688
|
+
*/
|
|
2689
|
+
async finalizeReservation(id, path, prNumber) {
|
|
2690
|
+
const now = new Date().toISOString();
|
|
2691
|
+
await run(this.db, `UPDATE worktree_pool SET path = ?, status = 'in_use', current_pr_number = ?, last_switched_at = ? WHERE id = ? AND status = 'creating'`, [path, prNumber, now, id]);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/**
|
|
2695
|
+
* Delete a reserved pool slot placeholder (cleanup on creation failure).
|
|
2696
|
+
*
|
|
2697
|
+
* @param {string} id - Pool worktree ID to remove
|
|
2698
|
+
*/
|
|
2699
|
+
async deleteReservation(id) {
|
|
2700
|
+
await run(this.db, `DELETE FROM worktree_pool WHERE id = ? AND status = 'creating'`, [id]);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/**
|
|
2704
|
+
* Atomically find and claim a pool worktree already assigned to a PR.
|
|
2705
|
+
* Uses BEGIN IMMEDIATE to serialize against concurrent callers.
|
|
2706
|
+
*
|
|
2707
|
+
* @param {number} prNumber - PR number to find
|
|
2708
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
2709
|
+
* @returns {Promise<Object|null>} Claimed pool entry or null if not found
|
|
2710
|
+
*/
|
|
2711
|
+
async claimByPR(prNumber, repository) {
|
|
2712
|
+
const claimTx = this.db.transaction(() => {
|
|
2713
|
+
const entry = this.db.prepare(`
|
|
2714
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2715
|
+
FROM worktree_pool
|
|
2716
|
+
WHERE current_pr_number = ? AND repository = ? COLLATE NOCASE
|
|
2717
|
+
AND status IN ('in_use', 'available')
|
|
2718
|
+
`).get(prNumber, repository);
|
|
2719
|
+
if (entry) {
|
|
2720
|
+
const now = new Date().toISOString();
|
|
2721
|
+
this.db.prepare(
|
|
2722
|
+
`UPDATE worktree_pool SET status = 'in_use', current_pr_number = ?, last_switched_at = ?, current_review_id = NULL WHERE id = ?`
|
|
2723
|
+
).run(prNumber, now, entry.id);
|
|
2724
|
+
}
|
|
2725
|
+
return entry || null;
|
|
2726
|
+
});
|
|
2727
|
+
return claimTx.immediate();
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/**
|
|
2731
|
+
* Atomically find and claim the LRU available pool worktree for a repository.
|
|
2732
|
+
* Uses BEGIN IMMEDIATE to serialize against concurrent callers.
|
|
2733
|
+
* Marks the claimed entry as 'switching' so no other caller can grab it.
|
|
2734
|
+
*
|
|
2735
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
2736
|
+
* @returns {Promise<Object|null>} Claimed pool entry or null if none available
|
|
2737
|
+
*/
|
|
2738
|
+
async claimAvailable(repository) {
|
|
2739
|
+
const claimTx = this.db.transaction(() => {
|
|
2740
|
+
const entry = this.db.prepare(`
|
|
2741
|
+
SELECT id, repository, path, status, current_pr_number, last_switched_at, last_fetched_at, created_at
|
|
2742
|
+
FROM worktree_pool
|
|
2743
|
+
WHERE repository = ? COLLATE NOCASE AND status = 'available'
|
|
2744
|
+
ORDER BY last_switched_at ASC NULLS FIRST
|
|
2745
|
+
LIMIT 1
|
|
2746
|
+
`).get(repository);
|
|
2747
|
+
if (entry) {
|
|
2748
|
+
this.db.prepare(
|
|
2749
|
+
`UPDATE worktree_pool SET status = 'switching' WHERE id = ?`
|
|
2750
|
+
).run(entry.id);
|
|
2751
|
+
}
|
|
2752
|
+
return entry || null;
|
|
2753
|
+
});
|
|
2754
|
+
return claimTx.immediate();
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* Reset stale pool entries on startup while preserving valid ownership.
|
|
2759
|
+
* Entries are considered stale if: no review owner, interrupted switching,
|
|
2760
|
+
* or the owning review has been deleted.
|
|
2761
|
+
* @returns {Array<{id: string, current_review_id: number}>} Preserved entries for in-memory rehydration
|
|
2762
|
+
*/
|
|
2763
|
+
async resetStaleAndPreserve() {
|
|
2764
|
+
// Delete placeholder entries that were mid-creation when the server stopped.
|
|
2765
|
+
// These have no valid path or worktree on disk — they cannot be recovered.
|
|
2766
|
+
await run(this.db, `DELETE FROM worktree_pool WHERE status = 'creating'`);
|
|
2767
|
+
|
|
2768
|
+
// Reset entries that are stale: no review owner, interrupted switching, or review deleted
|
|
2769
|
+
await run(this.db, `
|
|
2770
|
+
UPDATE worktree_pool SET status = 'available', current_review_id = NULL
|
|
2771
|
+
WHERE status != 'available' AND (
|
|
2772
|
+
current_review_id IS NULL
|
|
2773
|
+
OR status = 'switching'
|
|
2774
|
+
OR current_review_id NOT IN (SELECT id FROM reviews)
|
|
2775
|
+
)
|
|
2776
|
+
`);
|
|
2777
|
+
// Return preserved entries for in-memory rehydration
|
|
2778
|
+
return await query(this.db, `
|
|
2779
|
+
SELECT id, current_review_id FROM worktree_pool
|
|
2780
|
+
WHERE status = 'in_use' AND current_review_id IS NOT NULL
|
|
2781
|
+
`);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2118
2785
|
/**
|
|
2119
2786
|
* RepoSettingsRepository class for managing per-repository AI settings
|
|
2120
2787
|
*/
|
|
@@ -2134,9 +2801,9 @@ class RepoSettingsRepository {
|
|
|
2134
2801
|
*/
|
|
2135
2802
|
async getRepoSettings(repository) {
|
|
2136
2803
|
const row = await queryOne(this.db, `
|
|
2137
|
-
SELECT id, repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, auto_branch_review, created_at, updated_at
|
|
2804
|
+
SELECT id, repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, auto_branch_review, pool_size, pool_fetch_interval_minutes, load_skills, created_at, updated_at
|
|
2138
2805
|
FROM repo_settings
|
|
2139
|
-
WHERE repository = ?
|
|
2806
|
+
WHERE repository = ?
|
|
2140
2807
|
`, [repository]);
|
|
2141
2808
|
|
|
2142
2809
|
return row || null;
|
|
@@ -2149,7 +2816,7 @@ class RepoSettingsRepository {
|
|
|
2149
2816
|
*/
|
|
2150
2817
|
async getLocalPath(repository) {
|
|
2151
2818
|
const row = await queryOne(this.db, `
|
|
2152
|
-
SELECT local_path FROM repo_settings WHERE repository = ?
|
|
2819
|
+
SELECT local_path FROM repo_settings WHERE repository = ?
|
|
2153
2820
|
`, [repository]);
|
|
2154
2821
|
|
|
2155
2822
|
return row ? row.local_path : null;
|
|
@@ -2173,7 +2840,7 @@ class RepoSettingsRepository {
|
|
|
2173
2840
|
await run(this.db, `
|
|
2174
2841
|
UPDATE repo_settings
|
|
2175
2842
|
SET local_path = ?, updated_at = ?
|
|
2176
|
-
WHERE repository = ?
|
|
2843
|
+
WHERE repository = ?
|
|
2177
2844
|
`, [localPath, now, repository]);
|
|
2178
2845
|
} else {
|
|
2179
2846
|
// Insert new settings with just local_path
|
|
@@ -2191,7 +2858,7 @@ class RepoSettingsRepository {
|
|
|
2191
2858
|
* @returns {Promise<Object>} Saved settings object
|
|
2192
2859
|
*/
|
|
2193
2860
|
async saveRepoSettings(repository, settings) {
|
|
2194
|
-
const { default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path } = settings;
|
|
2861
|
+
const { default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, pool_size, pool_fetch_interval_minutes, load_skills } = settings;
|
|
2195
2862
|
const now = new Date().toISOString();
|
|
2196
2863
|
|
|
2197
2864
|
// Check if settings already exist
|
|
@@ -2208,8 +2875,11 @@ class RepoSettingsRepository {
|
|
|
2208
2875
|
default_tab = ?,
|
|
2209
2876
|
default_chat_instructions = ?,
|
|
2210
2877
|
local_path = ?,
|
|
2878
|
+
pool_size = ?,
|
|
2879
|
+
pool_fetch_interval_minutes = ?,
|
|
2880
|
+
load_skills = ?,
|
|
2211
2881
|
updated_at = ?
|
|
2212
|
-
WHERE repository = ?
|
|
2882
|
+
WHERE repository = ?
|
|
2213
2883
|
`, [
|
|
2214
2884
|
default_instructions !== undefined ? default_instructions : existing.default_instructions,
|
|
2215
2885
|
default_provider !== undefined ? default_provider : existing.default_provider,
|
|
@@ -2218,6 +2888,9 @@ class RepoSettingsRepository {
|
|
|
2218
2888
|
default_tab !== undefined ? default_tab : existing.default_tab,
|
|
2219
2889
|
default_chat_instructions !== undefined ? default_chat_instructions : existing.default_chat_instructions,
|
|
2220
2890
|
local_path !== undefined ? local_path : existing.local_path,
|
|
2891
|
+
pool_size !== undefined ? pool_size : existing.pool_size,
|
|
2892
|
+
pool_fetch_interval_minutes !== undefined ? pool_fetch_interval_minutes : existing.pool_fetch_interval_minutes,
|
|
2893
|
+
load_skills !== undefined ? load_skills : existing.load_skills,
|
|
2221
2894
|
now,
|
|
2222
2895
|
repository
|
|
2223
2896
|
]);
|
|
@@ -2231,14 +2904,17 @@ class RepoSettingsRepository {
|
|
|
2231
2904
|
default_tab: default_tab !== undefined ? default_tab : existing.default_tab,
|
|
2232
2905
|
default_chat_instructions: default_chat_instructions !== undefined ? default_chat_instructions : existing.default_chat_instructions,
|
|
2233
2906
|
local_path: local_path !== undefined ? local_path : existing.local_path,
|
|
2907
|
+
pool_size: pool_size !== undefined ? pool_size : existing.pool_size,
|
|
2908
|
+
pool_fetch_interval_minutes: pool_fetch_interval_minutes !== undefined ? pool_fetch_interval_minutes : existing.pool_fetch_interval_minutes,
|
|
2909
|
+
load_skills: load_skills !== undefined ? load_skills : existing.load_skills,
|
|
2234
2910
|
updated_at: now
|
|
2235
2911
|
};
|
|
2236
2912
|
} else {
|
|
2237
2913
|
// Insert new settings
|
|
2238
2914
|
const result = await run(this.db, `
|
|
2239
|
-
INSERT INTO repo_settings (repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, created_at, updated_at)
|
|
2240
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2241
|
-
`, [repository, default_instructions || null, default_provider || null, default_model || null, default_council_id || null, default_tab || null, default_chat_instructions || null, local_path || null, now, now]);
|
|
2915
|
+
INSERT INTO repo_settings (repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, pool_size, pool_fetch_interval_minutes, load_skills, created_at, updated_at)
|
|
2916
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2917
|
+
`, [repository, default_instructions || null, default_provider || null, default_model || null, default_council_id || null, default_tab || null, default_chat_instructions || null, local_path || null, pool_size ?? null, pool_fetch_interval_minutes ?? null, load_skills ?? null, now, now]);
|
|
2242
2918
|
|
|
2243
2919
|
return {
|
|
2244
2920
|
id: result.lastID,
|
|
@@ -2250,12 +2926,61 @@ class RepoSettingsRepository {
|
|
|
2250
2926
|
default_tab: default_tab || null,
|
|
2251
2927
|
default_chat_instructions: default_chat_instructions || null,
|
|
2252
2928
|
local_path: local_path || null,
|
|
2929
|
+
pool_size: pool_size ?? null,
|
|
2930
|
+
pool_fetch_interval_minutes: pool_fetch_interval_minutes ?? null,
|
|
2931
|
+
load_skills: load_skills ?? null,
|
|
2253
2932
|
created_at: now,
|
|
2254
2933
|
updated_at: now
|
|
2255
2934
|
};
|
|
2256
2935
|
}
|
|
2257
2936
|
}
|
|
2258
2937
|
|
|
2938
|
+
/**
|
|
2939
|
+
* Atomically attempt to claim the background fetch lease for a repository.
|
|
2940
|
+
* Uses SQLite UPSERT with a conditional WHERE clause to avoid TOCTOU races
|
|
2941
|
+
* between instances sharing the same database. Creates a repo_settings row
|
|
2942
|
+
* if one doesn't exist yet (config-only repos).
|
|
2943
|
+
* @param {string} repository - Repository in owner/repo format
|
|
2944
|
+
* @param {number} [staleGuardMs=600000] - Consider a fetch stale after this many ms (default 10 min)
|
|
2945
|
+
* @returns {Promise<boolean>} true if the lease was successfully claimed
|
|
2946
|
+
*/
|
|
2947
|
+
async tryClaimFetch(repository, staleGuardMs = 600000) {
|
|
2948
|
+
const now = new Date().toISOString();
|
|
2949
|
+
const staleThreshold = new Date(Date.now() - staleGuardMs).toISOString();
|
|
2950
|
+
const result = await run(this.db,
|
|
2951
|
+
`INSERT INTO repo_settings (repository, pool_fetch_started_at, created_at, updated_at)
|
|
2952
|
+
VALUES (?, ?, ?, ?)
|
|
2953
|
+
ON CONFLICT(repository) DO UPDATE SET pool_fetch_started_at = excluded.pool_fetch_started_at,
|
|
2954
|
+
pool_fetch_finished_at = NULL
|
|
2955
|
+
WHERE pool_fetch_started_at IS NULL
|
|
2956
|
+
OR pool_fetch_finished_at >= pool_fetch_started_at
|
|
2957
|
+
OR pool_fetch_started_at < ?`,
|
|
2958
|
+
[repository, now, now, now, staleThreshold]
|
|
2959
|
+
);
|
|
2960
|
+
return result.changes > 0;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
/**
|
|
2964
|
+
* Refresh the fetch lease timestamp for a repository.
|
|
2965
|
+
* Called after each successful worktree fetch to extend the stale guard window
|
|
2966
|
+
* so the 10-minute default only needs to outlive a single stalled fetch rather
|
|
2967
|
+
* than the entire serial loop across all worktrees.
|
|
2968
|
+
* @param {string} repository - Repository in owner/repo format
|
|
2969
|
+
*/
|
|
2970
|
+
async refreshFetchLease(repository) {
|
|
2971
|
+
const now = new Date().toISOString();
|
|
2972
|
+
await run(this.db, `UPDATE repo_settings SET pool_fetch_started_at = ? WHERE repository = ?`, [now, repository]);
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/**
|
|
2976
|
+
* Mark a repo-level background fetch as finished.
|
|
2977
|
+
* @param {string} repository - Repository in owner/repo format
|
|
2978
|
+
*/
|
|
2979
|
+
async markFetchFinished(repository) {
|
|
2980
|
+
const now = new Date().toISOString();
|
|
2981
|
+
await run(this.db, `UPDATE repo_settings SET pool_fetch_finished_at = ? WHERE repository = ?`, [now, repository]);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2259
2984
|
/**
|
|
2260
2985
|
* Delete settings for a repository
|
|
2261
2986
|
* @param {string} repository - Repository in owner/repo format
|
|
@@ -2263,7 +2988,7 @@ class RepoSettingsRepository {
|
|
|
2263
2988
|
*/
|
|
2264
2989
|
async deleteRepoSettings(repository) {
|
|
2265
2990
|
const result = await run(this.db, `
|
|
2266
|
-
DELETE FROM repo_settings WHERE repository = ?
|
|
2991
|
+
DELETE FROM repo_settings WHERE repository = ?
|
|
2267
2992
|
`, [repository]);
|
|
2268
2993
|
|
|
2269
2994
|
return result.changes > 0;
|
|
@@ -4234,6 +4959,7 @@ module.exports = {
|
|
|
4234
4959
|
CURRENT_SCHEMA_VERSION,
|
|
4235
4960
|
getDbPath,
|
|
4236
4961
|
WorktreeRepository,
|
|
4962
|
+
WorktreePoolRepository,
|
|
4237
4963
|
RepoSettingsRepository,
|
|
4238
4964
|
ReviewRepository,
|
|
4239
4965
|
CommentRepository,
|
|
@@ -4243,5 +4969,7 @@ module.exports = {
|
|
|
4243
4969
|
CouncilRepository,
|
|
4244
4970
|
ContextFileRepository,
|
|
4245
4971
|
generateWorktreeId,
|
|
4246
|
-
migrateExistingWorktrees
|
|
4972
|
+
migrateExistingWorktrees,
|
|
4973
|
+
// Exported for testing only
|
|
4974
|
+
_MIGRATIONS: MIGRATIONS
|
|
4247
4975
|
};
|