@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,838 @@
|
|
|
1
|
+
// Scan + tmux commands extracted from bin/hcc.mjs.
|
|
2
|
+
// hcc scan detects provider sessions; the tmux helpers implement DB-proven,
|
|
3
|
+
// evidence-checked tmux GC (plan/validate/execute) for hcc-managed sessions.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
9
|
+
import { tx } from '../../db/schema.mjs';
|
|
10
|
+
import { intOpt, parseOpts, validateOpts, wantsHelp } from '../../cli-args.mjs';
|
|
11
|
+
import { printResult, table } from '../../format.mjs';
|
|
12
|
+
import { readRuntime } from '../../runtime/state.mjs';
|
|
13
|
+
import { runtimeRequest } from '../../runtime/client.mjs';
|
|
14
|
+
import { registerProjectActivity } from '../../runtime/projects.mjs';
|
|
15
|
+
import { detectBranch } from '../../project-context.mjs';
|
|
16
|
+
import { bindingFromDetected } from '../../core/peers/bindings.mjs';
|
|
17
|
+
import { resolvePeerEvidence } from '../../core/peers/evidence.mjs';
|
|
18
|
+
import {
|
|
19
|
+
conditionalTmuxKill,
|
|
20
|
+
finalizeTmuxGcBindingMutation,
|
|
21
|
+
validateTmuxDestructiveEvidence,
|
|
22
|
+
validateTmuxGcBindingEvidence,
|
|
23
|
+
validateTmuxGcDeadProcessEvidence
|
|
24
|
+
} from '../../core/peers/tmux-safety.mjs';
|
|
25
|
+
import { inspectProcessIdentity } from '../../process/identity.mjs';
|
|
26
|
+
import {
|
|
27
|
+
ensureTmuxAvailable,
|
|
28
|
+
runTmux,
|
|
29
|
+
tmuxManagedSessionName,
|
|
30
|
+
tmuxPaneInfo,
|
|
31
|
+
tmuxSessionEnvironmentValue
|
|
32
|
+
} from '../../tmux.mjs';
|
|
33
|
+
import { resolveCurrentPeer } from '../../integrations/peers/identity.mjs';
|
|
34
|
+
|
|
35
|
+
export function createTmuxCommands(deps) {
|
|
36
|
+
const {
|
|
37
|
+
connect, now, addEvent, auditPayload,
|
|
38
|
+
upsertPeer, upsertCanonicalPeerBinding,
|
|
39
|
+
loadDiscover, sameResolvedPath,
|
|
40
|
+
helpTmux,
|
|
41
|
+
canonicalRoot, rootEvidence, processEvidenceFromRow,
|
|
42
|
+
inspectTmuxTarget, isProjectManagedTmuxSession
|
|
43
|
+
} = deps;
|
|
44
|
+
|
|
45
|
+
async function cmdScan(ctx, args) {
|
|
46
|
+
const opts = parseOpts(args, { booleans: ['register'] });
|
|
47
|
+
const { scanClaudeSessions, scanCodexSessions, scanProcesses } = await loadDiscover();
|
|
48
|
+
|
|
49
|
+
const found = [
|
|
50
|
+
...scanClaudeSessions(),
|
|
51
|
+
...scanCodexSessions(),
|
|
52
|
+
...scanProcesses(),
|
|
53
|
+
].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
|
|
54
|
+
|
|
55
|
+
// Deduplicate by peerId
|
|
56
|
+
const byId = new Map();
|
|
57
|
+
for (const s of found) {
|
|
58
|
+
if (!byId.has(s.peerId)) byId.set(s.peerId, s);
|
|
59
|
+
}
|
|
60
|
+
const results = [...byId.values()];
|
|
61
|
+
|
|
62
|
+
if (opts.register && results.length) {
|
|
63
|
+
registerProjectActivity(ctx);
|
|
64
|
+
const db = connect(ctx);
|
|
65
|
+
try {
|
|
66
|
+
for (const s of results) {
|
|
67
|
+
upsertPeer(db, {
|
|
68
|
+
id: s.peerId, kind: s.kind, role: 'peer',
|
|
69
|
+
worktree: s.cwd,
|
|
70
|
+
branch: detectBranch(s.cwd),
|
|
71
|
+
pid: s.pid,
|
|
72
|
+
status: s.status || 'running',
|
|
73
|
+
capabilities: 'detected'
|
|
74
|
+
});
|
|
75
|
+
upsertCanonicalPeerBinding(db, bindingFromDetected(s, s.transport || 'detected'), true);
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
printResult(ctx, results, (rows) => {
|
|
83
|
+
if (!rows.length) return 'no active sessions found in this project';
|
|
84
|
+
return table(rows, [
|
|
85
|
+
{ label: 'peer', value: (r) => r.peerId },
|
|
86
|
+
{ label: 'kind', value: (r) => r.kind },
|
|
87
|
+
{ label: 'pid', value: (r) => r.pid || '' },
|
|
88
|
+
{ label: 'cwd', value: (r) => r.cwd },
|
|
89
|
+
{ label: 'session', value: (r) => (r.sessionId || '').slice(0, 16) },
|
|
90
|
+
{ label: 'transport', value: (r) => r.transport },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── hcc tmux ────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function tmuxSessionNameForTarget(target) {
|
|
98
|
+
if (!target) return null;
|
|
99
|
+
try {
|
|
100
|
+
return runTmux(['display-message', '-p', '-t', target, '#{session_name}']).trim() || null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tmuxSessionCreationToken(target) {
|
|
107
|
+
if (!target) return null;
|
|
108
|
+
try {
|
|
109
|
+
return runTmux(['display-message', '-p', '-t', target, '#{session_created}']).trim() || null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function tmuxSessionId(target) {
|
|
116
|
+
if (!target) return null;
|
|
117
|
+
try {
|
|
118
|
+
return runTmux(['display-message', '-p', '-t', target, '#{session_id}']).trim() || null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function tmuxPaneForTarget(target) {
|
|
125
|
+
if (!target) return null;
|
|
126
|
+
try {
|
|
127
|
+
return runTmux(['display-message', '-p', '-t', target, '#{pane_id}']).trim() || null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function managedRuntimeSessions(ctx) {
|
|
134
|
+
try {
|
|
135
|
+
const runtime = readRuntime(ctx);
|
|
136
|
+
const data = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
|
|
137
|
+
return data.sessions || [];
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseJsonObject(value) {
|
|
144
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
145
|
+
if (typeof value !== 'string' || !value) return null;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(value);
|
|
148
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function strictTmuxClientObservation(sessionName) {
|
|
155
|
+
if (!sessionName) return { state: 'unknown', count: null };
|
|
156
|
+
try {
|
|
157
|
+
const output = runTmux(['list-clients', '-t', sessionName, '-F', '#{client_tty}']);
|
|
158
|
+
return { state: 'known', count: output.trim().split('\n').filter(Boolean).length };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return { state: 'unknown', count: null, error: error?.message || String(error) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function tmuxAttachmentAuthority(db, peerId, runtimeTarget) {
|
|
165
|
+
const row = db.prepare(`
|
|
166
|
+
SELECT payload
|
|
167
|
+
FROM events
|
|
168
|
+
WHERE type = 'tmux.session.attached'
|
|
169
|
+
AND json_extract(payload, '$.target_peer') = ?
|
|
170
|
+
AND json_extract(payload, '$.pane') = ?
|
|
171
|
+
ORDER BY id DESC
|
|
172
|
+
LIMIT 1
|
|
173
|
+
`).get(peerId, runtimeTarget);
|
|
174
|
+
const payload = parseJsonObject(row?.payload) || {};
|
|
175
|
+
return {
|
|
176
|
+
session: payload.tmux_session || null,
|
|
177
|
+
session_created: payload.tmux_session_created || null,
|
|
178
|
+
session_id: payload.tmux_session_id || null,
|
|
179
|
+
root: payload.hcc_root || null,
|
|
180
|
+
pane: payload.pane || null,
|
|
181
|
+
process_identity: payload.process_identity || null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function tmuxGcBindingSubject(ctx, row) {
|
|
186
|
+
const authority = row.authority || {};
|
|
187
|
+
return {
|
|
188
|
+
peer: row.peer || row.id || null,
|
|
189
|
+
status: row.status || null,
|
|
190
|
+
pid: row.pid || null,
|
|
191
|
+
pid_start_token: row.pid_start_token || null,
|
|
192
|
+
pid_command_hash: row.pid_command_hash || null,
|
|
193
|
+
last_seen_at: row.last_seen_at || null,
|
|
194
|
+
transport: row.transport || null,
|
|
195
|
+
runtime_target: row.runtime_target || null,
|
|
196
|
+
runtime_session_id: row.runtime_session_id || null,
|
|
197
|
+
updated_at: row.updated_at || null,
|
|
198
|
+
expected_session: tmuxManagedSessionName(ctx, row.peer || row.id),
|
|
199
|
+
expected_root: ctx.root || null,
|
|
200
|
+
authority: {
|
|
201
|
+
session: authority.session || null,
|
|
202
|
+
session_created: authority.session_created || null,
|
|
203
|
+
session_id: authority.session_id || null,
|
|
204
|
+
root: authority.root || null,
|
|
205
|
+
pane: authority.pane || null,
|
|
206
|
+
process_identity: authority.process_identity || null
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function tmuxGcBindingSubjectFromDb(db, ctx, peerId) {
|
|
212
|
+
const row = db.prepare(`
|
|
213
|
+
SELECT p.id AS peer, p.status, p.pid, p.pid_start_token,
|
|
214
|
+
p.pid_command_hash, p.last_seen_at,
|
|
215
|
+
b.transport, b.runtime_target, b.runtime_session_id, b.updated_at
|
|
216
|
+
FROM peers p
|
|
217
|
+
JOIN peer_bindings b ON b.peer = p.id
|
|
218
|
+
WHERE p.id = ?
|
|
219
|
+
`).get(peerId);
|
|
220
|
+
if (!row) return null;
|
|
221
|
+
return tmuxGcBindingSubject(ctx, {
|
|
222
|
+
...row,
|
|
223
|
+
authority: tmuxAttachmentAuthority(db, peerId, row.runtime_target)
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sameTmuxGcBindingSubject(planned, current) {
|
|
228
|
+
return Boolean(planned && current) && JSON.stringify(planned) === JSON.stringify(current);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const TMUX_GC_CONDITIONAL_TIMEOUT_MS = 5000;
|
|
232
|
+
|
|
233
|
+
function runBoundedTmuxGcCommand(args) {
|
|
234
|
+
const result = spawnSync('tmux', args, {
|
|
235
|
+
encoding: 'utf8',
|
|
236
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
237
|
+
input: '',
|
|
238
|
+
timeout: TMUX_GC_CONDITIONAL_TIMEOUT_MS,
|
|
239
|
+
killSignal: 'SIGKILL'
|
|
240
|
+
});
|
|
241
|
+
if (result.error) {
|
|
242
|
+
const timedOut = result.error.code === 'ETIMEDOUT';
|
|
243
|
+
throw new CliError(
|
|
244
|
+
timedOut ? 'TMUX_CONDITIONAL_KILL_TIMEOUT' : 'TMUX_ERROR',
|
|
245
|
+
timedOut
|
|
246
|
+
? `tmux conditional kill exceeded ${TMUX_GC_CONDITIONAL_TIMEOUT_MS}ms`
|
|
247
|
+
: `tmux failed: ${result.error.message}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (result.status !== 0) {
|
|
251
|
+
const message = (result.stderr || result.stdout || '').trim() || `tmux ${args.join(' ')} failed`;
|
|
252
|
+
throw new CliError('TMUX_ERROR', message);
|
|
253
|
+
}
|
|
254
|
+
return result.stdout || '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function casDetachTmuxGcBinding(db, subject, timestamp) {
|
|
258
|
+
return db.prepare(`
|
|
259
|
+
UPDATE peer_bindings
|
|
260
|
+
SET transport = 'detached',
|
|
261
|
+
runtime_target = NULL,
|
|
262
|
+
updated_at = ?
|
|
263
|
+
WHERE peer = ?
|
|
264
|
+
AND transport = 'tmux'
|
|
265
|
+
AND runtime_target = ?
|
|
266
|
+
AND runtime_session_id IS ?
|
|
267
|
+
AND updated_at IS ?
|
|
268
|
+
`).run(
|
|
269
|
+
timestamp,
|
|
270
|
+
subject.peer,
|
|
271
|
+
subject.runtime_target,
|
|
272
|
+
subject.runtime_session_id,
|
|
273
|
+
subject.updated_at
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function casDetachTmuxGcPeer(db, subject, timestamp) {
|
|
278
|
+
const result = db.prepare(`
|
|
279
|
+
UPDATE peers
|
|
280
|
+
SET status = 'detached',
|
|
281
|
+
last_seen_at = ?
|
|
282
|
+
WHERE id = ?
|
|
283
|
+
AND status IS ?
|
|
284
|
+
AND pid IS ?
|
|
285
|
+
AND pid_start_token IS ?
|
|
286
|
+
AND pid_command_hash IS ?
|
|
287
|
+
AND last_seen_at IS ?
|
|
288
|
+
`).run(
|
|
289
|
+
timestamp,
|
|
290
|
+
subject.peer,
|
|
291
|
+
subject.status,
|
|
292
|
+
subject.pid,
|
|
293
|
+
subject.pid_start_token,
|
|
294
|
+
subject.pid_command_hash,
|
|
295
|
+
subject.last_seen_at
|
|
296
|
+
);
|
|
297
|
+
if (Number(result.changes) !== 1) {
|
|
298
|
+
throw new CliError('TMUX_GC_PEER_CAS_FAILED',
|
|
299
|
+
'tmux GC peer compare-and-swap did not update exactly one row');
|
|
300
|
+
}
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function observeTmuxConditionalTarget(runtimeTarget) {
|
|
305
|
+
let paneInfo = null;
|
|
306
|
+
try { paneInfo = tmuxPaneInfo(runtimeTarget); } catch {}
|
|
307
|
+
const pane = paneInfo?.pane || tmuxPaneForTarget(runtimeTarget);
|
|
308
|
+
const session = tmuxSessionNameForTarget(runtimeTarget);
|
|
309
|
+
const processInspection = inspectProcessIdentity(paneInfo?.pid);
|
|
310
|
+
return {
|
|
311
|
+
session,
|
|
312
|
+
session_created: tmuxSessionCreationToken(session),
|
|
313
|
+
session_id: tmuxSessionId(session),
|
|
314
|
+
root: canonicalRoot(tmuxSessionEnvironmentValue(session, 'HCC_ROOT')),
|
|
315
|
+
pane,
|
|
316
|
+
pane_pid: paneInfo?.pid || null,
|
|
317
|
+
pane_dead: paneInfo?.dead === true,
|
|
318
|
+
process_identity: processInspection.state === 'live' ? processInspection.identity : null,
|
|
319
|
+
process_inspection: processInspection,
|
|
320
|
+
clients: strictTmuxClientObservation(session)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function rebindCandidateAuthority(candidate) {
|
|
325
|
+
return {
|
|
326
|
+
session: candidate.expected_session || candidate.session || null,
|
|
327
|
+
session_created: candidate.old_tmux_session_created || null,
|
|
328
|
+
session_id: candidate.old_tmux_session_id || null,
|
|
329
|
+
root: canonicalRoot(candidate.old_hcc_root),
|
|
330
|
+
pane: candidate.old_pane || null,
|
|
331
|
+
process_identity: parseJsonObject(candidate.old_process_identity)
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function validateTmuxGcDeadRebindEvidence(candidate) {
|
|
336
|
+
const stored = rebindCandidateAuthority(candidate);
|
|
337
|
+
const observed = observeTmuxConditionalTarget(candidate.runtime_target || candidate.session || '');
|
|
338
|
+
return {
|
|
339
|
+
...validateTmuxGcDeadProcessEvidence(stored, observed),
|
|
340
|
+
stored,
|
|
341
|
+
observed
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function validateTmuxGcBindingCandidate(subject) {
|
|
346
|
+
const observed = observeTmuxConditionalTarget(subject?.runtime_target || '');
|
|
347
|
+
const ownerEvidence = subject
|
|
348
|
+
? resolvePeerEvidence({
|
|
349
|
+
peer: subject,
|
|
350
|
+
processes: [processEvidenceFromRow(subject, 'owner')]
|
|
351
|
+
})
|
|
352
|
+
: { state: 'unknown', reason: 'peer_missing' };
|
|
353
|
+
const validationSubject = subject ? {
|
|
354
|
+
...subject,
|
|
355
|
+
owner_evidence: ownerEvidence,
|
|
356
|
+
expected_root: canonicalRoot(subject.expected_root),
|
|
357
|
+
authority: subject.authority ? {
|
|
358
|
+
...subject.authority,
|
|
359
|
+
root: canonicalRoot(subject.authority.root)
|
|
360
|
+
} : null
|
|
361
|
+
} : subject;
|
|
362
|
+
return {
|
|
363
|
+
...validateTmuxGcBindingEvidence(validationSubject, observed),
|
|
364
|
+
stored: subject?.authority || null,
|
|
365
|
+
observed,
|
|
366
|
+
subject
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function observeRebindEventEvidence(ctx, candidate) {
|
|
371
|
+
const sessionName = candidate.expected_session || candidate.session || null;
|
|
372
|
+
const storedIdentity = parseJsonObject(candidate.old_process_identity);
|
|
373
|
+
const storedRoot = canonicalRoot(candidate.old_hcc_root);
|
|
374
|
+
const storedSessionCreated = candidate.old_tmux_session_created
|
|
375
|
+
? String(candidate.old_tmux_session_created)
|
|
376
|
+
: null;
|
|
377
|
+
const storedSessionId = candidate.old_tmux_session_id || null;
|
|
378
|
+
const storedPane = candidate.old_pane || null;
|
|
379
|
+
if (!sessionName || !candidate.runtime_target || !storedIdentity || !storedRoot ||
|
|
380
|
+
!storedSessionCreated || !storedSessionId || !storedPane) {
|
|
381
|
+
return { state: 'unknown', reason: 'tmux_event_evidence_incomplete' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const target = inspectTmuxTarget(sessionName, candidate.runtime_target);
|
|
385
|
+
const currentSessionCreated = target.session.actual
|
|
386
|
+
? tmuxSessionCreationToken(target.session.actual)
|
|
387
|
+
: null;
|
|
388
|
+
const currentSessionId = target.session.actual
|
|
389
|
+
? tmuxSessionId(target.session.actual)
|
|
390
|
+
: null;
|
|
391
|
+
const exactSession = target.session.state === 'live' &&
|
|
392
|
+
target.session.actual === sessionName &&
|
|
393
|
+
currentSessionCreated === storedSessionCreated &&
|
|
394
|
+
currentSessionId === storedSessionId;
|
|
395
|
+
const session = target.session.state === 'dead'
|
|
396
|
+
? { state: 'dead', expected: `${sessionName}:${storedSessionCreated}`, actual: null }
|
|
397
|
+
: {
|
|
398
|
+
state: exactSession ? 'live' : 'unknown',
|
|
399
|
+
expected: `${sessionName}:${storedSessionCreated}`,
|
|
400
|
+
actual: target.session.actual && currentSessionCreated
|
|
401
|
+
? `${target.session.actual}:${currentSessionCreated}`
|
|
402
|
+
: null
|
|
403
|
+
};
|
|
404
|
+
const pane = target.pane.actual && target.pane.actual !== storedPane
|
|
405
|
+
? { state: 'unknown', expected: storedPane, actual: target.pane.actual }
|
|
406
|
+
: { ...target.pane, expected: storedPane };
|
|
407
|
+
const actualRoot = target.session.actual
|
|
408
|
+
? canonicalRoot(tmuxSessionEnvironmentValue(target.session.actual, 'HCC_ROOT'))
|
|
409
|
+
: null;
|
|
410
|
+
const expectedRoot = canonicalRoot(ctx.root);
|
|
411
|
+
const root = !expectedRoot || !actualRoot
|
|
412
|
+
? { state: 'unknown', expected: expectedRoot, actual: actualRoot }
|
|
413
|
+
: {
|
|
414
|
+
state: storedRoot === expectedRoot && actualRoot === storedRoot ? 'match' : 'mismatch',
|
|
415
|
+
expected: storedRoot,
|
|
416
|
+
actual: actualRoot
|
|
417
|
+
};
|
|
418
|
+
return resolvePeerEvidence({
|
|
419
|
+
peer: { status: 'working' },
|
|
420
|
+
tmux: {
|
|
421
|
+
managed: true,
|
|
422
|
+
session,
|
|
423
|
+
pane,
|
|
424
|
+
root,
|
|
425
|
+
process: {
|
|
426
|
+
name: 'old-pane',
|
|
427
|
+
storedIdentity,
|
|
428
|
+
current: inspectProcessIdentity(target.paneInfo?.pid || storedIdentity.pid)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function planTmuxGc(ctx, opts) {
|
|
435
|
+
ensureTmuxAvailable({ autoInstall: false });
|
|
436
|
+
const olderThanDays = intOpt(opts, 'older-than', 14);
|
|
437
|
+
if (olderThanDays < 0) throw new CliError('BAD_ARGS', '--older-than must be zero or greater');
|
|
438
|
+
const targetPeer = opts.peer || null;
|
|
439
|
+
const cutoff = now() - olderThanDays * 86400;
|
|
440
|
+
const runtimeSessions = await managedRuntimeSessions(ctx);
|
|
441
|
+
const managedPanes = new Set(runtimeSessions.map((s) => s.pane).filter(Boolean));
|
|
442
|
+
const managedPeers = new Set();
|
|
443
|
+
for (const session of runtimeSessions) {
|
|
444
|
+
if (session.id) managedPeers.add(session.id);
|
|
445
|
+
if (session.peer_id) managedPeers.add(session.peer_id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const db = connect(ctx);
|
|
449
|
+
let rows = [];
|
|
450
|
+
let cleanupFailureRows = [];
|
|
451
|
+
try {
|
|
452
|
+
rows = db.prepare(`
|
|
453
|
+
SELECT p.id AS peer, p.kind, p.status, p.pid,
|
|
454
|
+
p.pid_start_token, p.pid_command_hash, p.last_seen_at,
|
|
455
|
+
b.provider, b.provider_session_id, b.provider_session_name,
|
|
456
|
+
b.resume_mode, b.resume_arg, b.command, b.transport,
|
|
457
|
+
b.runtime_session_id, b.runtime_target, b.updated_at
|
|
458
|
+
FROM peer_bindings b
|
|
459
|
+
JOIN peers p ON p.id = b.peer
|
|
460
|
+
WHERE b.transport = 'tmux'
|
|
461
|
+
AND b.runtime_target IS NOT NULL
|
|
462
|
+
ORDER BY b.updated_at ASC, p.last_seen_at ASC, p.id ASC
|
|
463
|
+
`).all();
|
|
464
|
+
rows = rows.map((row) => ({
|
|
465
|
+
...row,
|
|
466
|
+
authority: tmuxAttachmentAuthority(db, row.peer, row.runtime_target)
|
|
467
|
+
}));
|
|
468
|
+
cleanupFailureRows = db.prepare(`
|
|
469
|
+
SELECT actor AS peer, type, created_at,
|
|
470
|
+
json_extract(payload, '$.old_peer') AS old_peer,
|
|
471
|
+
json_extract(payload, '$.old_tmux_session') AS old_tmux_session,
|
|
472
|
+
json_extract(payload, '$.old_runtime_target') AS old_runtime_target,
|
|
473
|
+
json_extract(payload, '$.new_runtime_target') AS new_runtime_target,
|
|
474
|
+
json_extract(payload, '$.old_pane') AS old_pane,
|
|
475
|
+
json_extract(payload, '$.old_process_identity') AS old_process_identity,
|
|
476
|
+
json_extract(payload, '$.old_hcc_root') AS old_hcc_root,
|
|
477
|
+
json_extract(payload, '$.old_tmux_session_created') AS old_tmux_session_created,
|
|
478
|
+
json_extract(payload, '$.old_tmux_session_id') AS old_tmux_session_id,
|
|
479
|
+
json_extract(payload, '$.reason') AS cleanup_reason
|
|
480
|
+
FROM events
|
|
481
|
+
WHERE type IN ('tmux.session.rebind_cleanup_failed', 'tmux.session.rebind_cleanup_pending')
|
|
482
|
+
AND created_at < ?
|
|
483
|
+
ORDER BY created_at ASC, id ASC
|
|
484
|
+
`).all(cutoff);
|
|
485
|
+
} finally {
|
|
486
|
+
db.close();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const seenSessions = new Set();
|
|
490
|
+
const candidates = [];
|
|
491
|
+
const skipped = [];
|
|
492
|
+
for (const row of rows) {
|
|
493
|
+
if (targetPeer && row.peer !== targetPeer) continue;
|
|
494
|
+
const bindingSubject = tmuxGcBindingSubject(ctx, row);
|
|
495
|
+
const strictBinding = validateTmuxGcBindingCandidate(bindingSubject);
|
|
496
|
+
const expectedSession = tmuxManagedSessionName(ctx, row.peer);
|
|
497
|
+
const actualSession = tmuxSessionNameForTarget(row.runtime_target);
|
|
498
|
+
const actualPane = tmuxPaneForTarget(row.runtime_target);
|
|
499
|
+
const ageSeconds = Math.max(0, now() - Math.max(Number(row.last_seen_at || 0), Number(row.updated_at || 0)));
|
|
500
|
+
const base = {
|
|
501
|
+
peer: row.peer,
|
|
502
|
+
kind: row.kind || '',
|
|
503
|
+
provider: row.provider,
|
|
504
|
+
session: actualSession || expectedSession,
|
|
505
|
+
expected_session: expectedSession,
|
|
506
|
+
pane: actualPane || row.runtime_target,
|
|
507
|
+
runtime_target: row.runtime_target,
|
|
508
|
+
runtime_session_id: row.runtime_session_id || null,
|
|
509
|
+
last_seen_at: row.last_seen_at || null,
|
|
510
|
+
updated_at: row.updated_at || null,
|
|
511
|
+
age_days: Math.floor(ageSeconds / 86400),
|
|
512
|
+
authority: row.authority
|
|
513
|
+
};
|
|
514
|
+
const skip = (reason, extra = {}) => skipped.push({ ...base, reason, ...extra });
|
|
515
|
+
|
|
516
|
+
if (!strictBinding.ok) {
|
|
517
|
+
skip(strictBinding.reason, { evidence_state: 'unknown' });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!actualSession || !actualPane) {
|
|
522
|
+
skip('tmux_target_missing');
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (actualSession !== expectedSession) {
|
|
526
|
+
skip('not_hcc_managed_name');
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
|
|
530
|
+
if (hccRoot && path.resolve(hccRoot) !== path.resolve(ctx.root)) {
|
|
531
|
+
skip('hcc_root_mismatch', { hcc_root: hccRoot });
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (seenSessions.has(actualSession)) {
|
|
535
|
+
skip('duplicate_db_binding');
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
seenSessions.add(actualSession);
|
|
539
|
+
if (managedPanes.has(actualPane) || managedPanes.has(row.runtime_target) || managedPeers.has(row.peer) || managedPeers.has(row.runtime_session_id)) {
|
|
540
|
+
skip('runtime_managed');
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const clientCount = strictBinding.observed.clients.count;
|
|
544
|
+
if (Math.max(Number(row.last_seen_at || 0), Number(row.updated_at || 0)) >= cutoff) {
|
|
545
|
+
skip('not_old_enough');
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
candidates.push({
|
|
549
|
+
...base,
|
|
550
|
+
source: 'binding',
|
|
551
|
+
reason: 'stale_hcc_managed_session',
|
|
552
|
+
hcc_root: hccRoot || null,
|
|
553
|
+
client_count: clientCount,
|
|
554
|
+
evidence_state: 'dead',
|
|
555
|
+
evidence_reason: strictBinding.mode === 'dead_process'
|
|
556
|
+
? 'tmux_process_confirmed_dead'
|
|
557
|
+
: 'explicit_exited',
|
|
558
|
+
gc_validation_mode: strictBinding.mode,
|
|
559
|
+
gc_validation_subject: bindingSubject
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
for (const row of cleanupFailureRows) {
|
|
563
|
+
const rowPeer = row.old_peer || row.peer || '';
|
|
564
|
+
if (targetPeer && rowPeer !== targetPeer) continue;
|
|
565
|
+
const expectedSession = row.old_tmux_session || null;
|
|
566
|
+
const actualSession = tmuxSessionNameForTarget(row.old_runtime_target);
|
|
567
|
+
const actualPane = tmuxPaneForTarget(row.old_runtime_target);
|
|
568
|
+
const ageSeconds = Math.max(0, now() - Number(row.created_at || 0));
|
|
569
|
+
const base = {
|
|
570
|
+
peer: rowPeer,
|
|
571
|
+
kind: '',
|
|
572
|
+
provider: '',
|
|
573
|
+
session: actualSession || expectedSession || '',
|
|
574
|
+
expected_session: expectedSession || '',
|
|
575
|
+
pane: actualPane || row.old_runtime_target || '',
|
|
576
|
+
runtime_target: row.old_runtime_target || null,
|
|
577
|
+
last_seen_at: null,
|
|
578
|
+
updated_at: row.created_at || null,
|
|
579
|
+
age_days: Math.floor(ageSeconds / 86400),
|
|
580
|
+
cleanup_reason: row.cleanup_reason || null,
|
|
581
|
+
old_pane: row.old_pane || null,
|
|
582
|
+
old_process_identity: row.old_process_identity || null,
|
|
583
|
+
old_hcc_root: row.old_hcc_root || null,
|
|
584
|
+
old_tmux_session_created: row.old_tmux_session_created || null,
|
|
585
|
+
old_tmux_session_id: row.old_tmux_session_id || null
|
|
586
|
+
};
|
|
587
|
+
const skip = (reason, extra = {}) => skipped.push({ ...base, reason, ...extra });
|
|
588
|
+
|
|
589
|
+
const strictDead = validateTmuxGcDeadRebindEvidence(base);
|
|
590
|
+
if (!strictDead.ok) {
|
|
591
|
+
skip(strictDead.reason, { evidence_state: 'unknown' });
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!expectedSession || !actualSession || !actualPane) {
|
|
596
|
+
skip('tmux_target_missing');
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (actualSession !== expectedSession) {
|
|
600
|
+
skip('old_runtime_target_changed', { actual_session: actualSession });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const hccRoot = tmuxSessionEnvironmentValue(expectedSession, 'HCC_ROOT');
|
|
604
|
+
if (!isProjectManagedTmuxSession(ctx, expectedSession, hccRoot)) {
|
|
605
|
+
skip('not_hcc_managed_name');
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (rootEvidence(ctx.root, hccRoot).state !== 'match') {
|
|
609
|
+
skip('hcc_root_mismatch', { hcc_root: hccRoot });
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (seenSessions.has(expectedSession)) {
|
|
613
|
+
skip('duplicate_db_binding');
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
seenSessions.add(expectedSession);
|
|
617
|
+
if (managedPanes.has(actualPane) || managedPanes.has(row.old_runtime_target)) {
|
|
618
|
+
skip('runtime_managed');
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const clientCount = strictDead.observed.clients.count;
|
|
622
|
+
candidates.push({
|
|
623
|
+
...base,
|
|
624
|
+
source: row.type === 'tmux.session.rebind_cleanup_pending' ? 'rebind_cleanup_pending' : 'rebind_cleanup_failed',
|
|
625
|
+
reason: row.type === 'tmux.session.rebind_cleanup_pending'
|
|
626
|
+
? 'stale_rebind_cleanup_pending_session'
|
|
627
|
+
: 'stale_rebind_cleanup_failed_session',
|
|
628
|
+
session: expectedSession,
|
|
629
|
+
hcc_root: hccRoot || null,
|
|
630
|
+
evidence_state: 'dead',
|
|
631
|
+
evidence_reason: 'tmux_process_confirmed_dead',
|
|
632
|
+
client_count: clientCount
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return { older_than_days: olderThanDays, cutoff, peer: targetPeer, candidates, skipped };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function validateTmuxGcCandidate(ctx, candidate, runtimeSessions = [], options = {}) {
|
|
639
|
+
const target = candidate.runtime_target || candidate.session || '';
|
|
640
|
+
const actualSession = tmuxSessionNameForTarget(target);
|
|
641
|
+
const actualPane = tmuxPaneForTarget(target);
|
|
642
|
+
const skip = (reason, extra = {}) => ({ ok: false, reason, ...extra });
|
|
643
|
+
if (!candidate.session || !actualSession || !actualPane) return skip('tmux_target_missing');
|
|
644
|
+
if (actualSession !== candidate.session) {
|
|
645
|
+
return skip('tmux_target_changed', { session: actualSession, pane: actualPane });
|
|
646
|
+
}
|
|
647
|
+
if (candidate.source !== 'binding' && candidate.old_pane !== actualPane) {
|
|
648
|
+
return skip('tmux_pane_changed', { pane: actualPane });
|
|
649
|
+
}
|
|
650
|
+
const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
|
|
651
|
+
if (!isProjectManagedTmuxSession(ctx, actualSession, hccRoot)) return skip('not_hcc_managed_name');
|
|
652
|
+
if (rootEvidence(ctx.root, hccRoot).state !== 'match') {
|
|
653
|
+
return skip('hcc_root_mismatch', { hcc_root: hccRoot });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const managedPanes = new Set(runtimeSessions.map((s) => s.pane).filter(Boolean));
|
|
657
|
+
const managedPeers = new Set();
|
|
658
|
+
for (const session of runtimeSessions) {
|
|
659
|
+
if (session.id) managedPeers.add(session.id);
|
|
660
|
+
if (session.peer_id) managedPeers.add(session.peer_id);
|
|
661
|
+
}
|
|
662
|
+
if (managedPanes.has(actualPane) || managedPanes.has(candidate.runtime_target)) {
|
|
663
|
+
return skip('runtime_managed');
|
|
664
|
+
}
|
|
665
|
+
if (candidate.source === 'binding' && (managedPeers.has(candidate.peer) || managedPeers.has(candidate.runtime_session_id))) {
|
|
666
|
+
return skip('runtime_managed');
|
|
667
|
+
}
|
|
668
|
+
const stored = candidate.source === 'binding'
|
|
669
|
+
? candidate.authority
|
|
670
|
+
: rebindCandidateAuthority(candidate);
|
|
671
|
+
const bindingValidation = candidate.source === 'binding'
|
|
672
|
+
? options.bindingValidation
|
|
673
|
+
: null;
|
|
674
|
+
const deadValidation = candidate.source !== 'binding' && options.freshEvidence?.state === 'dead'
|
|
675
|
+
? validateTmuxGcDeadRebindEvidence(candidate)
|
|
676
|
+
: null;
|
|
677
|
+
const validation = bindingValidation || deadValidation || validateTmuxDestructiveEvidence(
|
|
678
|
+
stored, observeTmuxConditionalTarget(target)
|
|
679
|
+
);
|
|
680
|
+
if (!validation.ok) return skip(validation.reason);
|
|
681
|
+
const validationMode = bindingValidation?.mode || (deadValidation ? 'dead_process' : 'live_process');
|
|
682
|
+
const clientCount = validation.observed?.clients?.count;
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
session: actualSession,
|
|
686
|
+
pane: actualPane,
|
|
687
|
+
hcc_root: hccRoot || null,
|
|
688
|
+
client_count: clientCount,
|
|
689
|
+
stored: validation.stored || stored,
|
|
690
|
+
gc_validation_mode: validationMode,
|
|
691
|
+
dead_process_mode: validationMode === 'dead_process'
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function cmdTmux(ctx, args) {
|
|
696
|
+
const sub = args[0];
|
|
697
|
+
if (!sub || wantsHelp(args)) return helpTmux();
|
|
698
|
+
if (sub !== 'gc') throw new CliError('BAD_ARGS', `Unknown tmux command: ${sub}`);
|
|
699
|
+
|
|
700
|
+
const opts = parseOpts(args.slice(1), { booleans: ['yes', 'dry-run'] });
|
|
701
|
+
validateOpts('tmux gc', opts, ['peer', 'older-than', 'yes', 'dry-run']);
|
|
702
|
+
if (opts.yes && opts['dry-run']) throw new CliError('BAD_ARGS', 'Use either --yes or --dry-run, not both');
|
|
703
|
+
|
|
704
|
+
const dryRun = !opts.yes;
|
|
705
|
+
const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
|
|
706
|
+
const plan = await planTmuxGc(ctx, opts);
|
|
707
|
+
const removed = [];
|
|
708
|
+
if (!dryRun) {
|
|
709
|
+
const runtimeSessions = await managedRuntimeSessions(ctx);
|
|
710
|
+
const db = connect(ctx);
|
|
711
|
+
try {
|
|
712
|
+
for (const candidate of plan.candidates) {
|
|
713
|
+
let currentEvidence = null;
|
|
714
|
+
let bindingValidation = null;
|
|
715
|
+
if (candidate.source === 'binding') {
|
|
716
|
+
const currentSubject = tmuxGcBindingSubjectFromDb(db, ctx, candidate.peer);
|
|
717
|
+
if (!sameTmuxGcBindingSubject(candidate.gc_validation_subject, currentSubject)) {
|
|
718
|
+
plan.skipped.push({ ...candidate, reason: 'tmux_binding_subject_changed', revalidated: true });
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
bindingValidation = validateTmuxGcBindingCandidate(currentSubject);
|
|
722
|
+
if (!bindingValidation.ok || bindingValidation.mode !== candidate.gc_validation_mode) {
|
|
723
|
+
plan.skipped.push({
|
|
724
|
+
...candidate,
|
|
725
|
+
reason: bindingValidation.ok ? 'tmux_binding_validation_mode_changed' : bindingValidation.reason,
|
|
726
|
+
revalidated: true
|
|
727
|
+
});
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
currentEvidence = observeRebindEventEvidence(ctx, candidate);
|
|
732
|
+
}
|
|
733
|
+
if (candidate.source !== 'binding' && currentEvidence.state !== 'dead') {
|
|
734
|
+
plan.skipped.push({
|
|
735
|
+
...candidate,
|
|
736
|
+
reason: currentEvidence.state === 'live' ? 'peer_live' : currentEvidence.reason,
|
|
737
|
+
evidence_state: currentEvidence.state,
|
|
738
|
+
revalidated: true
|
|
739
|
+
});
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const valid = validateTmuxGcCandidate(ctx, candidate, runtimeSessions, {
|
|
743
|
+
freshEvidence: currentEvidence,
|
|
744
|
+
bindingValidation
|
|
745
|
+
});
|
|
746
|
+
if (!valid.ok) {
|
|
747
|
+
plan.skipped.push({ ...candidate, reason: valid.reason, revalidated: true });
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const eventPayload = auditPayload({
|
|
751
|
+
actor,
|
|
752
|
+
target: candidate.peer,
|
|
753
|
+
admin: true,
|
|
754
|
+
peer: candidate.peer,
|
|
755
|
+
tmux_session: candidate.session,
|
|
756
|
+
runtime_target: candidate.runtime_target,
|
|
757
|
+
reason: candidate.reason,
|
|
758
|
+
older_than_days: plan.older_than_days
|
|
759
|
+
});
|
|
760
|
+
if (candidate.source === 'binding') {
|
|
761
|
+
try {
|
|
762
|
+
const t = now();
|
|
763
|
+
finalizeTmuxGcBindingMutation({
|
|
764
|
+
db,
|
|
765
|
+
plannedSubject: candidate.gc_validation_subject,
|
|
766
|
+
readSubject: () => tmuxGcBindingSubjectFromDb(db, ctx, candidate.peer),
|
|
767
|
+
sameSubject: sameTmuxGcBindingSubject,
|
|
768
|
+
conditionalKill: () => conditionalTmuxKill(runBoundedTmuxGcCommand, valid.stored, {
|
|
769
|
+
requireDeadPane: valid.dead_process_mode
|
|
770
|
+
}),
|
|
771
|
+
casBinding: (subject) => casDetachTmuxGcBinding(db, subject, t),
|
|
772
|
+
updatePeer: (subject) => casDetachTmuxGcPeer(db, subject, t)
|
|
773
|
+
});
|
|
774
|
+
} catch (error) {
|
|
775
|
+
plan.skipped.push({
|
|
776
|
+
...candidate,
|
|
777
|
+
reason: error?.code || 'tmux_gc_binding_finalization_failed',
|
|
778
|
+
error: error?.message || String(error),
|
|
779
|
+
revalidated: true
|
|
780
|
+
});
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
addEvent(db, 'tmux.session.gc', actor, null, eventPayload);
|
|
784
|
+
removed.push(candidate);
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
conditionalTmuxKill(runTmux, valid.stored, {
|
|
789
|
+
requireDeadPane: valid.dead_process_mode
|
|
790
|
+
});
|
|
791
|
+
} catch (error) {
|
|
792
|
+
plan.skipped.push({
|
|
793
|
+
...candidate,
|
|
794
|
+
reason: error?.code || 'tmux_conditional_kill_failed',
|
|
795
|
+
error: error?.message || String(error),
|
|
796
|
+
revalidated: true
|
|
797
|
+
});
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
tx(db, () => {
|
|
801
|
+
addEvent(db, 'tmux.session.gc', actor, null, eventPayload);
|
|
802
|
+
removed.push(candidate);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
} finally {
|
|
806
|
+
db.close();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const data = { dry_run: dryRun, older_than_days: plan.older_than_days, peer: plan.peer, candidates: plan.candidates, skipped: plan.skipped, removed };
|
|
811
|
+
printResult(ctx, data, (r) => {
|
|
812
|
+
const rows = dryRun ? r.candidates : r.removed;
|
|
813
|
+
const title = dryRun
|
|
814
|
+
? `tmux gc dry-run: ${rows.length} removable hcc-managed session${rows.length === 1 ? '' : 's'}`
|
|
815
|
+
: `tmux gc removed ${rows.length} hcc-managed session${rows.length === 1 ? '' : 's'}`;
|
|
816
|
+
if (!rows.length) return `${title}\n nothing to clean`;
|
|
817
|
+
return [
|
|
818
|
+
title,
|
|
819
|
+
table(rows, [
|
|
820
|
+
{ label: 'peer', value: (row) => row.peer },
|
|
821
|
+
{ label: 'session', value: (row) => row.session },
|
|
822
|
+
{ label: 'pane', value: (row) => row.pane },
|
|
823
|
+
{ label: 'age', value: (row) => `${row.age_days}d` },
|
|
824
|
+
{ label: 'reason', value: (row) => row.reason }
|
|
825
|
+
]),
|
|
826
|
+
dryRun ? 'run again with --yes to delete only these DB-proven hcc-managed tmux sessions' : ''
|
|
827
|
+
].filter(Boolean).join('\n');
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
cmdScan, cmdTmux,
|
|
833
|
+
tmuxSessionNameForTarget, tmuxSessionCreationToken, tmuxSessionId, tmuxPaneForTarget,
|
|
834
|
+
managedRuntimeSessions, parseJsonObject,
|
|
835
|
+
planTmuxGc, validateTmuxGcCandidate,
|
|
836
|
+
strictTmuxClientObservation, tmuxAttachmentAuthority
|
|
837
|
+
};
|
|
838
|
+
}
|