@aiwayds/dsh-tui-pi 0.29.0 → 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/README.md +3 -0
- package/README.zh-CN.md +3 -0
- package/lib/ask-user.d.ts +72 -0
- package/lib/ask-user.js +212 -0
- package/lib/ask-user.js.map +1 -1
- package/lib/clipboard.d.ts +114 -0
- package/lib/clipboard.js +236 -0
- package/lib/clipboard.js.map +1 -0
- package/lib/index.js +113 -62
- package/lib/index.js.map +1 -1
- package/lib/log-repair.d.ts +161 -0
- package/lib/log-repair.js +273 -0
- package/lib/log-repair.js.map +1 -0
- package/lib/repair-dialog.d.ts +69 -0
- package/lib/repair-dialog.js +153 -0
- package/lib/repair-dialog.js.map +1 -0
- package/lib/sessions.d.ts +9 -0
- package/lib/sessions.js +29 -7
- package/lib/sessions.js.map +1 -1
- package/lib/tui.js +17 -1
- package/lib/tui.js.map +1 -1
- package/package.json +2 -2
- package/scripts/repair-session-log.mjs +20 -6
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corrupted-resume-log repair orchestration — the /resume safety net.
|
|
3
|
+
*
|
|
4
|
+
* When a persisted session's log was forked by a historical double-writer
|
|
5
|
+
* (two dsh processes appending to one jsonl), the loader refuses it with
|
|
6
|
+
* "corrupt session log: seq gap …" and /resume dead-ends. The offline
|
|
7
|
+
* surgery itself lives in scripts/repair-session-log.mjs (deterministic
|
|
8
|
+
* dedupe + dense renumbering; the original is NEVER modified there). This
|
|
9
|
+
* module is the TUI-free bridge around it: it runs the script, verifies the
|
|
10
|
+
* result, and — only after an explicit user confirmation upstream — swaps
|
|
11
|
+
* the repaired copy in beside a kept backup, under the single-writer lock.
|
|
12
|
+
*
|
|
13
|
+
* Contract of the script (its own header is authoritative):
|
|
14
|
+
* exit 0 → the log is CLEAN (nothing written), or — with --apply — a
|
|
15
|
+
* `<stem>.repaired.jsonl[.zstd]` artifact was written BESIDE the
|
|
16
|
+
* original (the CLEAN verdict line distinguishes the two);
|
|
17
|
+
* exit 3 → corrupt diagnosed, nothing written (dry-run only by contract);
|
|
18
|
+
* exit 2 → usage/environment error (missing zstd, torn lines without
|
|
19
|
+
* --skip-bad-lines, …).
|
|
20
|
+
*
|
|
21
|
+
* Safety invariants (all enforced here, testable without a terminal):
|
|
22
|
+
* - The original log is never edited in place — swapping moves it aside to
|
|
23
|
+
* `<name>.corrupt-bak` first and only then renames the verified repaired
|
|
24
|
+
* copy over the canonical name.
|
|
25
|
+
* - A repaired copy that does not itself verify CLEAN is never swapped in.
|
|
26
|
+
* - The writer lock beside the log (<sessionDir>/writer.lock, same file the
|
|
27
|
+
* cold arms and the feishu guard compete on) is claimed for the whole
|
|
28
|
+
* apply→verify→swap window and always released, success or not.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { chmodSync, existsSync, renameSync, statSync } from 'node:fs';
|
|
32
|
+
import { basename, dirname, join } from 'node:path';
|
|
33
|
+
import { SESSION_LOG_FILE_NAMES } from "./retention.js";
|
|
34
|
+
import { acquireWriterLock, projectKeyFor, releaseOwnedWriterLock, } from "./writer-lock.js";
|
|
35
|
+
/** The repair script rides the published package (`files` includes scripts/). */
|
|
36
|
+
export function repairScriptPath() {
|
|
37
|
+
return new URL('../scripts/repair-session-log.mjs', import.meta.url).pathname;
|
|
38
|
+
}
|
|
39
|
+
/** Hard wall-clock budget for one script invocation (big logs still finish; a hung zstd must not freeze /resume). */
|
|
40
|
+
export const REPAIR_TIMEOUT_MS = 120_000;
|
|
41
|
+
/**
|
|
42
|
+
* The path the repair script writes its artifact to (mirrors the script's
|
|
43
|
+
* own `<stem>.repaired.jsonl[.zstd]` naming, stem = name minus the jsonl[.zstd]
|
|
44
|
+
* suffix). Exported so tests and callers can predict the artifact location.
|
|
45
|
+
*/
|
|
46
|
+
export function repairedArtifactPath(logPath) {
|
|
47
|
+
const isZstd = /\.jsonl\.zstd$/.test(logPath);
|
|
48
|
+
const stem = basename(logPath).replace(/\.jsonl(\.zstd)?$/, '');
|
|
49
|
+
return join(dirname(logPath), `${stem}.repaired.jsonl${isZstd ? '.zstd' : ''}`);
|
|
50
|
+
}
|
|
51
|
+
function firstLine(text) {
|
|
52
|
+
const line = text.split('\n').map(l => l.trim()).find(l => l !== '');
|
|
53
|
+
return line ?? '';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Run scripts/repair-session-log.mjs against one log. Never touches the log
|
|
57
|
+
* itself (the script cannot); classifies the outcome per the exit contract
|
|
58
|
+
* above. A missing zstd binary surfaces as the script's exit 2 — or, should
|
|
59
|
+
* the spawn itself fail with ENOENT, as a failed result with an install hint.
|
|
60
|
+
*/
|
|
61
|
+
export function runRepair(logPath, options = {}) {
|
|
62
|
+
const apply = options.apply ?? false;
|
|
63
|
+
const spawn = options.spawnSyncFn ?? spawnSync;
|
|
64
|
+
let proc;
|
|
65
|
+
try {
|
|
66
|
+
proc = spawn(process.execPath, [repairScriptPath(), logPath, ...(apply ? ['--apply'] : [])], { encoding: 'utf8', timeout: REPAIR_TIMEOUT_MS, maxBuffer: 1 << 26 });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
+
return { status: 'failed', detail: `cannot run the repair script: ${message}` };
|
|
71
|
+
}
|
|
72
|
+
if (proc.error !== undefined) {
|
|
73
|
+
const code = proc.error.code;
|
|
74
|
+
const hint = code === 'ENOENT' ? ' — is zstd installed and on PATH?' : '';
|
|
75
|
+
return {
|
|
76
|
+
status: 'failed',
|
|
77
|
+
detail: `cannot run the repair script: ${proc.error.message}${hint}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const out = proc.stdout ?? '';
|
|
81
|
+
const err = proc.stderr ?? '';
|
|
82
|
+
if (proc.status === 0) {
|
|
83
|
+
// Exit 0 is ambiguous by contract (CLEAN vs. repaired+written); the
|
|
84
|
+
// CLEAN verdict line disambiguates without guessing at files.
|
|
85
|
+
if (out.includes('verdict: CLEAN'))
|
|
86
|
+
return { status: 'clean' };
|
|
87
|
+
const repairedPath = repairedArtifactPath(logPath);
|
|
88
|
+
if (existsSync(repairedPath)) {
|
|
89
|
+
return { status: 'repaired', repairedPath, detail: firstLine(out) };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
status: 'failed',
|
|
93
|
+
detail: 'repair script exited 0 but wrote no repaired artifact beside the log',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (proc.status === 3) {
|
|
97
|
+
// By the script's contract exit 3 means "corrupt, dry-run, nothing
|
|
98
|
+
// written" — with --apply this is a contract violation, but either way
|
|
99
|
+
// there is nothing to swap in, so it is a failure for our caller.
|
|
100
|
+
return {
|
|
101
|
+
status: 'failed',
|
|
102
|
+
detail: firstLine(out) || 'log is corrupt — the script diagnosed it without writing a repair',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
status: 'failed',
|
|
107
|
+
detail: firstLine(err) || `repair script exited ${proc.status ?? 'by signal'}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Verify one log loads clean (dry-run diagnosis, never writes). Used on the
|
|
112
|
+
* repaired artifact BEFORE it replaces the canonical log.
|
|
113
|
+
*/
|
|
114
|
+
export function verifyClean(logPath, options = {}) {
|
|
115
|
+
const run = runRepair(logPath, { ...options, apply: false });
|
|
116
|
+
return run.status === 'clean' ? { ok: true } : { ok: false, detail: run.detail };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Swap the verified repaired copy in over the canonical log name: the
|
|
120
|
+
* original moves aside to `<name>.corrupt-bak` (suffixed with the epoch
|
|
121
|
+
* millisecond when that name is taken — never overwritten), the repaired
|
|
122
|
+
* copy takes its place with owner-only permissions, matching the store's
|
|
123
|
+
* private-file hygiene. Synchronous on purpose: the swap window sits under
|
|
124
|
+
* the writer lock and must be a tight rename-rename pair. A failed swap-in
|
|
125
|
+
* restores the original best-effort (mirroring the feishu surface) before
|
|
126
|
+
* rethrowing, so the session is never stranded without its canonical log.
|
|
127
|
+
*/
|
|
128
|
+
export function swapRepaired(logPath, repairedPath, options = {}) {
|
|
129
|
+
let backupPath = `${logPath}.corrupt-bak`;
|
|
130
|
+
try {
|
|
131
|
+
statSync(backupPath);
|
|
132
|
+
backupPath = `${backupPath}.${Date.now()}`;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Free — the plain .corrupt-bak name is ours.
|
|
136
|
+
}
|
|
137
|
+
renameSync(logPath, backupPath);
|
|
138
|
+
try {
|
|
139
|
+
renameSync(repairedPath, logPath);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
// Best-effort restore first — the canonical name must not stay missing
|
|
143
|
+
// with the original parked in the backup; the rethrow is the signal.
|
|
144
|
+
try {
|
|
145
|
+
renameSync(backupPath, logPath);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Nothing further is recoverable here; the caller reports the swap
|
|
149
|
+
// failure and the .corrupt-bak copy still holds the original.
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
;
|
|
155
|
+
(options.chmodFn ?? chmodSync)(logPath, 0o600);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Permissions are store hygiene, not log integrity: the swap already
|
|
159
|
+
// succeeded, so a chmod failure never flips the outcome. A later
|
|
160
|
+
// successful repair (or any manual chmod) fixes the mode.
|
|
161
|
+
}
|
|
162
|
+
return { backupPath };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* End-to-end repair of one session log, under the single-writer lock beside
|
|
166
|
+
* it: claim → apply → verify the repaired copy → swap → release. ANY failure
|
|
167
|
+
* leaves the canonical log untouched, releases the lock, and reports
|
|
168
|
+
* {kind:'failed'}; only a verified artifact is ever swapped in. The lock dir
|
|
169
|
+
* is the log's own directory — the same `<sessionDir>/writer.lock` the cold
|
|
170
|
+
* arms and the feishu guard compete on.
|
|
171
|
+
*/
|
|
172
|
+
export async function repairSessionLog(logPath, options = {}) {
|
|
173
|
+
const dir = dirname(logPath);
|
|
174
|
+
let claim;
|
|
175
|
+
try {
|
|
176
|
+
claim = await acquireWriterLock(dir);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
180
|
+
return { kind: 'failed', detail: `cannot claim the writer lock: ${message}` };
|
|
181
|
+
}
|
|
182
|
+
if (!claim.ok)
|
|
183
|
+
return { kind: 'locked', holder: claim.holder };
|
|
184
|
+
try {
|
|
185
|
+
const run = runRepair(logPath, { apply: true, spawnSyncFn: options.spawnSyncFn });
|
|
186
|
+
if (run.status === 'failed')
|
|
187
|
+
return { kind: 'failed', detail: run.detail };
|
|
188
|
+
if (run.status === 'clean')
|
|
189
|
+
return { kind: 'clean' };
|
|
190
|
+
const verdict = verifyClean(run.repairedPath, options);
|
|
191
|
+
if (!verdict.ok) {
|
|
192
|
+
return {
|
|
193
|
+
kind: 'failed',
|
|
194
|
+
detail: `the repaired copy still fails verification: ${verdict.detail}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
kind: 'repaired',
|
|
199
|
+
backupPath: swapRepaired(logPath, run.repairedPath, options).backupPath,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
// swapRepaired restored the original best-effort before rethrowing; map
|
|
204
|
+
// the residue to a plain failure so no exception ever escapes toward the
|
|
205
|
+
// command dispatch — this module's contract is results, not throws.
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
return { kind: 'failed', detail: `swap failed: ${message}` };
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await releaseOwnedWriterLock(dir);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The /resume corrupt-log fingerprint: the loader's "corrupt session log:
|
|
215
|
+
* seq gap …" refusal, plus the raw-decompression twin ("corrupt zstandard
|
|
216
|
+
* log"). Case-insensitive; anything else (locked session, missing
|
|
217
|
+
* persistence, network) must NOT route into the repair flow.
|
|
218
|
+
*/
|
|
219
|
+
const CORRUPT_LOG_PATTERN = /corrupt .*(session|zstandard) log/i;
|
|
220
|
+
export function isCorruptLogError(message) {
|
|
221
|
+
return CORRUPT_LOG_PATTERN.test(message);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* User-facing notice for a repair attempt that did NOT swap anything;
|
|
225
|
+
* undefined when the flow should proceed to resume ('repaired' | 'clean').
|
|
226
|
+
* Pure so the wording is testable without a terminal (English-only).
|
|
227
|
+
*/
|
|
228
|
+
export function repairFailureNotice(result) {
|
|
229
|
+
if (result.kind === 'locked') {
|
|
230
|
+
return `session is driven by pid ${result.holder.pid} — close it on the other side first`;
|
|
231
|
+
}
|
|
232
|
+
if (result.kind === 'failed') {
|
|
233
|
+
return `repair failed: ${result.detail} — log untouched`;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Locate one session's canonical log file on disk the same way the
|
|
239
|
+
* single-writer guard derives it: `<root>/<projectKeyFor(cwd)>/<sessionId>/<name>`,
|
|
240
|
+
* with the cwd taken from the persisted header list (the log lives under
|
|
241
|
+
* THAT project key). Best-effort: no header, no cwd, or no log file under
|
|
242
|
+
* the derived dir yields undefined — the caller reports a failure instead
|
|
243
|
+
* of repairing a decoy. When both suffixes coexist (a kept raw copy beside
|
|
244
|
+
* the compressed log) the compressed name wins: it is what the writer
|
|
245
|
+
* appends to and what the loader reads.
|
|
246
|
+
*/
|
|
247
|
+
export async function locateSessionLog(persistence, sessionId, root) {
|
|
248
|
+
let cwd;
|
|
249
|
+
try {
|
|
250
|
+
const stored = (await persistence?.list?.().catch(() => [])) ?? [];
|
|
251
|
+
const header = stored.find(candidate => String(candidate.id) === sessionId);
|
|
252
|
+
if (typeof header?.cwd === 'string' && header.cwd !== '')
|
|
253
|
+
cwd = header.cwd;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
if (cwd === undefined)
|
|
259
|
+
return undefined;
|
|
260
|
+
const dir = join(root, projectKeyFor(cwd), sessionId);
|
|
261
|
+
for (const name of SESSION_LOG_FILE_NAMES.slice().reverse()) {
|
|
262
|
+
const file = join(dir, name);
|
|
263
|
+
try {
|
|
264
|
+
statSync(file);
|
|
265
|
+
return file;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Not this suffix — try the next one.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=log-repair.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log-repair.js","sourceRoot":"","sources":["../src/log-repair.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /resume repair confirmation dialog — shown when a picked session's
|
|
3
|
+
* log fails to load with a corrupt-log error (src/log-repair.ts
|
|
4
|
+
* `isCorruptLogError`). Repairing rewrites user data on disk, so it is
|
|
5
|
+
* NEVER done implicitly: this dialog is the explicit confirmation gate.
|
|
6
|
+
*
|
|
7
|
+
* Two options, in the spec's button order:
|
|
8
|
+
* 1. Repair & resume — run the guarded in-place repair, then re-enter the
|
|
9
|
+
* selected row's resume path.
|
|
10
|
+
* 2. Cancel — leave the log untouched; /resume reports the original error.
|
|
11
|
+
* ↑↓ moves the selection, `1`/`2` select directly, Enter confirms, Esc
|
|
12
|
+
* cancels — the exact keymap of the submit routing dialog (route-dialog.ts),
|
|
13
|
+
* whose pure-reducer split this file mirrors so the decision matrix stays
|
|
14
|
+
* unit-testable without a terminal. Framing/focus follow the shared overlay
|
|
15
|
+
* contract: PanelHost framing, close re-focuses the CURRENT editor instance
|
|
16
|
+
* through `restoreFocus`.
|
|
17
|
+
*/
|
|
18
|
+
import { type Component, type TUI } from '@earendil-works/pi-tui';
|
|
19
|
+
import { type TuiTheme } from './theme/index.ts';
|
|
20
|
+
/** The two choices in display order; index 0 is preselected. */
|
|
21
|
+
export declare const REPAIR_CONFIRM_OPTIONS: ReadonlyArray<{
|
|
22
|
+
id: 'repair' | 'cancel';
|
|
23
|
+
title: string;
|
|
24
|
+
hint: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** The fixed body copy (spec wording): what happened, what repair does, what is kept. */
|
|
27
|
+
export declare const REPAIR_CONFIRM_MESSAGE: string;
|
|
28
|
+
/** Footer hint — hardcoded like every other panel footer (English-only). */
|
|
29
|
+
export declare const REPAIR_CONFIRM_FOOTER = "\u2191\u2193 select \u00B7 1/2 pick \u00B7 Enter confirm \u00B7 Esc cancel";
|
|
30
|
+
/** Pure dialog state: which row is highlighted, and the terminal outcome. */
|
|
31
|
+
export interface RepairConfirmState {
|
|
32
|
+
selected: number;
|
|
33
|
+
/**
|
|
34
|
+
* Set once by a terminal key: `'confirm'` (Enter on a selection) or
|
|
35
|
+
* `'cancel'` (Esc). Further input is ignored afterwards.
|
|
36
|
+
*/
|
|
37
|
+
settled?: 'confirm' | 'cancel';
|
|
38
|
+
}
|
|
39
|
+
export declare function initialRepairConfirmState(): RepairConfirmState;
|
|
40
|
+
/**
|
|
41
|
+
* Apply one raw key sequence to the dialog state. Unknown keys are no-ops;
|
|
42
|
+
* anything after a settle is ignored (single terminal outcome guard).
|
|
43
|
+
*/
|
|
44
|
+
export declare function updateRepairConfirm(state: RepairConfirmState, data: string): RepairConfirmState;
|
|
45
|
+
/** Resolved dialog outcome: the chosen action, or undefined on cancel. */
|
|
46
|
+
export declare function repairConfirmOutcome(state: RepairConfirmState): 'repair' | 'cancel' | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The framed overlay component. Renders the fixed body copy plus the two
|
|
49
|
+
* option rows; every key goes through {@link updateRepairConfirm}, and the
|
|
50
|
+
* first terminal key fires `onFinish` exactly once.
|
|
51
|
+
*/
|
|
52
|
+
export declare class RepairConfirmPanel implements Component {
|
|
53
|
+
private readonly theme;
|
|
54
|
+
private readonly onFinish;
|
|
55
|
+
private readonly requestRenderFn;
|
|
56
|
+
private state;
|
|
57
|
+
constructor(theme: TuiTheme, onFinish: (outcome: 'repair' | 'cancel' | undefined) => void, requestRender: () => void);
|
|
58
|
+
invalidate(): void;
|
|
59
|
+
render(width: number): string[];
|
|
60
|
+
handleInput(data: string): void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Open the repair confirmation dialog for one corrupt resume target.
|
|
64
|
+
* Resolves `'repair'` when the user explicitly confirmed, `'cancelled'`
|
|
65
|
+
* otherwise (Esc, or an overlay that failed to mount — treated as cancel so
|
|
66
|
+
* a half-mounted dialog can never imply consent). Closing always hands
|
|
67
|
+
* focus back through `restoreFocus` before the promise settles.
|
|
68
|
+
*/
|
|
69
|
+
export declare function openRepairConfirmDialog(tui: TUI, theme: TuiTheme, restoreFocus: () => void): Promise<'repair' | 'cancelled'>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /resume repair confirmation dialog — shown when a picked session's
|
|
3
|
+
* log fails to load with a corrupt-log error (src/log-repair.ts
|
|
4
|
+
* `isCorruptLogError`). Repairing rewrites user data on disk, so it is
|
|
5
|
+
* NEVER done implicitly: this dialog is the explicit confirmation gate.
|
|
6
|
+
*
|
|
7
|
+
* Two options, in the spec's button order:
|
|
8
|
+
* 1. Repair & resume — run the guarded in-place repair, then re-enter the
|
|
9
|
+
* selected row's resume path.
|
|
10
|
+
* 2. Cancel — leave the log untouched; /resume reports the original error.
|
|
11
|
+
* ↑↓ moves the selection, `1`/`2` select directly, Enter confirms, Esc
|
|
12
|
+
* cancels — the exact keymap of the submit routing dialog (route-dialog.ts),
|
|
13
|
+
* whose pure-reducer split this file mirrors so the decision matrix stays
|
|
14
|
+
* unit-testable without a terminal. Framing/focus follow the shared overlay
|
|
15
|
+
* contract: PanelHost framing, close re-focuses the CURRENT editor instance
|
|
16
|
+
* through `restoreFocus`.
|
|
17
|
+
*/
|
|
18
|
+
import { getKeybindings } from '@earendil-works/pi-tui';
|
|
19
|
+
import { PanelHost, panelThemeFns } from "./panels.js";
|
|
20
|
+
import { BOLD, RESET, ansiFg } from "./theme/index.js";
|
|
21
|
+
import { clipToWidth, wrapText } from "./text.js";
|
|
22
|
+
/** The two choices in display order; index 0 is preselected. */
|
|
23
|
+
export const REPAIR_CONFIRM_OPTIONS = [
|
|
24
|
+
{ id: 'repair', title: 'Repair & resume', hint: 'rebuild the log in place, then enter the session' },
|
|
25
|
+
{ id: 'cancel', title: 'Cancel', hint: 'leave the log untouched' },
|
|
26
|
+
];
|
|
27
|
+
/** The fixed body copy (spec wording): what happened, what repair does, what is kept. */
|
|
28
|
+
export const REPAIR_CONFIRM_MESSAGE = 'Resume log is corrupted (likely from a historical double-writer). '
|
|
29
|
+
+ 'Repair it in place? The original is kept as .corrupt-bak.';
|
|
30
|
+
/** Footer hint — hardcoded like every other panel footer (English-only). */
|
|
31
|
+
export const REPAIR_CONFIRM_FOOTER = '↑↓ select · 1/2 pick · Enter confirm · Esc cancel';
|
|
32
|
+
export function initialRepairConfirmState() {
|
|
33
|
+
return { selected: 0 };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Apply one raw key sequence to the dialog state. Unknown keys are no-ops;
|
|
37
|
+
* anything after a settle is ignored (single terminal outcome guard).
|
|
38
|
+
*/
|
|
39
|
+
export function updateRepairConfirm(state, data) {
|
|
40
|
+
if (state.settled !== undefined)
|
|
41
|
+
return state;
|
|
42
|
+
const kb = getKeybindings();
|
|
43
|
+
if (kb.matches(data, 'tui.select.cancel'))
|
|
44
|
+
return { ...state, settled: 'cancel' };
|
|
45
|
+
if (kb.matches(data, 'tui.input.submit'))
|
|
46
|
+
return { ...state, settled: 'confirm' };
|
|
47
|
+
if (kb.matches(data, 'tui.select.up')) {
|
|
48
|
+
return { ...state, selected: Math.max(0, state.selected - 1) };
|
|
49
|
+
}
|
|
50
|
+
if (kb.matches(data, 'tui.select.down')) {
|
|
51
|
+
return { ...state, selected: Math.min(REPAIR_CONFIRM_OPTIONS.length - 1, state.selected + 1) };
|
|
52
|
+
}
|
|
53
|
+
// Digit direct-select (1-based): selects the row, Enter still confirms —
|
|
54
|
+
// same select-then-confirm split as the routing dialog.
|
|
55
|
+
const digit = /^([1-9])$/.exec(data);
|
|
56
|
+
if (digit !== null) {
|
|
57
|
+
const index = Number(digit[1]) - 1;
|
|
58
|
+
if (index < REPAIR_CONFIRM_OPTIONS.length)
|
|
59
|
+
return { ...state, selected: index };
|
|
60
|
+
}
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
/** Resolved dialog outcome: the chosen action, or undefined on cancel. */
|
|
64
|
+
export function repairConfirmOutcome(state) {
|
|
65
|
+
if (state.settled !== 'confirm')
|
|
66
|
+
return undefined;
|
|
67
|
+
return REPAIR_CONFIRM_OPTIONS[state.selected]?.id;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The framed overlay component. Renders the fixed body copy plus the two
|
|
71
|
+
* option rows; every key goes through {@link updateRepairConfirm}, and the
|
|
72
|
+
* first terminal key fires `onFinish` exactly once.
|
|
73
|
+
*/
|
|
74
|
+
export class RepairConfirmPanel {
|
|
75
|
+
theme;
|
|
76
|
+
onFinish;
|
|
77
|
+
requestRenderFn;
|
|
78
|
+
state = initialRepairConfirmState();
|
|
79
|
+
constructor(theme, onFinish, requestRender) {
|
|
80
|
+
this.theme = theme;
|
|
81
|
+
this.onFinish = onFinish;
|
|
82
|
+
this.requestRenderFn = requestRender;
|
|
83
|
+
}
|
|
84
|
+
invalidate() { }
|
|
85
|
+
render(width) {
|
|
86
|
+
const fns = panelThemeFns(this.theme);
|
|
87
|
+
const wrap = Math.max(2, width - 2);
|
|
88
|
+
// Word-wrap the body FIRST, then paint (iron rule: width math runs on
|
|
89
|
+
// plain text, ANSI goes on after clipping).
|
|
90
|
+
const lines = [
|
|
91
|
+
fns.accent(BOLD + clipToWidth('● Corrupted resume log', wrap) + RESET),
|
|
92
|
+
...wrapText(REPAIR_CONFIRM_MESSAGE, wrap).map(segment => fns.muted(clipToWidth(segment, wrap))),
|
|
93
|
+
'',
|
|
94
|
+
];
|
|
95
|
+
for (let i = 0; i < REPAIR_CONFIRM_OPTIONS.length; i++) {
|
|
96
|
+
const option = REPAIR_CONFIRM_OPTIONS[i];
|
|
97
|
+
const marker = i === this.state.selected ? '▸' : ' ';
|
|
98
|
+
const row = clipToWidth(`${marker} ${i + 1}. ${option.title} — ${option.hint}`, wrap);
|
|
99
|
+
lines.push(i === this.state.selected
|
|
100
|
+
? ansiFg(this.theme.palette.accent) + BOLD + row + RESET
|
|
101
|
+
: fns.muted(row));
|
|
102
|
+
}
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push(fns.subtle(clipToWidth(REPAIR_CONFIRM_FOOTER, wrap)));
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
handleInput(data) {
|
|
108
|
+
const previous = this.state;
|
|
109
|
+
this.state = updateRepairConfirm(this.state, data);
|
|
110
|
+
if (this.state === previous)
|
|
111
|
+
return;
|
|
112
|
+
if (this.state.settled === undefined) {
|
|
113
|
+
this.requestRenderFn();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.onFinish(repairConfirmOutcome(this.state));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Open the repair confirmation dialog for one corrupt resume target.
|
|
121
|
+
* Resolves `'repair'` when the user explicitly confirmed, `'cancelled'`
|
|
122
|
+
* otherwise (Esc, or an overlay that failed to mount — treated as cancel so
|
|
123
|
+
* a half-mounted dialog can never imply consent). Closing always hands
|
|
124
|
+
* focus back through `restoreFocus` before the promise settles.
|
|
125
|
+
*/
|
|
126
|
+
export function openRepairConfirmDialog(tui, theme, restoreFocus) {
|
|
127
|
+
return new Promise(resolve => {
|
|
128
|
+
let settled = false;
|
|
129
|
+
const settle = (outcome) => {
|
|
130
|
+
if (settled)
|
|
131
|
+
return;
|
|
132
|
+
settled = true;
|
|
133
|
+
resolve(outcome);
|
|
134
|
+
};
|
|
135
|
+
// A half-mounted overlay must not strand the keyboard: PanelHost's
|
|
136
|
+
// onError closes + calls restoreFocus, then we settle as cancelled.
|
|
137
|
+
const host = new PanelHost(tui, theme, () => {
|
|
138
|
+
restoreFocus();
|
|
139
|
+
settle('cancelled');
|
|
140
|
+
});
|
|
141
|
+
const finish = (outcome) => {
|
|
142
|
+
host.close();
|
|
143
|
+
restoreFocus();
|
|
144
|
+
settle(outcome === 'repair' ? 'repair' : 'cancelled');
|
|
145
|
+
};
|
|
146
|
+
const panel = new RepairConfirmPanel(theme, outcome => finish(outcome), () => tui.requestRender());
|
|
147
|
+
// maxHeight is a hard slice in pi-tui: ~9 content rows + 5 frame rows
|
|
148
|
+
// must fit or the bottom border (and footer) is cut. 75% of the 24-row
|
|
149
|
+
// e2e floor is 18 — the same headroom the /resume picker reserves.
|
|
150
|
+
host.open(panel, '70%', '75%');
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=repair-dialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repair-dialog.js","sourceRoot":"","sources":["../src/repair-dialog.ts"],"names":[],"mappings":"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"}
|
package/lib/sessions.d.ts
CHANGED
|
@@ -195,6 +195,15 @@ export declare function filterSessionsByLastActivity(headers: readonly SessionHe
|
|
|
195
195
|
export declare function previewOfEvents(events: readonly SessionEvent[]): string | undefined;
|
|
196
196
|
/** Collapse control chars/whitespace and clip a raw message into a one-line preview. */
|
|
197
197
|
export declare function normalizePreview(text: string, maxChars?: number): string;
|
|
198
|
+
/**
|
|
199
|
+
* The picker row's SESSION-column label: the first-message preview when one
|
|
200
|
+
* was read, else the header label (cwd basename · short id). `corrupt`
|
|
201
|
+
* prepends the ⚠ marker — the same pre-read `inspect` that harvests previews
|
|
202
|
+
* already diagnosed this log as corrupt, and selecting the row will offer an
|
|
203
|
+
* in-place repair instead of a dead end. Pure so the marker contract is
|
|
204
|
+
* unit-testable without a store.
|
|
205
|
+
*/
|
|
206
|
+
export declare function resumeRowTitle(header: SessionHeader, preview: string | undefined, corrupt: boolean): string;
|
|
198
207
|
/**
|
|
199
208
|
* Validate one persisted session's log without publishing it — the /resume
|
|
200
209
|
* pre-check: a corrupt target must be rejected BEFORE the current live agent
|
package/lib/sessions.js
CHANGED
|
@@ -11,6 +11,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { basename, join } from 'node:path';
|
|
13
13
|
import { SESSION_LOG_FILE_NAMES } from "./retention.js";
|
|
14
|
+
import { isCorruptLogError } from "./log-repair.js";
|
|
14
15
|
import { emitNotice } from "./notice-bridge.js";
|
|
15
16
|
import { wrapFramedOverlay } from "./frame.js";
|
|
16
17
|
import { autoColumns, columnWidths, MARKER_W, padCell, panelThemeFns, TablePanel, tableHeaderLine, tableRuleLine, TABLE_SEP, } from "./panels.js";
|
|
@@ -405,9 +406,27 @@ export function normalizePreview(text, maxChars = PREVIEW_MAX_CHARS) {
|
|
|
405
406
|
const oneLine = text.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
406
407
|
return oneLine.length <= maxChars ? oneLine : `${oneLine.slice(0, maxChars)}…`;
|
|
407
408
|
}
|
|
408
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* The picker row's SESSION-column label: the first-message preview when one
|
|
411
|
+
* was read, else the header label (cwd basename · short id). `corrupt`
|
|
412
|
+
* prepends the ⚠ marker — the same pre-read `inspect` that harvests previews
|
|
413
|
+
* already diagnosed this log as corrupt, and selecting the row will offer an
|
|
414
|
+
* in-place repair instead of a dead end. Pure so the marker contract is
|
|
415
|
+
* unit-testable without a store.
|
|
416
|
+
*/
|
|
417
|
+
export function resumeRowTitle(header, preview, corrupt) {
|
|
418
|
+
const base = preview ?? `${basename(header.cwd ?? '?')} · ${clipToWidth(String(header.id), 8)}`;
|
|
419
|
+
return corrupt ? `⚠ ${base}` : base;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Best-effort first-message preview per session id; failures map to
|
|
423
|
+
* undefined, and failures matching the corrupt-log fingerprint additionally
|
|
424
|
+
* record the id in `corruptIds` — zero extra IO (the inspect calls already
|
|
425
|
+
* ran for the previews), so the ⚠ rows stay a free byproduct of enrichment.
|
|
426
|
+
*/
|
|
409
427
|
async function loadSessionPreviews(persistence, ids) {
|
|
410
428
|
const previews = new Map();
|
|
429
|
+
const corruptIds = new Set();
|
|
411
430
|
let cursor = 0;
|
|
412
431
|
const workers = Array.from({ length: Math.min(PREVIEW_CONCURRENCY, ids.length) }, async () => {
|
|
413
432
|
while (cursor < ids.length) {
|
|
@@ -420,14 +439,17 @@ async function loadSessionPreviews(persistence, ids) {
|
|
|
420
439
|
if (text)
|
|
421
440
|
preview = normalizePreview(text);
|
|
422
441
|
}
|
|
423
|
-
catch {
|
|
442
|
+
catch (error) {
|
|
424
443
|
preview = undefined;
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
if (isCorruptLogError(message))
|
|
446
|
+
corruptIds.add(String(id));
|
|
425
447
|
}
|
|
426
448
|
previews.set(String(id), preview);
|
|
427
449
|
}
|
|
428
450
|
});
|
|
429
451
|
await Promise.all(workers);
|
|
430
|
-
return previews;
|
|
452
|
+
return { previews, corruptIds };
|
|
431
453
|
}
|
|
432
454
|
/**
|
|
433
455
|
* Validate one persisted session's log without publishing it — the /resume
|
|
@@ -518,16 +540,16 @@ export async function pickPersistedSession(ctx, tui, theme, excludeSessionId, re
|
|
|
518
540
|
}
|
|
519
541
|
// Enrich the most recent candidates with their first-message preview so the
|
|
520
542
|
// rows are distinguishable at a glance; older ones fall back to the header
|
|
521
|
-
// label (cwd + short id).
|
|
522
|
-
|
|
543
|
+
// label (cwd + short id). Logs the enrichment inspect diagnosed as corrupt
|
|
544
|
+
// get the ⚠ marker — a free byproduct of the same calls, never a new scan.
|
|
545
|
+
const { previews, corruptIds } = await loadSessionPreviews(persistence, ordered.slice(0, PREVIEW_SESSION_CAP).map(header => header.id));
|
|
523
546
|
const rows = ordered.map(header => {
|
|
524
547
|
const id = String(header.id);
|
|
525
|
-
const preview = previews.get(id);
|
|
526
548
|
const updated = lastUpdates.get(id)?.mtimeMs ?? header.createdAt;
|
|
527
549
|
return {
|
|
528
550
|
value: id,
|
|
529
551
|
header,
|
|
530
|
-
session:
|
|
552
|
+
session: resumeRowTitle(header, previews.get(id), corruptIds.has(id)),
|
|
531
553
|
when: new Date(updated).toLocaleString(),
|
|
532
554
|
dir: header.cwd ?? 'no cwd',
|
|
533
555
|
};
|