@logicseek/hello-cc 0.1.8 → 1.0.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/CHANGELOG.md +117 -0
- package/README.md +26 -4
- package/README.zh-CN.md +22 -3
- package/bin/hcc.mjs +3 -5622
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +16 -5
- package/docs/commands.zh-CN.md +16 -5
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +53 -5
- package/docs/guide.zh-CN.md +46 -3
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +15 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +139 -15
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +61 -8
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +232 -29
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +143 -7
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5646 -262
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { readTx, tx } from '../../db/schema.mjs';
|
|
4
|
+
import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
|
|
5
|
+
|
|
6
|
+
export const HISTORY_GC_BATCH_SIZE = 256;
|
|
7
|
+
|
|
8
|
+
const HISTORY_CATEGORIES = Object.freeze(['events', 'tasks', 'messages', 'handoffs']);
|
|
9
|
+
const PLAN_CHANGED = Symbol('gc-plan-changed');
|
|
10
|
+
|
|
11
|
+
const CURRENT_TMUX_AUTHORITIES = `
|
|
12
|
+
SELECT MAX(e.id)
|
|
13
|
+
FROM events e
|
|
14
|
+
JOIN peer_bindings b
|
|
15
|
+
ON b.peer = json_extract(e.payload, '$.target_peer')
|
|
16
|
+
WHERE e.type = 'tmux.session.attached'
|
|
17
|
+
AND b.transport = 'tmux'
|
|
18
|
+
AND (b.runtime_target IS NULL OR b.runtime_target = json_extract(e.payload, '$.pane'))
|
|
19
|
+
GROUP BY b.peer, b.runtime_target
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
function rowById(db, table, column, value) {
|
|
23
|
+
return db.prepare(`SELECT * FROM ${table} WHERE ${column} = ?`).get(value) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function deleteExactRow(db, table, row) {
|
|
27
|
+
const columns = Object.keys(row);
|
|
28
|
+
const predicate = columns.map((column) => `${column} IS ?`).join(' AND ');
|
|
29
|
+
return db.prepare(`DELETE FROM ${table} WHERE ${predicate}`)
|
|
30
|
+
.run(...columns.map((column) => row[column])).changes === 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function captureGcLockSubject(db, resource) {
|
|
34
|
+
const lock = rowById(db, 'locks', 'resource', resource);
|
|
35
|
+
if (!lock) return { lock: null, peer: null, binding: null, authority: null };
|
|
36
|
+
return {
|
|
37
|
+
lock,
|
|
38
|
+
peer: rowById(db, 'peers', 'id', lock.owner),
|
|
39
|
+
binding: rowById(db, 'peer_bindings', 'peer', lock.owner),
|
|
40
|
+
authority: lock.task_id === null || lock.task_id === undefined
|
|
41
|
+
? null
|
|
42
|
+
: rowById(db, 'tasks', 'id', lock.task_id)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function captureGcLockSubjects(db, observedAt) {
|
|
47
|
+
return db.prepare(`
|
|
48
|
+
SELECT resource FROM locks WHERE expires_at < ? ORDER BY resource ASC
|
|
49
|
+
`).all(observedAt).map(({ resource }) => captureGcLockSubject(db, resource));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function finalizeGcLockSubjects(db, subjects, evidenceByOwner, options = {}) {
|
|
53
|
+
const dryRun = Boolean(options.dryRun);
|
|
54
|
+
return tx(db, () => {
|
|
55
|
+
const result = { deleted: 0, deferred: 0, live: 0 };
|
|
56
|
+
if (!dryRun && options.beforeMutate?.({ subjects }) === false) {
|
|
57
|
+
return { ...result, deferred: subjects.length, blocked: true };
|
|
58
|
+
}
|
|
59
|
+
for (const planned of subjects) {
|
|
60
|
+
const owner = planned.lock?.owner;
|
|
61
|
+
const current = planned.lock
|
|
62
|
+
? captureGcLockSubject(db, planned.lock.resource)
|
|
63
|
+
: { lock: null, peer: null, binding: null, authority: null };
|
|
64
|
+
if (JSON.stringify(current) !== JSON.stringify(planned)) {
|
|
65
|
+
result.deferred += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (evidenceByOwner.get(owner)?.state === 'live') {
|
|
69
|
+
result.live += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (dryRun || deleteExactRow(db, 'locks', planned.lock)) result.deleted += 1;
|
|
73
|
+
else result.deferred += 1;
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeHistoryCategories(categories = HISTORY_CATEGORIES) {
|
|
80
|
+
const requested = new Set(categories);
|
|
81
|
+
for (const category of requested) {
|
|
82
|
+
if (!HISTORY_CATEGORIES.includes(category)) {
|
|
83
|
+
throw new RangeError(`Unknown history GC category: ${category}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return HISTORY_CATEGORIES.filter((category) => requested.has(category));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function boundedBatchSize(value) {
|
|
90
|
+
if (value === undefined) return HISTORY_GC_BATCH_SIZE;
|
|
91
|
+
const size = Number(value);
|
|
92
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
93
|
+
throw new RangeError('History GC batch size must be a positive safe integer');
|
|
94
|
+
}
|
|
95
|
+
return Math.min(size, HISTORY_GC_BATCH_SIZE);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function snapshotTableSql(snapshot) {
|
|
99
|
+
if (!snapshot || !/^history_gc_[a-f0-9]{24}$/.test(snapshot.tableName || '')) {
|
|
100
|
+
throw new RangeError('Invalid history GC snapshot');
|
|
101
|
+
}
|
|
102
|
+
return `temp."${snapshot.tableName}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function insertSnapshotCategory(db, snapshot, category) {
|
|
106
|
+
const table = snapshotTableSql(snapshot);
|
|
107
|
+
if (category === 'events') {
|
|
108
|
+
db.prepare(`
|
|
109
|
+
INSERT INTO ${table}(category, id)
|
|
110
|
+
SELECT 'events', id FROM events
|
|
111
|
+
WHERE created_at < ? AND id NOT IN (${CURRENT_TMUX_AUTHORITIES})
|
|
112
|
+
`).run(snapshot.cutoff);
|
|
113
|
+
} else if (category === 'tasks') {
|
|
114
|
+
db.prepare(`
|
|
115
|
+
INSERT INTO ${table}(category, id)
|
|
116
|
+
SELECT 'tasks', id FROM tasks
|
|
117
|
+
WHERE status IN ('done', 'abandoned') AND updated_at < ?
|
|
118
|
+
`).run(snapshot.cutoff);
|
|
119
|
+
} else if (category === 'messages') {
|
|
120
|
+
db.prepare(`
|
|
121
|
+
INSERT INTO ${table}(category, id)
|
|
122
|
+
SELECT 'messages', id FROM messages WHERE created_at < ?
|
|
123
|
+
`).run(snapshot.cutoff);
|
|
124
|
+
} else if (category === 'handoffs') {
|
|
125
|
+
db.prepare(`
|
|
126
|
+
INSERT INTO ${table}(category, id)
|
|
127
|
+
SELECT 'handoffs', h.id
|
|
128
|
+
FROM handoffs h
|
|
129
|
+
LEFT JOIN tasks t ON t.id = h.task_id
|
|
130
|
+
WHERE h.created_at < ?
|
|
131
|
+
AND (h.task_id IS NULL OR t.id IS NULL OR t.status IN ('done', 'abandoned'))
|
|
132
|
+
`).run(snapshot.cutoff);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function snapshotTimestamp(db, snapshot, category) {
|
|
137
|
+
const table = snapshotTableSql(snapshot);
|
|
138
|
+
let row;
|
|
139
|
+
if (category === 'events') {
|
|
140
|
+
row = db.prepare(`
|
|
141
|
+
SELECT MAX(e.created_at) AS timestamp
|
|
142
|
+
FROM ${table} s JOIN events e ON e.id = s.id
|
|
143
|
+
WHERE s.category = 'events'
|
|
144
|
+
`).get();
|
|
145
|
+
} else if (category === 'tasks') {
|
|
146
|
+
row = db.prepare(`
|
|
147
|
+
SELECT MAX(t.updated_at) AS timestamp
|
|
148
|
+
FROM ${table} s JOIN tasks t ON t.id = s.id
|
|
149
|
+
WHERE s.category = 'tasks'
|
|
150
|
+
`).get();
|
|
151
|
+
} else if (category === 'messages') {
|
|
152
|
+
row = db.prepare(`
|
|
153
|
+
SELECT MAX(m.created_at) AS timestamp
|
|
154
|
+
FROM ${table} s JOIN messages m ON m.id = s.id
|
|
155
|
+
WHERE s.category = 'messages'
|
|
156
|
+
`).get();
|
|
157
|
+
} else if (category === 'handoffs') {
|
|
158
|
+
row = db.prepare(`
|
|
159
|
+
SELECT MAX(h.created_at) AS timestamp
|
|
160
|
+
FROM ${table} s JOIN handoffs h ON h.id = s.id
|
|
161
|
+
WHERE s.category = 'handoffs'
|
|
162
|
+
`).get();
|
|
163
|
+
}
|
|
164
|
+
return row?.timestamp === null || row?.timestamp === undefined
|
|
165
|
+
? null
|
|
166
|
+
: Number(row.timestamp);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function snapshotProtectedCount(db, snapshot, category) {
|
|
170
|
+
if (category !== 'handoffs') return 0;
|
|
171
|
+
return Number(db.prepare(`
|
|
172
|
+
SELECT COUNT(*) AS count
|
|
173
|
+
FROM handoffs h
|
|
174
|
+
JOIN tasks t ON t.id = h.task_id
|
|
175
|
+
WHERE h.created_at < ?
|
|
176
|
+
AND t.status NOT IN ('done', 'abandoned')
|
|
177
|
+
`).get(snapshot.cutoff)?.count || 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function createHistoryGcSnapshot(db, cutoff, options = {}) {
|
|
181
|
+
const categories = normalizeHistoryCategories(options.categories);
|
|
182
|
+
const batchSize = boundedBatchSize(options.batchSize);
|
|
183
|
+
const retentionSec = Number(options.retentionSec || 0);
|
|
184
|
+
const snapshot = {
|
|
185
|
+
tableName: `history_gc_${randomBytes(12).toString('hex')}`,
|
|
186
|
+
cutoff,
|
|
187
|
+
categories,
|
|
188
|
+
batchSize,
|
|
189
|
+
eligibleCounts: {},
|
|
190
|
+
protectedCounts: {},
|
|
191
|
+
maxTimestamps: [],
|
|
192
|
+
gcCutoffs: []
|
|
193
|
+
};
|
|
194
|
+
const table = snapshotTableSql(snapshot);
|
|
195
|
+
try {
|
|
196
|
+
readTx(db, () => {
|
|
197
|
+
db.exec(`
|
|
198
|
+
CREATE TEMP TABLE ${table} (
|
|
199
|
+
category TEXT NOT NULL,
|
|
200
|
+
id INTEGER NOT NULL,
|
|
201
|
+
PRIMARY KEY (category, id)
|
|
202
|
+
) WITHOUT ROWID
|
|
203
|
+
`);
|
|
204
|
+
for (const category of categories) insertSnapshotCategory(db, snapshot, category);
|
|
205
|
+
const counts = new Map(db.prepare(`
|
|
206
|
+
SELECT category, COUNT(*) AS count FROM ${table} GROUP BY category
|
|
207
|
+
`).all().map((row) => [row.category, Number(row.count)]));
|
|
208
|
+
snapshot.eligibleCounts = Object.fromEntries(categories.map((category) => [
|
|
209
|
+
category,
|
|
210
|
+
counts.get(category) || 0
|
|
211
|
+
]));
|
|
212
|
+
snapshot.protectedCounts = Object.fromEntries(categories.map((category) => [
|
|
213
|
+
category,
|
|
214
|
+
snapshotProtectedCount(db, snapshot, category)
|
|
215
|
+
]));
|
|
216
|
+
snapshot.maxTimestamps = categories
|
|
217
|
+
.map((category) => snapshotTimestamp(db, snapshot, category))
|
|
218
|
+
.filter((value) => value !== null);
|
|
219
|
+
snapshot.gcCutoffs = snapshot.maxTimestamps.map((value) => value + retentionSec);
|
|
220
|
+
}, {
|
|
221
|
+
cleanupContext: `History GC snapshot read transaction for ${snapshot.tableName}`
|
|
222
|
+
});
|
|
223
|
+
return snapshot;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
cleanupHistoryGcSnapshot(db, snapshot, {
|
|
226
|
+
primaryError: error,
|
|
227
|
+
context: 'History GC snapshot creation'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function dropHistoryGcSnapshot(db, snapshot) {
|
|
233
|
+
db.exec(`DROP TABLE IF EXISTS ${snapshotTableSql(snapshot)}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cleanupHistoryGcSnapshot(db, snapshot, options = {}) {
|
|
237
|
+
const hasPrimaryError = Object.hasOwn(options, 'primaryError');
|
|
238
|
+
try {
|
|
239
|
+
dropHistoryGcSnapshot(db, snapshot);
|
|
240
|
+
} catch (cleanupError) {
|
|
241
|
+
if (!hasPrimaryError) throw cleanupError;
|
|
242
|
+
const snapshotName = /^history_gc_[a-f0-9]{24}$/.test(snapshot?.tableName || '')
|
|
243
|
+
? snapshot.tableName
|
|
244
|
+
: 'unknown snapshot';
|
|
245
|
+
throw aggregateCleanupFailure(
|
|
246
|
+
options.primaryError,
|
|
247
|
+
cleanupError,
|
|
248
|
+
`${options.context || 'History GC operation'} failed and cleanup of ${snapshotName} also failed`,
|
|
249
|
+
{
|
|
250
|
+
context: options.context || 'History GC operation',
|
|
251
|
+
snapshotName
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (hasPrimaryError) throw options.primaryError;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function runWithHistoryGcSnapshotCleanup(
|
|
259
|
+
db,
|
|
260
|
+
snapshot,
|
|
261
|
+
operation,
|
|
262
|
+
context = 'History GC operation'
|
|
263
|
+
) {
|
|
264
|
+
let result;
|
|
265
|
+
try {
|
|
266
|
+
result = operation(snapshot);
|
|
267
|
+
} catch (primaryError) {
|
|
268
|
+
cleanupHistoryGcSnapshot(db, snapshot, { primaryError, context });
|
|
269
|
+
}
|
|
270
|
+
cleanupHistoryGcSnapshot(db, snapshot);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function runWithHistoryGcSnapshotCleanupAsync(
|
|
275
|
+
db,
|
|
276
|
+
snapshot,
|
|
277
|
+
operation,
|
|
278
|
+
context = 'History GC operation'
|
|
279
|
+
) {
|
|
280
|
+
let result;
|
|
281
|
+
try {
|
|
282
|
+
result = await operation(snapshot);
|
|
283
|
+
} catch (primaryError) {
|
|
284
|
+
cleanupHistoryGcSnapshot(db, snapshot, { primaryError, context });
|
|
285
|
+
}
|
|
286
|
+
cleanupHistoryGcSnapshot(db, snapshot);
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function normalizedCounts(input, categories, label, maximums = null) {
|
|
291
|
+
return Object.fromEntries(categories.map((category) => {
|
|
292
|
+
const value = Number(input?.[category] || 0);
|
|
293
|
+
if (!Number.isSafeInteger(value) || value < 0 ||
|
|
294
|
+
(maximums && value > maximums[category])) {
|
|
295
|
+
throw new RangeError(`Invalid history GC ${label} for ${category}`);
|
|
296
|
+
}
|
|
297
|
+
return [category, value];
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function captureMessages(db, snapshot, afterId, limit) {
|
|
302
|
+
const table = snapshotTableSql(snapshot);
|
|
303
|
+
return db.prepare(`
|
|
304
|
+
SELECT m.*
|
|
305
|
+
FROM ${table} s
|
|
306
|
+
JOIN messages m ON m.id = s.id
|
|
307
|
+
WHERE s.category = 'messages' AND s.id > ? AND m.created_at < ?
|
|
308
|
+
ORDER BY s.id ASC
|
|
309
|
+
LIMIT ?
|
|
310
|
+
`)
|
|
311
|
+
.all(afterId, snapshot.cutoff, limit)
|
|
312
|
+
.map((message) => ({
|
|
313
|
+
message,
|
|
314
|
+
reads: db.prepare(`
|
|
315
|
+
SELECT * FROM message_reads WHERE message_id = ? ORDER BY peer ASC
|
|
316
|
+
`).all(message.id)
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function captureHandoffs(db, snapshot, afterId, limit) {
|
|
321
|
+
const table = snapshotTableSql(snapshot);
|
|
322
|
+
return db.prepare(`
|
|
323
|
+
SELECT h.id
|
|
324
|
+
FROM ${table} s
|
|
325
|
+
JOIN handoffs h ON h.id = s.id
|
|
326
|
+
LEFT JOIN tasks t ON t.id = h.task_id
|
|
327
|
+
WHERE s.category = 'handoffs'
|
|
328
|
+
AND s.id > ?
|
|
329
|
+
AND h.created_at < ?
|
|
330
|
+
AND (h.task_id IS NULL OR t.id IS NULL OR t.status IN ('done', 'abandoned'))
|
|
331
|
+
ORDER BY s.id ASC
|
|
332
|
+
LIMIT ?
|
|
333
|
+
`).all(afterId, snapshot.cutoff, limit).map(({ id }) => {
|
|
334
|
+
const handoff = rowById(db, 'handoffs', 'id', id);
|
|
335
|
+
return {
|
|
336
|
+
handoff,
|
|
337
|
+
task: handoff.task_id === null || handoff.task_id === undefined
|
|
338
|
+
? null
|
|
339
|
+
: rowById(db, 'tasks', 'id', handoff.task_id)
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function captureHistoryGcPlan(db, cutoff, options = {}) {
|
|
345
|
+
const ownsSnapshot = !options.snapshot;
|
|
346
|
+
const snapshot = options.snapshot || createHistoryGcSnapshot(db, cutoff, options);
|
|
347
|
+
try {
|
|
348
|
+
return captureHistoryGcPlanFromSnapshot(db, cutoff, options, snapshot);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (!ownsSnapshot) throw error;
|
|
351
|
+
cleanupHistoryGcSnapshot(db, snapshot, {
|
|
352
|
+
primaryError: error,
|
|
353
|
+
context: 'History GC plan construction'
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function captureHistoryGcPlanFromSnapshot(db, cutoff, options, snapshot) {
|
|
359
|
+
const categories = snapshot.categories;
|
|
360
|
+
const batchSize = snapshot.batchSize;
|
|
361
|
+
if (Number(cutoff) !== Number(snapshot.cutoff)) {
|
|
362
|
+
throw new RangeError('History GC snapshot cutoff does not match the plan cutoff');
|
|
363
|
+
}
|
|
364
|
+
if (options.categories &&
|
|
365
|
+
JSON.stringify(normalizeHistoryCategories(options.categories)) !== JSON.stringify(categories)) {
|
|
366
|
+
throw new RangeError('History GC snapshot categories do not match the plan categories');
|
|
367
|
+
}
|
|
368
|
+
const after = Object.fromEntries(categories.map((category) => [
|
|
369
|
+
category,
|
|
370
|
+
Number(options.after?.[category] || 0)
|
|
371
|
+
]));
|
|
372
|
+
const eligibleCounts = snapshot.eligibleCounts;
|
|
373
|
+
const processedCounts = normalizedCounts(
|
|
374
|
+
options.processedCounts,
|
|
375
|
+
categories,
|
|
376
|
+
'processed count',
|
|
377
|
+
eligibleCounts
|
|
378
|
+
);
|
|
379
|
+
const remainingCounts = Object.fromEntries(categories.map((category) => [
|
|
380
|
+
category,
|
|
381
|
+
eligibleCounts[category] - processedCounts[category]
|
|
382
|
+
]));
|
|
383
|
+
const plan = {
|
|
384
|
+
cutoff,
|
|
385
|
+
categories,
|
|
386
|
+
batchSize,
|
|
387
|
+
snapshot,
|
|
388
|
+
after,
|
|
389
|
+
eligibleCounts,
|
|
390
|
+
processedCounts,
|
|
391
|
+
remainingCounts,
|
|
392
|
+
nextAfter: { ...after },
|
|
393
|
+
events: [],
|
|
394
|
+
tasks: [],
|
|
395
|
+
messages: [],
|
|
396
|
+
handoffs: []
|
|
397
|
+
};
|
|
398
|
+
const activeCategories = categories.filter((category) => remainingCounts[category] > 0);
|
|
399
|
+
const baseAllocation = activeCategories.length > 0
|
|
400
|
+
? Math.floor(batchSize / activeCategories.length)
|
|
401
|
+
: 0;
|
|
402
|
+
const extraAllocations = activeCategories.length > 0
|
|
403
|
+
? batchSize % activeCategories.length
|
|
404
|
+
: 0;
|
|
405
|
+
|
|
406
|
+
for (const [index, category] of activeCategories.entries()) {
|
|
407
|
+
const allocation = Math.min(
|
|
408
|
+
remainingCounts[category],
|
|
409
|
+
baseAllocation + (index < extraAllocations ? 1 : 0)
|
|
410
|
+
);
|
|
411
|
+
if (allocation === 0) continue;
|
|
412
|
+
if (category === 'events') {
|
|
413
|
+
const table = snapshotTableSql(snapshot);
|
|
414
|
+
plan.events = db.prepare(`
|
|
415
|
+
SELECT e.*
|
|
416
|
+
FROM ${table} s
|
|
417
|
+
JOIN events e ON e.id = s.id
|
|
418
|
+
WHERE s.category = 'events' AND s.id > ? AND e.created_at < ?
|
|
419
|
+
AND e.id NOT IN (${CURRENT_TMUX_AUTHORITIES})
|
|
420
|
+
ORDER BY s.id ASC
|
|
421
|
+
LIMIT ?
|
|
422
|
+
`).all(after.events, cutoff, allocation);
|
|
423
|
+
} else if (category === 'tasks') {
|
|
424
|
+
const table = snapshotTableSql(snapshot);
|
|
425
|
+
plan.tasks = db.prepare(`
|
|
426
|
+
SELECT t.*
|
|
427
|
+
FROM ${table} s
|
|
428
|
+
JOIN tasks t ON t.id = s.id
|
|
429
|
+
WHERE s.category = 'tasks' AND s.id > ?
|
|
430
|
+
AND t.status IN ('done', 'abandoned') AND t.updated_at < ?
|
|
431
|
+
ORDER BY s.id ASC
|
|
432
|
+
LIMIT ?
|
|
433
|
+
`).all(after.tasks, cutoff, allocation);
|
|
434
|
+
} else if (category === 'messages') {
|
|
435
|
+
plan.messages = captureMessages(db, snapshot, after.messages, allocation);
|
|
436
|
+
} else if (category === 'handoffs') {
|
|
437
|
+
plan.handoffs = captureHandoffs(db, snapshot, after.handoffs, allocation);
|
|
438
|
+
}
|
|
439
|
+
const rows = category === 'messages'
|
|
440
|
+
? plan.messages.map(({ message }) => message)
|
|
441
|
+
: category === 'handoffs'
|
|
442
|
+
? plan.handoffs.map(({ handoff }) => handoff)
|
|
443
|
+
: plan[category];
|
|
444
|
+
if (rows.length > 0) plan.nextAfter[category] = Number(rows.at(-1).id);
|
|
445
|
+
}
|
|
446
|
+
return plan;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function captureHistoryGcCutoffs(db, cutoff, retentionSec, options = {}) {
|
|
450
|
+
if (options.snapshot) {
|
|
451
|
+
return options.snapshot.maxTimestamps.map((value) => value + retentionSec);
|
|
452
|
+
}
|
|
453
|
+
const snapshot = createHistoryGcSnapshot(db, cutoff, { ...options, retentionSec });
|
|
454
|
+
return runWithHistoryGcSnapshotCleanup(
|
|
455
|
+
db,
|
|
456
|
+
snapshot,
|
|
457
|
+
() => [...snapshot.gcCutoffs],
|
|
458
|
+
'History GC cutoff capture'
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function selectedPlan(plan, categories) {
|
|
463
|
+
return Object.fromEntries(categories.map((category) => [category, plan[category]]));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function planCounts(plan, categories) {
|
|
467
|
+
return {
|
|
468
|
+
old_events: categories.includes('events') ? (plan.events?.length || 0) : 0,
|
|
469
|
+
old_tasks: categories.includes('tasks') ? (plan.tasks?.length || 0) : 0,
|
|
470
|
+
old_messages: categories.includes('messages') ? (plan.messages?.length || 0) : 0,
|
|
471
|
+
old_handoffs: categories.includes('handoffs') ? (plan.handoffs?.length || 0) : 0
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function finalizeHistoryGcPlan(db, plan, options = {}) {
|
|
476
|
+
const categories = normalizeHistoryCategories(options.categories || plan.categories);
|
|
477
|
+
const counts = planCounts(plan, categories);
|
|
478
|
+
const plannedTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
479
|
+
const deferredRemainder = plan.remainingCounts
|
|
480
|
+
? categories.reduce((sum, category) => sum + Number(plan.remainingCounts[category]), 0)
|
|
481
|
+
: plannedTotal;
|
|
482
|
+
try {
|
|
483
|
+
return tx(db, () => {
|
|
484
|
+
if (!options.dryRun && options.beforeMutate?.({ plan, categories }) === false) {
|
|
485
|
+
return {
|
|
486
|
+
applied: false,
|
|
487
|
+
blocked: true,
|
|
488
|
+
...planCounts({ events: [], tasks: [], messages: [], handoffs: [] }, categories),
|
|
489
|
+
deferred: deferredRemainder
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const current = captureHistoryGcPlan(db, plan.cutoff, {
|
|
493
|
+
snapshot: plan.snapshot,
|
|
494
|
+
categories,
|
|
495
|
+
after: plan.after,
|
|
496
|
+
processedCounts: plan.processedCounts,
|
|
497
|
+
batchSize: plan.batchSize
|
|
498
|
+
});
|
|
499
|
+
if (JSON.stringify(selectedPlan(current, categories)) !==
|
|
500
|
+
JSON.stringify(selectedPlan(plan, categories))) {
|
|
501
|
+
return { applied: false, ...planCounts({ events: [], tasks: [], messages: [], handoffs: [] }, categories), deferred: deferredRemainder };
|
|
502
|
+
}
|
|
503
|
+
if (!options.dryRun) {
|
|
504
|
+
for (const event of categories.includes('events') ? plan.events : []) {
|
|
505
|
+
if (!deleteExactRow(db, 'events', event)) throw PLAN_CHANGED;
|
|
506
|
+
}
|
|
507
|
+
for (const task of categories.includes('tasks') ? plan.tasks : []) {
|
|
508
|
+
if (!deleteExactRow(db, 'tasks', task)) throw PLAN_CHANGED;
|
|
509
|
+
}
|
|
510
|
+
for (const { message } of categories.includes('messages') ? plan.messages : []) {
|
|
511
|
+
if (!deleteExactRow(db, 'messages', message)) throw PLAN_CHANGED;
|
|
512
|
+
}
|
|
513
|
+
for (const { handoff } of categories.includes('handoffs') ? plan.handoffs : []) {
|
|
514
|
+
if (!deleteExactRow(db, 'handoffs', handoff)) throw PLAN_CHANGED;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return { applied: true, ...counts, deferred: 0 };
|
|
518
|
+
});
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (error !== PLAN_CHANGED) throw error;
|
|
521
|
+
return {
|
|
522
|
+
applied: false,
|
|
523
|
+
old_events: 0,
|
|
524
|
+
old_tasks: 0,
|
|
525
|
+
old_messages: 0,
|
|
526
|
+
old_handoffs: 0,
|
|
527
|
+
deferred: deferredRemainder
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function historyPlanCount(plan) {
|
|
533
|
+
return plan.events.length + plan.tasks.length + plan.messages.length + plan.handoffs.length;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function processedCountsFromResult(result) {
|
|
537
|
+
return {
|
|
538
|
+
events: result.old_events,
|
|
539
|
+
tasks: result.old_tasks,
|
|
540
|
+
messages: result.old_messages,
|
|
541
|
+
handoffs: result.old_handoffs
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function finalizeHistoryGcBatches(db, initialPlan, options = {}) {
|
|
546
|
+
const alreadyProcessed = initialPlan.processedCounts || {};
|
|
547
|
+
const totals = {
|
|
548
|
+
old_events: Number(alreadyProcessed.events || 0),
|
|
549
|
+
old_tasks: Number(alreadyProcessed.tasks || 0),
|
|
550
|
+
old_messages: Number(alreadyProcessed.messages || 0),
|
|
551
|
+
old_handoffs: Number(alreadyProcessed.handoffs || 0),
|
|
552
|
+
deferred: 0
|
|
553
|
+
};
|
|
554
|
+
const eligibleCounts = initialPlan.eligibleCounts;
|
|
555
|
+
const processBatches = () => {
|
|
556
|
+
let plan = initialPlan;
|
|
557
|
+
while (historyPlanCount(plan) > 0) {
|
|
558
|
+
const result = finalizeHistoryGcPlan(db, plan, {
|
|
559
|
+
dryRun: options.dryRun,
|
|
560
|
+
beforeMutate: options.beforeMutate
|
|
561
|
+
});
|
|
562
|
+
totals.old_events += result.old_events;
|
|
563
|
+
totals.old_tasks += result.old_tasks;
|
|
564
|
+
totals.old_messages += result.old_messages;
|
|
565
|
+
totals.old_handoffs += result.old_handoffs;
|
|
566
|
+
totals.deferred += result.deferred;
|
|
567
|
+
if (!result.applied) break;
|
|
568
|
+
options.afterBatch?.({ plan, result, totals: { ...totals } });
|
|
569
|
+
|
|
570
|
+
plan = captureHistoryGcPlan(db, plan.cutoff, {
|
|
571
|
+
snapshot: plan.snapshot,
|
|
572
|
+
categories: plan.categories,
|
|
573
|
+
after: plan.nextAfter,
|
|
574
|
+
processedCounts: processedCountsFromResult(totals),
|
|
575
|
+
batchSize: plan.batchSize
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
const processedCounts = processedCountsFromResult(totals);
|
|
579
|
+
const snapshotRemainder = initialPlan.categories.reduce(
|
|
580
|
+
(sum, category) => sum + eligibleCounts[category] - processedCounts[category],
|
|
581
|
+
0
|
|
582
|
+
);
|
|
583
|
+
totals.deferred = Math.max(totals.deferred, snapshotRemainder);
|
|
584
|
+
return totals;
|
|
585
|
+
};
|
|
586
|
+
if (options.dropSnapshot === false) return processBatches();
|
|
587
|
+
return runWithHistoryGcSnapshotCleanup(
|
|
588
|
+
db,
|
|
589
|
+
initialPlan.snapshot,
|
|
590
|
+
processBatches,
|
|
591
|
+
'History GC batch processing'
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function historyGcCutoffs(plan, retentionSec) {
|
|
596
|
+
const cutoffs = [];
|
|
597
|
+
const categories = normalizeHistoryCategories(plan.categories);
|
|
598
|
+
for (const category of categories) {
|
|
599
|
+
const entries = plan[category] || [];
|
|
600
|
+
const timestamp = category === 'tasks' ? 'updated_at' : 'created_at';
|
|
601
|
+
let maximum = null;
|
|
602
|
+
for (const entry of entries) {
|
|
603
|
+
const row = category === 'messages'
|
|
604
|
+
? entry.message
|
|
605
|
+
: category === 'handoffs'
|
|
606
|
+
? entry.handoff
|
|
607
|
+
: entry;
|
|
608
|
+
const value = Number(row[timestamp]);
|
|
609
|
+
if (maximum === null || value > maximum) maximum = value;
|
|
610
|
+
}
|
|
611
|
+
if (maximum !== null) cutoffs.push(maximum + retentionSec);
|
|
612
|
+
}
|
|
613
|
+
return cutoffs;
|
|
614
|
+
}
|