atris 3.62.0 → 3.64.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/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +120 -0
- package/atris/skills/x-search/SKILL.md +3 -1
- package/ax +4 -3
- package/bin/atris.js +22 -2
- package/cli/atris_code.py +25 -1
- package/commands/auth.js +79 -10
- package/commands/autoland.js +11 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +3 -3
- package/commands/buy.js +242 -0
- package/commands/deck.js +2 -2
- package/commands/engine.js +122 -13
- package/commands/feedback.js +2 -2
- package/commands/fork.js +2 -2
- package/commands/gm.js +2 -1
- package/commands/improve.js +142 -8
- package/commands/integrations.js +4 -3
- package/commands/loops.js +2 -1
- package/commands/member.js +82 -15
- package/commands/mission.js +4 -1
- package/commands/pack-craft.js +2 -2
- package/commands/publish.js +2 -2
- package/commands/radar.js +3 -2
- package/commands/router.js +2 -2
- package/commands/task.js +91 -2
- package/commands/transactions.js +115 -0
- package/commands/truth.js +2 -2
- package/commands/x-search.js +81 -10
- package/commands/youtube.js +24 -1
- package/lib/autoland.js +3 -2
- package/lib/ax-auto-lane.js +2 -2
- package/lib/brief-rules.js +11 -0
- package/lib/chat-log-scan.js +2 -1
- package/lib/ci-runner.js +3 -2
- package/lib/codex-flight.js +2 -1
- package/lib/commerce.js +90 -0
- package/lib/deck-history.js +2 -2
- package/lib/deck-review.js +3 -3
- package/lib/design-api.js +2 -2
- package/lib/engine-registry.js +102 -18
- package/lib/first-minute.js +2 -2
- package/lib/fleet.js +8 -36
- package/lib/git-spawn.js +5 -2
- package/lib/ignored-cache.js +51 -0
- package/lib/known-commands.js +1 -1
- package/lib/manifest.js +2 -2
- package/lib/map-refs.js +70 -4
- package/lib/member-engine.js +15 -1
- package/lib/member-scaffold.js +2 -0
- package/lib/member-type.js +27 -0
- package/lib/pack-capabilities.js +2 -1
- package/lib/permission-grants.js +2 -2
- package/lib/pulse.js +5 -5
- package/lib/roster-runs.js +13 -2
- package/lib/runner-command.js +54 -3
- package/lib/sandbox-pids.js +113 -0
- package/lib/secret-gateway.js +3 -13
- package/lib/state-home.js +106 -0
- package/lib/task-db.js +4 -3
- package/lib/task-prune.js +462 -0
- package/lib/x-post-check.js +497 -0
- package/package.json +1 -1
- package/scripts/det/data/xsearch-answer.json +24 -0
- package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
- package/scripts/det/xsearch-bench.js +307 -0
- package/scripts/det/ytnotes +122 -16
- package/scripts/det/ytrail-bench.js +215 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/utils/auth.js +3 -3
- package/utils/update-check.js +2 -2
package/lib/task-db.js
CHANGED
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
|
-
const os = require('os');
|
|
28
27
|
const crypto = require('crypto');
|
|
29
28
|
const { DatabaseSync } = require('node:sqlite');
|
|
30
29
|
const reviewIntegrity = require('./review-integrity');
|
|
@@ -35,8 +34,8 @@ const { parseVerifyCommand } = require('./auto-accept-certified');
|
|
|
35
34
|
const { taskExplanation } = require('./task-explanation');
|
|
36
35
|
const { treeHashFor } = require('./tree-hash');
|
|
37
36
|
const { sleepSync } = require('./sleep-sync');
|
|
37
|
+
const { atrisHome } = require('./state-home');
|
|
38
38
|
|
|
39
|
-
const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
|
|
40
39
|
const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
|
|
41
40
|
const TODO_RENDER_DONE_LIMIT = 8;
|
|
42
41
|
const TODO_RENDER_FAILED_LIMIT = 12;
|
|
@@ -188,8 +187,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_tasks_source ON tasks(workspace_root, sourc
|
|
|
188
187
|
let _cachedDb = null;
|
|
189
188
|
let _cachedPath = null;
|
|
190
189
|
|
|
190
|
+
// Default is ~/.atris/tasks.db, resolved per call so a test run that forgets
|
|
191
|
+
// ATRIS_TASKS_DB lands in a throwaway db instead of the real one.
|
|
191
192
|
function getDbPath() {
|
|
192
|
-
return process.env.ATRIS_TASKS_DB ||
|
|
193
|
+
return process.env.ATRIS_TASKS_DB || path.join(atrisHome(), 'tasks.db');
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
function open(dbPath) {
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Remove task rows that test runs wrote into the shared task database.
|
|
4
|
+
// A row is test junk only when its project folder sits under a temp
|
|
5
|
+
// directory (/tmp, /var/folders, os.tmpdir(), and their /private twins) AND
|
|
6
|
+
// that folder is gone from disk. Tests make and remove their temp folders;
|
|
7
|
+
// a live checkout under /tmp still exists, so it is never touched. Neither
|
|
8
|
+
// is any row with a real project path.
|
|
9
|
+
//
|
|
10
|
+
// planPrune reads only. applyPrune writes a consistent backup with
|
|
11
|
+
// VACUUM INTO, checks it, plans from the backup, then deletes in short
|
|
12
|
+
// transactions. Each transaction re-checks every task against the backup
|
|
13
|
+
// (same row, same history count, still idle, folder still gone), so nothing
|
|
14
|
+
// is removed that the backup does not hold.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
21
|
+
const { withBusyRetry } = require('./task-db');
|
|
22
|
+
const { sleepSync } = require('./sleep-sync');
|
|
23
|
+
|
|
24
|
+
// A temp task with any activity this recent may belong to a test still running.
|
|
25
|
+
const GRACE_MS = 60 * 60 * 1000;
|
|
26
|
+
const MAX_ROWS_PER_BATCH = 2000;
|
|
27
|
+
const BATCH_PAUSE_MS = 20;
|
|
28
|
+
const BUSY_TIMEOUT_MS = 5000;
|
|
29
|
+
|
|
30
|
+
const NAMED_TEMP_ROOTS = [
|
|
31
|
+
'/tmp', '/private/tmp',
|
|
32
|
+
'/var/tmp', '/private/var/tmp',
|
|
33
|
+
'/var/folders', '/private/var/folders',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// History lookups by task id. Each must use the task-id index; a plan on the
|
|
37
|
+
// workspace/time index rescans unrelated history under the write lock.
|
|
38
|
+
const EVENT_SQL = {
|
|
39
|
+
summary: 'SELECT COUNT(*) AS n, MAX(created_at) AS last_at FROM task_events WHERE task_id = ?',
|
|
40
|
+
roots: 'SELECT DISTINCT workspace_root FROM task_events WHERE task_id = ?',
|
|
41
|
+
remove: 'DELETE FROM task_events WHERE task_id = ?',
|
|
42
|
+
};
|
|
43
|
+
// Same definition lib/task-db.js creates on open.
|
|
44
|
+
const TASK_ID_INDEX = 'CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, version)';
|
|
45
|
+
|
|
46
|
+
const TASK_COLUMNS = 'id, title, status, tag, workspace_root, source_key, claimed_by, claimed_at, created_at, updated_at, done_at, metadata';
|
|
47
|
+
|
|
48
|
+
function normalizeRoot(value) {
|
|
49
|
+
const raw = String(value || '').replace(/\\/g, '/');
|
|
50
|
+
if (!raw.startsWith('/') && !/^[A-Za-z]:\//.test(raw)) return null;
|
|
51
|
+
// `.` and `..` can mean something else once a link is in the path; a
|
|
52
|
+
// folder spelled that way is never treated as temp.
|
|
53
|
+
if (raw.split('/').some(part => part === '.' || part === '..')) return null;
|
|
54
|
+
const normal = path.posix.normalize(raw);
|
|
55
|
+
return normal.length > 1 ? normal.replace(/\/+$/, '') : normal;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function realKey(key) {
|
|
59
|
+
try { return normalizeRoot(fs.realpathSync(key)); } catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function lstatOrNull(target) {
|
|
63
|
+
try { return fs.lstatSync(target); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A link that is a temp root or sits above one (/tmp, /var, a linked TMPDIR)
|
|
67
|
+
// is expected. Any other link in the path means we cannot be sure.
|
|
68
|
+
function isExpectedLink(dir, roots) {
|
|
69
|
+
return roots.some(root => root === dir || root.startsWith(`${dir}/`));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The real path of a folder that may be gone: resolve its nearest existing
|
|
73
|
+
// ancestor (so /var -> /private/var and a symlinked TMPDIR both line up),
|
|
74
|
+
// then put the missing tail back. lstat, not exists: a dangling link is
|
|
75
|
+
// something on disk and stops the walk. Null when any other link is in the
|
|
76
|
+
// existing part of the path.
|
|
77
|
+
function resolvedKey(key, roots) {
|
|
78
|
+
let head = key;
|
|
79
|
+
const tail = [];
|
|
80
|
+
while (head && head !== '/' && !lstatOrNull(head)) {
|
|
81
|
+
tail.unshift(path.posix.basename(head));
|
|
82
|
+
head = path.posix.dirname(head);
|
|
83
|
+
}
|
|
84
|
+
for (let dir = head; dir && dir !== '/'; dir = path.posix.dirname(dir)) {
|
|
85
|
+
const stat = lstatOrNull(dir);
|
|
86
|
+
if (stat && stat.isSymbolicLink() && !isExpectedLink(dir, roots)) return null;
|
|
87
|
+
}
|
|
88
|
+
const real = realKey(head);
|
|
89
|
+
if (!real) return null;
|
|
90
|
+
return tail.length ? path.posix.join(real, ...tail) : real;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeTmpdir() {
|
|
94
|
+
try { return os.tmpdir(); } catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function tempRoots({ tmpdir = safeTmpdir(), named = NAMED_TEMP_ROOTS } = {}) {
|
|
98
|
+
const roots = new Set();
|
|
99
|
+
for (const dir of [...named, tmpdir]) {
|
|
100
|
+
const key = normalizeRoot(dir);
|
|
101
|
+
if (!key || key === '/') continue;
|
|
102
|
+
roots.add(key);
|
|
103
|
+
const real = realKey(key);
|
|
104
|
+
if (real && real !== '/') roots.add(real);
|
|
105
|
+
}
|
|
106
|
+
// Shortest first so a row groups under the broad folder, e.g. /var/folders.
|
|
107
|
+
return [...roots].sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function matchRoot(key, roots) {
|
|
111
|
+
for (const root of roots) {
|
|
112
|
+
if (key === root || key.startsWith(`${root}/`)) return root;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// fs.mkdtempSync appends six random letters or digits, and the prefixes in
|
|
118
|
+
// test/ end with a dash, so a test folder's last segment ends `-XXXXXX`.
|
|
119
|
+
// Hand-named folders (/tmp/agentxp, /tmp/bench-boot-probe) held real agent
|
|
120
|
+
// work and never match.
|
|
121
|
+
function looksMkdtemp(key) {
|
|
122
|
+
return /-[A-Za-z0-9]{6}$/.test(path.posix.basename(key));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The temp root a project folder sits under, or null for a real project.
|
|
126
|
+
// The spelling alone never decides: the folder's real location (through its
|
|
127
|
+
// nearest existing ancestor) must sit under a temp root, so a missing
|
|
128
|
+
// /tmp/projects/old-repo where /tmp/projects links to a home folder is real.
|
|
129
|
+
// The stored spelling only picks the group label.
|
|
130
|
+
function tempPrefixFor(workspaceRoot, roots = tempRoots()) {
|
|
131
|
+
const key = normalizeRoot(workspaceRoot);
|
|
132
|
+
if (!key) return null;
|
|
133
|
+
const resolved = resolvedKey(key, roots);
|
|
134
|
+
if (!resolved) return null;
|
|
135
|
+
const real = matchRoot(resolved, roots);
|
|
136
|
+
if (!real || resolved === real || roots.includes(key) || !looksMkdtemp(key)) return null;
|
|
137
|
+
return matchRoot(key, roots) || real;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only a folder that is clearly missing counts as gone. A permission error
|
|
141
|
+
// or anything else odd means we cannot tell, so the row stays.
|
|
142
|
+
function folderGone(dir) {
|
|
143
|
+
try {
|
|
144
|
+
fs.lstatSync(dir);
|
|
145
|
+
return false;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return Boolean(err && (err.code === 'ENOENT' || err.code === 'ENOTDIR'));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function makeJudge(roots) {
|
|
152
|
+
const cache = new Map();
|
|
153
|
+
// 'real' | 'exists' | prefix (temp and gone)
|
|
154
|
+
return (root) => {
|
|
155
|
+
if (cache.has(root)) return cache.get(root);
|
|
156
|
+
const prefix = tempPrefixFor(root, roots);
|
|
157
|
+
const verdict = !prefix ? 'real' : (folderGone(root) ? prefix : 'exists');
|
|
158
|
+
cache.set(root, verdict);
|
|
159
|
+
return verdict;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isJunk(verdict) {
|
|
164
|
+
return verdict !== 'real' && verdict !== 'exists';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function fingerprint(row) {
|
|
168
|
+
return crypto.createHash('sha1').update(JSON.stringify(TASK_COLUMNS.split(', ').map(col => row[col]))).digest('hex');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Stream rows where node:sqlite supports it; older builds only have all().
|
|
172
|
+
function eachRow(stmt) {
|
|
173
|
+
return typeof stmt.iterate === 'function' ? stmt.iterate() : stmt.all();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function tableExists(db, name) {
|
|
177
|
+
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function dbBytes(dbPath) {
|
|
181
|
+
let total = 0;
|
|
182
|
+
for (const suffix of ['', '-wal']) {
|
|
183
|
+
try { total += fs.statSync(dbPath + suffix).size; } catch { /* missing wal is fine */ }
|
|
184
|
+
}
|
|
185
|
+
return total;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function openReadOnly(dbPath) {
|
|
189
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
190
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
191
|
+
return db;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Read-only. Works on the live database or on a backup.
|
|
195
|
+
function planPrune(db, { now = Date.now(), graceMs = GRACE_MS, roots = tempRoots() } = {}) {
|
|
196
|
+
const cutoff = now - graceMs;
|
|
197
|
+
const judge = makeJudge(roots);
|
|
198
|
+
|
|
199
|
+
// History per task: row count, newest row, and every folder it names.
|
|
200
|
+
const history = new Map();
|
|
201
|
+
for (const row of eachRow(db.prepare(`
|
|
202
|
+
SELECT task_id, workspace_root, COUNT(*) AS n, MAX(created_at) AS last_at
|
|
203
|
+
FROM task_events GROUP BY task_id, workspace_root
|
|
204
|
+
`))) {
|
|
205
|
+
const entry = history.get(row.task_id) || { n: 0, last_at: 0, roots: [] };
|
|
206
|
+
entry.n += Number(row.n);
|
|
207
|
+
entry.last_at = Math.max(entry.last_at, Number(row.last_at) || 0);
|
|
208
|
+
entry.roots.push(row.workspace_root);
|
|
209
|
+
history.set(row.task_id, entry);
|
|
210
|
+
}
|
|
211
|
+
const allJunk = (entry) => entry.roots.every(root => isJunk(judge(root)));
|
|
212
|
+
|
|
213
|
+
const usesByTask = new Map();
|
|
214
|
+
if (tableExists(db, 'part_uses')) {
|
|
215
|
+
for (const row of db.prepare('SELECT used_by, COUNT(*) AS n FROM part_uses WHERE used_by IS NOT NULL GROUP BY used_by').all()) {
|
|
216
|
+
usesByTask.set(row.used_by, Number(row.n));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const groups = new Map();
|
|
221
|
+
const group = (prefix) => {
|
|
222
|
+
if (!groups.has(prefix)) groups.set(prefix, { prefix, tasks: 0, history_rows: 0, stray_history_rows: 0, part_uses: 0 });
|
|
223
|
+
return groups.get(prefix);
|
|
224
|
+
};
|
|
225
|
+
const rows = [];
|
|
226
|
+
const kept = { recent: 0, held: 0, folder_exists: 0, stray_held: 0, stray_recent: 0 };
|
|
227
|
+
let totalTasks = 0;
|
|
228
|
+
for (const row of eachRow(db.prepare(`SELECT ${TASK_COLUMNS} FROM tasks`))) {
|
|
229
|
+
totalTasks += 1;
|
|
230
|
+
const entry = history.get(row.id) || { n: 0, last_at: 0, roots: [] };
|
|
231
|
+
history.delete(row.id);
|
|
232
|
+
const verdict = judge(row.workspace_root);
|
|
233
|
+
if (verdict === 'real') continue;
|
|
234
|
+
if (verdict === 'exists') { kept.folder_exists += 1; continue; }
|
|
235
|
+
if (!allJunk(entry)) { kept.held += 1; continue; }
|
|
236
|
+
if (Math.max(Number(row.updated_at) || 0, entry.last_at) >= cutoff) { kept.recent += 1; continue; }
|
|
237
|
+
const g = group(verdict);
|
|
238
|
+
const uses = usesByTask.get(row.id) || 0;
|
|
239
|
+
g.tasks += 1;
|
|
240
|
+
g.history_rows += entry.n;
|
|
241
|
+
g.part_uses += uses;
|
|
242
|
+
rows.push({ kind: 'task', id: row.id, workspace_root: row.workspace_root, fp: fingerprint(row), events: entry.n, uses });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// What is left in history has no task row. Only temp-and-gone history is
|
|
246
|
+
// eligible, and a missing task keeps all of its history if any row names
|
|
247
|
+
// a real project or a folder that still exists.
|
|
248
|
+
for (const [taskId, entry] of history) {
|
|
249
|
+
const verdicts = entry.roots.map(judge);
|
|
250
|
+
if (!verdicts.some(isJunk)) continue;
|
|
251
|
+
if (!verdicts.every(isJunk)) { kept.stray_held += 1; continue; }
|
|
252
|
+
if (entry.last_at >= cutoff) { kept.stray_recent += 1; continue; }
|
|
253
|
+
group(verdicts[0]).stray_history_rows += entry.n;
|
|
254
|
+
rows.push({ kind: 'stray', id: taskId, events: entry.n, uses: 0 });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const list = [...groups.values()].sort((a, b) => b.tasks - a.tasks || a.prefix.localeCompare(b.prefix));
|
|
258
|
+
const sum = (field) => list.reduce((acc, g) => acc + g[field], 0);
|
|
259
|
+
return {
|
|
260
|
+
total_tasks: totalTasks,
|
|
261
|
+
grace_hours: graceMs / (60 * 60 * 1000),
|
|
262
|
+
groups: list,
|
|
263
|
+
remove: {
|
|
264
|
+
tasks: sum('tasks'),
|
|
265
|
+
history_rows: sum('history_rows'),
|
|
266
|
+
stray_history_rows: sum('stray_history_rows'),
|
|
267
|
+
part_uses: sum('part_uses'),
|
|
268
|
+
},
|
|
269
|
+
kept,
|
|
270
|
+
rows,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function dryRun(dbPath, options = {}) {
|
|
275
|
+
if (!fs.existsSync(dbPath)) return { db_path: dbPath, missing: true };
|
|
276
|
+
const db = openReadOnly(dbPath);
|
|
277
|
+
try {
|
|
278
|
+
const plan = planPrune(db, options);
|
|
279
|
+
return { db_path: dbPath, db_bytes: dbBytes(dbPath), ...plan };
|
|
280
|
+
} finally {
|
|
281
|
+
db.close();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function sqlString(value) {
|
|
286
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function stamp(now) {
|
|
290
|
+
return new Date(now).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function countTasks(db) {
|
|
294
|
+
return Number(db.prepare('SELECT COUNT(*) AS n FROM tasks').get().n);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function usesTaskIdIndex(db) {
|
|
298
|
+
const plan = db.prepare(`EXPLAIN QUERY PLAN ${EVENT_SQL.summary}`).all('x').map(r => r.detail).join(' ');
|
|
299
|
+
return /USING (COVERING )?INDEX \S+ \(task_id=\?/.test(plan);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Consistent copy while other processes write: VACUUM INTO reads one
|
|
303
|
+
// snapshot. A raw file copy of a live WAL database can tear.
|
|
304
|
+
function writeBackup(db, dbPath, now, roots) {
|
|
305
|
+
let backupPath = `${dbPath}.before-prune-${stamp(now)}`;
|
|
306
|
+
for (let i = 2; fs.existsSync(backupPath); i++) backupPath = `${dbPath}.before-prune-${stamp(now)}-${i}`;
|
|
307
|
+
const countBefore = countTasks(db);
|
|
308
|
+
withBusyRetry(() => db.exec(`VACUUM INTO ${sqlString(backupPath)}`));
|
|
309
|
+
const countAfter = countTasks(db);
|
|
310
|
+
const backup = openReadOnly(backupPath);
|
|
311
|
+
try {
|
|
312
|
+
const check = backup.prepare('PRAGMA quick_check').get();
|
|
313
|
+
const verdict = check && Object.values(check)[0];
|
|
314
|
+
if (verdict !== 'ok') throw new Error(`backup failed its integrity check: ${verdict}`);
|
|
315
|
+
const backupCount = countTasks(backup);
|
|
316
|
+
const low = Math.min(countBefore, countAfter);
|
|
317
|
+
const high = Math.max(countBefore, countAfter);
|
|
318
|
+
if (backupCount < low || backupCount > high) {
|
|
319
|
+
throw new Error(`backup has ${backupCount} tasks, the database had ${countBefore} to ${countAfter}`);
|
|
320
|
+
}
|
|
321
|
+
return { backupPath, backupCount, plan: planPrune(backup, { now, roots }) };
|
|
322
|
+
} finally {
|
|
323
|
+
backup.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Group work so each transaction deletes about maxRows rows. A task with more
|
|
328
|
+
// history than that still goes in one transaction of its own.
|
|
329
|
+
function chunkByRows(items, maxRows) {
|
|
330
|
+
const chunks = [];
|
|
331
|
+
let current = [];
|
|
332
|
+
let rows = 0;
|
|
333
|
+
for (const item of items) {
|
|
334
|
+
const cost = item.events + item.uses + (item.kind === 'task' ? 1 : 0);
|
|
335
|
+
if (current.length && rows + cost > maxRows) {
|
|
336
|
+
chunks.push(current);
|
|
337
|
+
current = [];
|
|
338
|
+
rows = 0;
|
|
339
|
+
}
|
|
340
|
+
current.push(item);
|
|
341
|
+
rows += cost;
|
|
342
|
+
}
|
|
343
|
+
if (current.length) chunks.push(current);
|
|
344
|
+
return chunks;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function applyPrune(dbPath, options = {}) {
|
|
348
|
+
const {
|
|
349
|
+
now = Date.now(),
|
|
350
|
+
vacuum = false,
|
|
351
|
+
graceMs = GRACE_MS,
|
|
352
|
+
maxRowsPerBatch = MAX_ROWS_PER_BATCH,
|
|
353
|
+
afterBackup = null,
|
|
354
|
+
roots = tempRoots(),
|
|
355
|
+
} = options;
|
|
356
|
+
if (!fs.existsSync(dbPath)) return { db_path: dbPath, missing: true };
|
|
357
|
+
const bytesBefore = dbBytes(dbPath);
|
|
358
|
+
const db = new DatabaseSync(dbPath);
|
|
359
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
360
|
+
try {
|
|
361
|
+
if (!usesTaskIdIndex(db)) withBusyRetry(() => db.exec(TASK_ID_INDEX));
|
|
362
|
+
if (!usesTaskIdIndex(db)) throw new Error('history lookups by task id have no index; nothing was removed');
|
|
363
|
+
const { backupPath, backupCount, plan } = writeBackup(db, dbPath, now, roots);
|
|
364
|
+
if (typeof afterBackup === 'function') afterBackup();
|
|
365
|
+
|
|
366
|
+
const hasUses = tableExists(db, 'part_uses');
|
|
367
|
+
const getTask = db.prepare(`SELECT ${TASK_COLUMNS} FROM tasks WHERE id = ?`);
|
|
368
|
+
const summary = db.prepare(EVENT_SQL.summary);
|
|
369
|
+
const eventRoots = db.prepare(EVENT_SQL.roots);
|
|
370
|
+
const delEvents = db.prepare(EVENT_SQL.remove);
|
|
371
|
+
const delTask = db.prepare('DELETE FROM tasks WHERE id = ?');
|
|
372
|
+
const delUses = hasUses ? db.prepare('DELETE FROM part_uses WHERE used_by = ?') : null;
|
|
373
|
+
// part_uses has no index on used_by, so read a whole batch in one pass.
|
|
374
|
+
const usesFor = hasUses
|
|
375
|
+
? db.prepare('SELECT used_by, COUNT(*) AS n FROM part_uses WHERE used_by IN (SELECT value FROM json_each(?)) GROUP BY used_by')
|
|
376
|
+
: null;
|
|
377
|
+
|
|
378
|
+
const removed = { tasks: 0, history_rows: 0, stray_history_rows: 0, part_uses: 0 };
|
|
379
|
+
const skipped = { changed_since_backup: 0, held: 0, recent: 0, folder_exists: 0 };
|
|
380
|
+
const chunks = chunkByRows(plan.rows, maxRowsPerBatch);
|
|
381
|
+
let batches = 0;
|
|
382
|
+
for (let c = 0; c < chunks.length; c++) {
|
|
383
|
+
withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
|
|
384
|
+
try {
|
|
385
|
+
// Fresh answers inside the lock: live clock, live disk, live rows.
|
|
386
|
+
const cutoff = Date.now() - graceMs;
|
|
387
|
+
const judge = makeJudge(roots);
|
|
388
|
+
const liveUses = new Map();
|
|
389
|
+
if (usesFor) {
|
|
390
|
+
const ids = chunks[c].filter(item => item.kind === 'task').map(item => item.id);
|
|
391
|
+
for (const row of usesFor.all(JSON.stringify(ids))) liveUses.set(row.used_by, Number(row.n));
|
|
392
|
+
}
|
|
393
|
+
for (const item of chunks[c]) {
|
|
394
|
+
const live = getTask.get(item.id);
|
|
395
|
+
if (item.kind === 'task' ? (!live || fingerprint(live) !== item.fp) : live) {
|
|
396
|
+
skipped.changed_since_backup += 1;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const ev = summary.get(item.id);
|
|
400
|
+
if (Number(ev.n) !== item.events || (liveUses.get(item.id) || 0) !== item.uses) {
|
|
401
|
+
skipped.changed_since_backup += 1;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const lastAt = Math.max(Number(ev.last_at) || 0, live ? Number(live.updated_at) || 0 : 0);
|
|
405
|
+
if (lastAt >= cutoff) { skipped.recent += 1; continue; }
|
|
406
|
+
if (live && !isJunk(judge(live.workspace_root))) { skipped.folder_exists += 1; continue; }
|
|
407
|
+
if (!eventRoots.all(item.id).every(r => isJunk(judge(r.workspace_root)))) { skipped.held += 1; continue; }
|
|
408
|
+
|
|
409
|
+
const gone = Number(delEvents.run(item.id).changes);
|
|
410
|
+
if (item.kind === 'stray') {
|
|
411
|
+
removed.stray_history_rows += gone;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
delTask.run(item.id);
|
|
415
|
+
removed.tasks += 1;
|
|
416
|
+
removed.history_rows += gone;
|
|
417
|
+
if (delUses && item.uses) removed.part_uses += Number(delUses.run(item.id).changes);
|
|
418
|
+
}
|
|
419
|
+
db.exec('COMMIT');
|
|
420
|
+
batches += 1;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
try { db.exec('ROLLBACK'); } catch { /* already rolled back */ }
|
|
423
|
+
throw err;
|
|
424
|
+
}
|
|
425
|
+
if (c + 1 < chunks.length) sleepSync(BATCH_PAUSE_MS);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let vacuumed = false;
|
|
429
|
+
if (vacuum) {
|
|
430
|
+
withBusyRetry(() => db.exec('VACUUM'));
|
|
431
|
+
withBusyRetry(() => db.exec('PRAGMA wal_checkpoint(TRUNCATE)'));
|
|
432
|
+
vacuumed = true;
|
|
433
|
+
}
|
|
434
|
+
const { rows, ...planSummary } = plan;
|
|
435
|
+
return {
|
|
436
|
+
db_path: dbPath,
|
|
437
|
+
backup_path: backupPath,
|
|
438
|
+
backup_tasks: backupCount,
|
|
439
|
+
bytes_before: bytesBefore,
|
|
440
|
+
bytes_after: dbBytes(dbPath),
|
|
441
|
+
total_tasks: countTasks(db),
|
|
442
|
+
groups: planSummary.groups,
|
|
443
|
+
planned: planSummary.remove,
|
|
444
|
+
removed,
|
|
445
|
+
skipped,
|
|
446
|
+
kept: planSummary.kept,
|
|
447
|
+
batches,
|
|
448
|
+
vacuumed,
|
|
449
|
+
};
|
|
450
|
+
} finally {
|
|
451
|
+
db.close();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
module.exports = {
|
|
456
|
+
EVENT_SQL,
|
|
457
|
+
tempRoots,
|
|
458
|
+
tempPrefixFor,
|
|
459
|
+
folderGone,
|
|
460
|
+
dryRun,
|
|
461
|
+
applyPrune,
|
|
462
|
+
};
|