@edspencer/paddock 0.71.2 → 0.72.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/package.json +1 -1
- package/packages/server/dist/app.js +27 -0
- package/packages/server/dist/cli/args.d.ts +56 -5
- package/packages/server/dist/cli/args.js +193 -31
- package/packages/server/dist/cli/config.d.ts +157 -0
- package/packages/server/dist/cli/config.js +462 -0
- package/packages/server/dist/cli/paddock.js +53 -3
- package/packages/server/dist/config.d.ts +36 -1
- package/packages/server/dist/config.js +59 -44
- package/packages/server/dist/herdctl.d.ts +45 -0
- package/packages/server/dist/herdctl.js +58 -4
- package/packages/server/dist/instance-config.d.ts +149 -21
- package/packages/server/dist/instance-config.js +228 -5
- package/packages/server/dist/last-activity.d.ts +21 -0
- package/packages/server/dist/last-activity.js +7 -2
- package/packages/server/dist/profiles.d.ts +154 -0
- package/packages/server/dist/profiles.js +214 -0
- package/packages/server/dist/routes/transcripts.d.ts +61 -0
- package/packages/server/dist/routes/transcripts.js +676 -0
- package/packages/server/dist/routes.d.ts +3 -0
- package/packages/server/dist/routes.js +2 -0
- package/packages/server/dist/transcript-move.d.ts +62 -0
- package/packages/server/dist/transcript-move.js +179 -0
- package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
- package/packages/server/dist/transcripts-migration-execute.js +630 -0
- package/packages/server/dist/transcripts-migration.d.ts +336 -0
- package/packages/server/dist/transcripts-migration.js +917 -0
- package/packages/server/dist/transcripts.d.ts +46 -2
- package/packages/server/dist/transcripts.js +126 -3
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/DiscoverPage-DXI9FjmJ.js +2 -0
- package/packages/web/dist/assets/DiscoverView-D9oqVkT4.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-DyH1j5oR.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-D95ofnQ8.js} +1 -1
- package/packages/web/dist/assets/ProjectMenu-BO70tGwy.js +2 -0
- package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-BxX_aADU.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BbCyKoh5.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-BafOHL43.js} +1 -1
- package/packages/web/dist/assets/RootHome-BGkMypUJ.js +2 -0
- package/packages/web/dist/assets/Surfaces-B8SyV4r3.js +2 -0
- package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-DGyr--oL.js} +1 -1
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BNczNNmx.js +37 -0
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-DiTvGSrV.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-CKZ_jy81.js} +1 -1
- package/packages/web/dist/assets/channel-CjwDi1A9.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-5o96lthu.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-38_pZfd8.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-Db73LvlE.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-C-Q1-wUu.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-ClJigITk.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BVzSX2hw.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-DJlL-ELa.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-BC0QEk6w.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CxiNtY3i.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CxiNtY3i.js +2 -0
- package/packages/web/dist/assets/{core-DnpcT0s6.js → core-VUdoTe9w.js} +1 -1
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DVqArcBy.js +2 -0
- package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-BgFGyUdv.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-BqdZmJt3.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-DeLPu8jT.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-BqTiTGay.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-CbpAvCgl.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-C4h0V97s.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-Dwz77uRx.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DSikJnZR.js} +1 -1
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-DkAGNZb3.js +293 -0
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-BQtzt9hP.js} +1 -1
- package/packages/web/dist/assets/index-BN3rLMpD.js +71 -0
- package/packages/web/dist/assets/index-Dfzb1DvL.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-YcaBUjeC.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BfLZB88u.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-DwaFhd6C.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-e9P3WxsD.js} +1 -1
- package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-CsXS5LTz.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-DsA72Qyi.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-DyLOITDI.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C36aM3D-.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-Cq1zTk6a.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-B6MctUk7.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-CaNRUoPR.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-DDj_tZiQ.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-DDaMlLyK.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bu6Swk2T.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-bMP-7Wam.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-9nzKgc0Y.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DIWbAFW5.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CVL3skbs.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DHoVPvll.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/Chip-Cp32dDmz.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +0 -2
- package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +0 -2
- package/packages/web/dist/assets/ProjectMenu-DXXXtXeh.js +0 -2
- package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +0 -13
- package/packages/web/dist/assets/RootHome-rWodzOC8.js +0 -2
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-UzIlxaCQ.js +0 -37
- package/packages/web/dist/assets/channel-C-sodtn7.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +0 -2
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-B-4NadKI.js +0 -2
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BxliloOH.js +0 -293
- package/packages/web/dist/assets/index-Cx9y6c3f.js +0 -71
- package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +0 -2
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `own → host` transcript migration — the READ half (#882).
|
|
3
|
+
*
|
|
4
|
+
* Flipping `claude.transcripts` from `own` to `host` on an instance that has
|
|
5
|
+
* been used is #708: every pre-flip chat vanishes from the list, files intact
|
|
6
|
+
* but unreachable. This module answers the two questions the guided migration
|
|
7
|
+
* asks BEFORE anything moves:
|
|
8
|
+
*
|
|
9
|
+
* 1. "Is there anything to migrate?" → {@link probeMigration} (the banner)
|
|
10
|
+
* 2. "What exactly would happen?" → {@link buildMigrationPlan} (the table)
|
|
11
|
+
*
|
|
12
|
+
* Nothing here writes, moves or deletes. The execute half — quiesce, move,
|
|
13
|
+
* write `claude.transcripts: host` — is a separate change; see
|
|
14
|
+
* `docs/DESIGN-transcripts-migration.md` §4 for its ordering and commit point.
|
|
15
|
+
*
|
|
16
|
+
* ## The probe asks "is `.chats/` non-empty?", not "is anything new?"
|
|
17
|
+
*
|
|
18
|
+
* The design's first draft short-circuited on the first chat present in
|
|
19
|
+
* `.chats/` and absent from the host store. That is a narrower question than
|
|
20
|
+
* the banner asks, and §10.1 corrects it: a user who adopted their CLI history
|
|
21
|
+
* and then kept working in both places has **zero** such chats — every id
|
|
22
|
+
* exists on both sides — and would never see the banner, despite being exactly
|
|
23
|
+
* the person #882 was opened for.
|
|
24
|
+
*
|
|
25
|
+
* The real predicate comes from the migration's own postcondition (§5): the
|
|
26
|
+
* redirect symlink `pointChatsDirAt` plants on the next boot is declined
|
|
27
|
+
* outright if `.chats/` is a real directory holding **anything**
|
|
28
|
+
* (`transcripts.ts:145`), so every entry must move — including a chat that is
|
|
29
|
+
* byte-identical on both sides. Identity means there is no decision for the
|
|
30
|
+
* user to make; it does not mean there is no work to do. So the probe readdirs
|
|
31
|
+
* `.chats/` and nothing else: one syscall batch per project, no host-store read
|
|
32
|
+
* at all.
|
|
33
|
+
*
|
|
34
|
+
* ## The classifier is staged so cost tracks conflicts, not chat count
|
|
35
|
+
*
|
|
36
|
+
* | Stage | Work | Rules out |
|
|
37
|
+
* |---|---|---|
|
|
38
|
+
* | 0 | `readdir` both sides | an id only in `.chats/` is `new` — no `stat`, no read |
|
|
39
|
+
* | 1 | `stat` both sides | equal size **and** mtime ⇒ identical, no row |
|
|
40
|
+
* | 2 | tail-read the shorter side's last `uuid`, then a **bounded probe** of the longer side in a 32 KB window ending at byte `shorter.size` | a hit ⇒ `fast-forward` |
|
|
41
|
+
* | 3 | full scan of the longer side for that uuid | hit ⇒ `fast-forward` (rewritten history); miss ⇒ `diverged` |
|
|
42
|
+
*
|
|
43
|
+
* Stage 2 is the load-bearing part and the reason the table is affordable at
|
|
44
|
+
* all. A transcript is append-only, so a genuine ancestor's last record *ends
|
|
45
|
+
* at byte offset `shorter.size`* in its descendant — the uuid is at a known
|
|
46
|
+
* offset and does not need to be searched for. Measured 298 hits / 0 misses
|
|
47
|
+
* over 300 real transcripts (design §0.3). The full scan survives only to
|
|
48
|
+
* *confirm divergence*, never to confirm a fast-forward, and it draws from a
|
|
49
|
+
* shared per-request byte budget so a pathological instance degrades to
|
|
50
|
+
* `unknown` rows rather than to an unbounded read. Nothing is silently
|
|
51
|
+
* truncated: `scanBudgetExhausted` and `totals.unknown` are the disclosure.
|
|
52
|
+
*/
|
|
53
|
+
import { promises as fs, createReadStream } from "node:fs";
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
import { encodedTranscriptDir, projectChatsDir } from "./transcripts.js";
|
|
56
|
+
import { isConversationRecord } from "./last-activity.js";
|
|
57
|
+
/** The env var that shadows `claude.transcripts` — see `config.ts:1481`. */
|
|
58
|
+
export const TRANSCRIPTS_ENV_VAR = "PADDOCK_CLAUDE_TRANSCRIPTS";
|
|
59
|
+
/**
|
|
60
|
+
* Where unchecked chats are preserved: a SIBLING of `.chats/`, not a child.
|
|
61
|
+
*
|
|
62
|
+
* #882 specified `.chats/pre-migration/`, which cannot work — that directory is
|
|
63
|
+
* "anything", so `pointChatsDirAt` declines the redirect and the migration
|
|
64
|
+
* built to fix #708 ships #708's symptom. Design §5.1 verified it against the
|
|
65
|
+
* real `ensureProjectChats`. Named here because the plan reports the path and
|
|
66
|
+
* the execute half must use the same one.
|
|
67
|
+
*/
|
|
68
|
+
export const PRESERVE_DIR_NAME = ".chats-pre-migration";
|
|
69
|
+
/** How much of a transcript's end to read looking for its last record's uuid. */
|
|
70
|
+
const TAIL_WINDOW_BYTES = 32 * 1024;
|
|
71
|
+
/** One wider retry — enough to clear a single enormous tool-result record. */
|
|
72
|
+
const WIDE_TAIL_BYTES = 4 * 1024 * 1024;
|
|
73
|
+
/** The bounded probe's window, ending at byte `shorter.size` in the longer file. */
|
|
74
|
+
const PROBE_WINDOW_BYTES = 32 * 1024;
|
|
75
|
+
/** Chunk size for the stage-3 full scan (raw substring search, no parsing). */
|
|
76
|
+
const SCAN_CHUNK_BYTES = 1024 * 1024;
|
|
77
|
+
/**
|
|
78
|
+
* Bytes stage 3 (and the diverged rows' comparison columns) may read across one
|
|
79
|
+
* whole request. The worst case is a *product* of chat count and transcript
|
|
80
|
+
* size and neither is under our control, so it is bounded rather than trusted.
|
|
81
|
+
*/
|
|
82
|
+
export const DIVERGENCE_SCAN_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
83
|
+
/* -------------------------------------------------------------------------- */
|
|
84
|
+
/* directory reading */
|
|
85
|
+
/* -------------------------------------------------------------------------- */
|
|
86
|
+
/** Same guard as `readFirstUserText`: keep a session id inside `.chats/`. */
|
|
87
|
+
const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
88
|
+
/** A flat sidechain transcript at the top of a store (45 of them on this box). */
|
|
89
|
+
const SIDECHAIN_RE = /^agent-[0-9a-fA-F]+\.jsonl$/;
|
|
90
|
+
function entryKind(name, isDir) {
|
|
91
|
+
if (isDir) {
|
|
92
|
+
if (name === "memory")
|
|
93
|
+
return "memory";
|
|
94
|
+
if (name === ".reverts")
|
|
95
|
+
return "reverts";
|
|
96
|
+
return SESSION_ID_RE.test(name) ? "chat-dir" : "unexpected";
|
|
97
|
+
}
|
|
98
|
+
if (SIDECHAIN_RE.test(name))
|
|
99
|
+
return "sidechain";
|
|
100
|
+
if (name.endsWith(".jsonl") && SESSION_ID_RE.test(name.slice(0, -6)))
|
|
101
|
+
return "chat";
|
|
102
|
+
return "unexpected";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read one `.chats/`. Returns `null` when there is nothing to migrate from it:
|
|
106
|
+
* missing, or already a symlink (a store that has been pointed at the host,
|
|
107
|
+
* which is what a migrated project looks like before the restart).
|
|
108
|
+
*/
|
|
109
|
+
async function readChatsDir(chatsDir) {
|
|
110
|
+
const st = await fs.lstat(chatsDir).catch(() => null);
|
|
111
|
+
if (!st)
|
|
112
|
+
return null;
|
|
113
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
114
|
+
return null;
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = await fs.readdir(chatsDir, { withFileTypes: true });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return { error: err };
|
|
121
|
+
}
|
|
122
|
+
const listing = {
|
|
123
|
+
chats: [],
|
|
124
|
+
chatDirs: new Set(),
|
|
125
|
+
projectExtras: [],
|
|
126
|
+
unexpected: [],
|
|
127
|
+
hasReverts: false,
|
|
128
|
+
hasMemory: false,
|
|
129
|
+
entryCount: entries.length,
|
|
130
|
+
};
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
const full = path.join(chatsDir, e.name);
|
|
133
|
+
switch (entryKind(e.name, e.isDirectory())) {
|
|
134
|
+
case "chat":
|
|
135
|
+
listing.chats.push(e.name.slice(0, -6));
|
|
136
|
+
break;
|
|
137
|
+
case "chat-dir":
|
|
138
|
+
listing.chatDirs.add(e.name);
|
|
139
|
+
break;
|
|
140
|
+
case "reverts":
|
|
141
|
+
listing.hasReverts = true;
|
|
142
|
+
break;
|
|
143
|
+
case "memory":
|
|
144
|
+
listing.hasMemory = true;
|
|
145
|
+
listing.projectExtras.push(full);
|
|
146
|
+
break;
|
|
147
|
+
case "sidechain":
|
|
148
|
+
listing.projectExtras.push(full);
|
|
149
|
+
break;
|
|
150
|
+
default:
|
|
151
|
+
listing.unexpected.push(full);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// A `<id>/` with no `<id>.jsonl` has no row to hang off, but the
|
|
155
|
+
// postcondition still moves it — so it is named at project level.
|
|
156
|
+
for (const id of listing.chatDirs) {
|
|
157
|
+
if (!listing.chats.includes(id))
|
|
158
|
+
listing.projectExtras.push(path.join(chatsDir, id));
|
|
159
|
+
}
|
|
160
|
+
return { listing };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Which chat a `.reverts/<id>-<stamp>.jsonl` belongs to.
|
|
164
|
+
*
|
|
165
|
+
* `.reverts/` is shared across sessions (`herdctl.ts:1988`), so a partial
|
|
166
|
+
* migration has to split it — and the id is prefix-matched, longest first, so a
|
|
167
|
+
* chat whose id is a prefix of another chat's cannot steal its reverts.
|
|
168
|
+
*/
|
|
169
|
+
function ownerOfRevert(name, chatIds) {
|
|
170
|
+
let best;
|
|
171
|
+
for (let i = name.indexOf("-"); i > 0; i = name.indexOf("-", i + 1)) {
|
|
172
|
+
const candidate = name.slice(0, i);
|
|
173
|
+
if (chatIds.has(candidate) && (best === undefined || candidate.length > best.length)) {
|
|
174
|
+
best = candidate;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return best;
|
|
178
|
+
}
|
|
179
|
+
/** `mtimeMs:size` of a directory — "has its file *set* changed" (`adoptable.ts:354`). */
|
|
180
|
+
async function dirKey(dir) {
|
|
181
|
+
const st = await fs.stat(dir).catch(() => null);
|
|
182
|
+
return st ? `${st.mtimeMs}:${st.size}` : "-";
|
|
183
|
+
}
|
|
184
|
+
const probeCache = new Map();
|
|
185
|
+
/** Drop the memoised probe answers (tests; a config write). */
|
|
186
|
+
export function resetMigrationProbeCache() {
|
|
187
|
+
probeCache.clear();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* "Is there anything to migrate?" — one `readdir` per project, no transcript
|
|
191
|
+
* read and no host-store read at all (§10.1).
|
|
192
|
+
*
|
|
193
|
+
* Memoised per project on `mtimeMs:size` of `.chats/` plus the config version,
|
|
194
|
+
* which costs one `stat` to validate — that is what lets the banner be fetched
|
|
195
|
+
* on every page load. The host-store term the design's §3.4 cache key carried
|
|
196
|
+
* is gone with the host-store read it was there for.
|
|
197
|
+
*
|
|
198
|
+
* ### `pendingChats` is a lower bound that this implementation makes exact
|
|
199
|
+
*
|
|
200
|
+
* The design frames it as a lower bound because the probe "stops counting once
|
|
201
|
+
* the answer is known". `fs.readdir` hands back the whole list in one call, so
|
|
202
|
+
* there is no per-entry short-circuit to be had and counting the `*.jsonl`
|
|
203
|
+
* entries is free — the number is exact. It is still *reported* as a lower
|
|
204
|
+
* bound, because that is the contract clients were given and narrowing it later
|
|
205
|
+
* is a breaking change; but the field is `>= 0` rather than `∈ {0, 1}`.
|
|
206
|
+
*
|
|
207
|
+
* The one wrinkle the corrected predicate introduces: eligibility is about
|
|
208
|
+
* ENTRIES and `pendingChats` counts TRANSCRIPTS, so a `.chats/` holding only
|
|
209
|
+
* `memory/` is `eligible: true` with `pendingChats: 0`. That combination is
|
|
210
|
+
* correct, not a contradiction — there is real work to do and no chat to do it
|
|
211
|
+
* to — and it makes the schema's "exact only when 0" gloss wrong in one
|
|
212
|
+
* direction. Callers must key the banner off `eligible`, never off the count.
|
|
213
|
+
*
|
|
214
|
+
* ### `mode === "host"` is not on its own a reason to refuse
|
|
215
|
+
*
|
|
216
|
+
* The predicate is the same under both modes, which is the #882 ruling (§2)
|
|
217
|
+
* superseding the design's §7.2. `host` shipped as an unconditional refusal on
|
|
218
|
+
* the reasoning "already migrated, nothing to offer", and that is wrong for the
|
|
219
|
+
* one instance that most needs the offer: flipping to `host` while `.chats/`
|
|
220
|
+
* still held anything is #708's STRANDED state. `pointChatsDirAt` declines the
|
|
221
|
+
* redirect against a non-empty real directory (`transcripts.ts:145`), so the old
|
|
222
|
+
* transcripts sit in a real `.chats/` that nothing reads — and the probe told
|
|
223
|
+
* exactly those users there was nothing to do.
|
|
224
|
+
*
|
|
225
|
+
* The distinction is the DIRECTORY, and it is why {@link readChatsDir} `lstat`s:
|
|
226
|
+
*
|
|
227
|
+
* | `.chats/` under `host` | Eligible | Why |
|
|
228
|
+
* |---|---|---|
|
|
229
|
+
* | symlink into the host store | no — `already-host` | migrated; the redirect is planted |
|
|
230
|
+
* | empty real directory | no — `already-host` | the next boot plants it by itself |
|
|
231
|
+
* | non-empty real directory | **yes** | stranded: the redirect was declined |
|
|
232
|
+
*
|
|
233
|
+
* The first two are the overwhelmingly common case and must stay ineligible —
|
|
234
|
+
* showing a migration banner to everyone who has already migrated is worse than
|
|
235
|
+
* the bug this narrows. Under `host` the execute path moves the files and skips
|
|
236
|
+
* the config write (`configWritten: false`, `restartRequired: true`); the next
|
|
237
|
+
* boot then finds an empty `.chats/`, plants the redirect, and the stranded
|
|
238
|
+
* chats reappear. That closes the `own → host` half of #708.
|
|
239
|
+
*/
|
|
240
|
+
export async function probeMigration(input) {
|
|
241
|
+
const base = {
|
|
242
|
+
mode: input.mode,
|
|
243
|
+
pendingChats: 0,
|
|
244
|
+
pendingProjects: 0,
|
|
245
|
+
scannedProjects: 0,
|
|
246
|
+
computedAt: new Date().toISOString(),
|
|
247
|
+
};
|
|
248
|
+
// Cheap refusals first, in the order of how much they tell a client. A
|
|
249
|
+
// shadowing env var comes first because it means the flip CANNOT work (the
|
|
250
|
+
// config write would be inert), which a user needs to know even on a posture
|
|
251
|
+
// that hides the banner.
|
|
252
|
+
//
|
|
253
|
+
// `already-host` is NOT among them, and that is the correction — see the
|
|
254
|
+
// `mode === "host"` note below.
|
|
255
|
+
if (input.envShadowed) {
|
|
256
|
+
return {
|
|
257
|
+
...base,
|
|
258
|
+
eligible: false,
|
|
259
|
+
reason: "env-shadowed",
|
|
260
|
+
envVar: TRANSCRIPTS_ENV_VAR,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// §10.4: `paranoid` chose isolation deliberately, and a permanent offer to
|
|
264
|
+
// undo the posture you picked is nagging. The migration stays reachable from
|
|
265
|
+
// the Config screen whenever mode is `own` — this hides the banner, not the
|
|
266
|
+
// feature.
|
|
267
|
+
//
|
|
268
|
+
// It does not apply under `host`, where the run changes no posture at all: the
|
|
269
|
+
// instance is already on `host` and the migration only makes its own stranded
|
|
270
|
+
// chats reachable again. Suppressing that would be hiding a recovery, not
|
|
271
|
+
// declining to nag.
|
|
272
|
+
if (input.profile === "paranoid" && input.mode !== "host") {
|
|
273
|
+
return { ...base, eligible: false, reason: "profile-paranoid" };
|
|
274
|
+
}
|
|
275
|
+
let pendingChats = 0;
|
|
276
|
+
let pendingProjects = 0;
|
|
277
|
+
let scanned = 0;
|
|
278
|
+
try {
|
|
279
|
+
for (const project of input.projects) {
|
|
280
|
+
scanned++;
|
|
281
|
+
const chatsDir = projectChatsDir(project.dir);
|
|
282
|
+
const key = `${await dirKey(chatsDir)}|${input.configVersion ?? "-"}`;
|
|
283
|
+
const cached = probeCache.get(project.slug);
|
|
284
|
+
let entry;
|
|
285
|
+
if (cached && cached.key === key) {
|
|
286
|
+
entry = cached;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const read = await readChatsDir(chatsDir);
|
|
290
|
+
const listing = read && "listing" in read ? read.listing : null;
|
|
291
|
+
entry = {
|
|
292
|
+
key,
|
|
293
|
+
pending: listing !== null && listing.entryCount > 0,
|
|
294
|
+
chats: listing?.chats.length ?? 0,
|
|
295
|
+
};
|
|
296
|
+
probeCache.set(project.slug, entry);
|
|
297
|
+
}
|
|
298
|
+
if (entry.pending) {
|
|
299
|
+
pendingProjects++;
|
|
300
|
+
pendingChats += entry.chats;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return { ...base, scannedProjects: scanned, eligible: false, reason: "scan-failed" };
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
...base,
|
|
309
|
+
scannedProjects: scanned,
|
|
310
|
+
pendingChats,
|
|
311
|
+
pendingProjects,
|
|
312
|
+
eligible: pendingProjects > 0,
|
|
313
|
+
// Nothing pending under `host` is the HEALTHY migrated instance, and it
|
|
314
|
+
// keeps the reason it has always had. `nothing-pending` would be true but
|
|
315
|
+
// less useful — it says "no work" where `already-host` says "no work, and
|
|
316
|
+
// here is why there never will be".
|
|
317
|
+
...(pendingProjects > 0
|
|
318
|
+
? {}
|
|
319
|
+
: { reason: input.mode === "host" ? "already-host" : "nothing-pending" }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/* -------------------------------------------------------------------------- */
|
|
323
|
+
/* transcript reads */
|
|
324
|
+
/* -------------------------------------------------------------------------- */
|
|
325
|
+
/** The last `bytes` of `file` ending at `end`, and whether that reached byte 0. */
|
|
326
|
+
async function readWindow(file, end, bytes) {
|
|
327
|
+
const take = Math.min(bytes, end);
|
|
328
|
+
const start = end - take;
|
|
329
|
+
const handle = await fs.open(file, "r");
|
|
330
|
+
try {
|
|
331
|
+
const buf = Buffer.alloc(take);
|
|
332
|
+
await handle.read(buf, 0, take, start);
|
|
333
|
+
return { text: buf.toString("utf8"), whole: start === 0 };
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
await handle.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** The `uuid` of the last record in `text`, walking backwards. */
|
|
340
|
+
function lastUuidIn(text, whole) {
|
|
341
|
+
const lines = text.split("\n");
|
|
342
|
+
// A byte offset almost certainly landed mid-line; that fragment is the OLDEST
|
|
343
|
+
// line in the window, so dropping it costs nothing.
|
|
344
|
+
if (!whole)
|
|
345
|
+
lines.shift();
|
|
346
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
347
|
+
const line = lines[i].trim();
|
|
348
|
+
if (!line)
|
|
349
|
+
continue;
|
|
350
|
+
let rec;
|
|
351
|
+
try {
|
|
352
|
+
rec = JSON.parse(line);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (typeof rec.uuid === "string" && rec.uuid.length > 0)
|
|
358
|
+
return rec.uuid;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The `uuid` of a transcript's last record — the thing the ancestor test asks
|
|
364
|
+
* about. One 32 KB tail read, widened once for a transcript whose final record
|
|
365
|
+
* is a multi-megabyte tool result.
|
|
366
|
+
*/
|
|
367
|
+
async function lastRecordUuid(file, size) {
|
|
368
|
+
if (size === 0)
|
|
369
|
+
return undefined;
|
|
370
|
+
try {
|
|
371
|
+
const near = await readWindow(file, size, TAIL_WINDOW_BYTES);
|
|
372
|
+
const found = lastUuidIn(near.text, near.whole);
|
|
373
|
+
if (found !== undefined || near.whole)
|
|
374
|
+
return found;
|
|
375
|
+
const far = await readWindow(file, size, WIDE_TAIL_BYTES);
|
|
376
|
+
return lastUuidIn(far.text, far.whole);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Stage 2: does `uuid` appear in a 32 KB window of `file` ENDING at byte
|
|
384
|
+
* `offset`?
|
|
385
|
+
*
|
|
386
|
+
* This is the measurement the whole cost model rests on. A transcript is
|
|
387
|
+
* append-only, so if the shorter file really is an ancestor of this one, its
|
|
388
|
+
* last record ends exactly at byte `shorter.size` here — the uuid is at a known
|
|
389
|
+
* offset, and finding it needs a read of one window rather than a scan of the
|
|
390
|
+
* file. 298/300 hits and zero misses over the real corpus (design §0.3); the
|
|
391
|
+
* two non-hits were files whose final record exceeded the window, which fall
|
|
392
|
+
* through to stage 3 and are classified correctly there.
|
|
393
|
+
*/
|
|
394
|
+
async function probeAtOffset(file, uuid, offset) {
|
|
395
|
+
if (offset <= 0)
|
|
396
|
+
return false;
|
|
397
|
+
try {
|
|
398
|
+
const { text } = await readWindow(file, offset, PROBE_WINDOW_BYTES);
|
|
399
|
+
return text.includes(uuid);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Stage 3: raw chunked substring scan. No JSON parsing — the question is only
|
|
407
|
+
* "does this uuid occur anywhere", and parsing 256 MB to answer it would cost
|
|
408
|
+
* several times the read.
|
|
409
|
+
*
|
|
410
|
+
* Chunks overlap by the needle's length so a uuid straddling a boundary is
|
|
411
|
+
* still found.
|
|
412
|
+
*/
|
|
413
|
+
async function fullScanFor(file, uuid) {
|
|
414
|
+
const stream = createReadStream(file, {
|
|
415
|
+
encoding: "utf8",
|
|
416
|
+
highWaterMark: SCAN_CHUNK_BYTES,
|
|
417
|
+
});
|
|
418
|
+
let carry = "";
|
|
419
|
+
try {
|
|
420
|
+
for await (const chunk of stream) {
|
|
421
|
+
const text = carry + chunk;
|
|
422
|
+
if (text.includes(uuid))
|
|
423
|
+
return true;
|
|
424
|
+
carry = text.slice(-uuid.length);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
stream.destroy();
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Parse a transcript for the comparison columns: how many real messages it
|
|
437
|
+
* holds and when the last one was. Same `isConversation` rule as the chat
|
|
438
|
+
* list's ordering (#863), imported rather than re-stated — a second copy of
|
|
439
|
+
* that rule would drift and the two numbers would disagree on screen.
|
|
440
|
+
*/
|
|
441
|
+
async function readSideDetail(file) {
|
|
442
|
+
let messageCount = 0;
|
|
443
|
+
let lastMessageAt;
|
|
444
|
+
const stream = createReadStream(file, { encoding: "utf8" });
|
|
445
|
+
let carry = "";
|
|
446
|
+
const consume = (line) => {
|
|
447
|
+
const trimmed = line.trim();
|
|
448
|
+
if (!trimmed)
|
|
449
|
+
return;
|
|
450
|
+
let rec;
|
|
451
|
+
try {
|
|
452
|
+
rec = JSON.parse(trimmed);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (!isConversationRecord(rec))
|
|
458
|
+
return;
|
|
459
|
+
messageCount++;
|
|
460
|
+
const ts = rec.timestamp;
|
|
461
|
+
if (typeof ts === "string" && !Number.isNaN(Date.parse(ts)))
|
|
462
|
+
lastMessageAt = ts;
|
|
463
|
+
};
|
|
464
|
+
try {
|
|
465
|
+
for await (const chunk of stream) {
|
|
466
|
+
const parts = (carry + chunk).split("\n");
|
|
467
|
+
carry = parts.pop() ?? "";
|
|
468
|
+
for (const line of parts)
|
|
469
|
+
consume(line);
|
|
470
|
+
}
|
|
471
|
+
consume(carry);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
/* a partial count is still better than none */
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
stream.destroy();
|
|
478
|
+
}
|
|
479
|
+
return lastMessageAt === undefined ? { messageCount } : { messageCount, lastMessageAt };
|
|
480
|
+
}
|
|
481
|
+
async function sideOf(file) {
|
|
482
|
+
const st = await fs.stat(file).catch(() => null);
|
|
483
|
+
if (!st)
|
|
484
|
+
return null;
|
|
485
|
+
return { path: file, sizeBytes: st.size, mtime: st.mtime.toISOString() };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Build the modal's plan: every chat in every project's `.chats/`, classified
|
|
489
|
+
* against its host store.
|
|
490
|
+
*
|
|
491
|
+
* Chats identical on both sides are omitted from the rows — there is no
|
|
492
|
+
* decision to make — but they are counted in `totals.identical` so a row count
|
|
493
|
+
* lower than the user's chat total always has an explanation. They still
|
|
494
|
+
* migrate: the postcondition is about `.chats/` being empty, not about which
|
|
495
|
+
* chats the user picked.
|
|
496
|
+
*/
|
|
497
|
+
export async function buildMigrationPlan(input) {
|
|
498
|
+
const warnings = [];
|
|
499
|
+
const projects = [];
|
|
500
|
+
const candidates = [];
|
|
501
|
+
let identical = 0;
|
|
502
|
+
if (input.envShadowed) {
|
|
503
|
+
warnings.push({
|
|
504
|
+
code: "env-shadowed",
|
|
505
|
+
message: `${TRANSCRIPTS_ENV_VAR} is set in the environment, which overrides the config ` +
|
|
506
|
+
`file. Writing claude.transcripts: host would have no effect until it is unset.`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
for (const project of input.projects) {
|
|
510
|
+
const scan = await scanProject(project, input);
|
|
511
|
+
if (scan === null)
|
|
512
|
+
continue;
|
|
513
|
+
warnings.push(...scan.warnings);
|
|
514
|
+
candidates.push(...scan.candidates);
|
|
515
|
+
const rows = scan.rows.filter((r) => !r.identical);
|
|
516
|
+
identical += scan.rows.length - rows.length;
|
|
517
|
+
for (const r of rows)
|
|
518
|
+
delete r.identical;
|
|
519
|
+
if (rows.length > 0 || scan.projectExtras.length > 0) {
|
|
520
|
+
projects.push({
|
|
521
|
+
slug: project.slug,
|
|
522
|
+
name: project.name,
|
|
523
|
+
chatsDir: scan.chatsDir,
|
|
524
|
+
hostStore: scan.hostStore,
|
|
525
|
+
preserveDir: scan.preserveDir,
|
|
526
|
+
chats: rows,
|
|
527
|
+
projectExtras: scan.projectExtras,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const scanBudgetExhausted = await settleCandidates(candidates, input.scanBudgetBytes ?? DIVERGENCE_SCAN_BUDGET_BYTES);
|
|
532
|
+
const totals = {
|
|
533
|
+
chats: 0,
|
|
534
|
+
new: 0,
|
|
535
|
+
fastForward: 0,
|
|
536
|
+
diverged: 0,
|
|
537
|
+
unknown: 0,
|
|
538
|
+
identical,
|
|
539
|
+
defaultSelected: 0,
|
|
540
|
+
};
|
|
541
|
+
for (const p of projects) {
|
|
542
|
+
for (const c of p.chats) {
|
|
543
|
+
totals.chats++;
|
|
544
|
+
if (c.state === "new")
|
|
545
|
+
totals.new++;
|
|
546
|
+
else if (c.state === "fast-forward")
|
|
547
|
+
totals.fastForward++;
|
|
548
|
+
else if (c.state === "diverged")
|
|
549
|
+
totals.diverged++;
|
|
550
|
+
else
|
|
551
|
+
totals.unknown++;
|
|
552
|
+
if (c.defaultSelected)
|
|
553
|
+
totals.defaultSelected++;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
mode: input.mode,
|
|
558
|
+
configPath: input.configPath,
|
|
559
|
+
configVersion: input.configVersion,
|
|
560
|
+
projects,
|
|
561
|
+
sweepers: await countSweepers(input),
|
|
562
|
+
totals,
|
|
563
|
+
scanBudgetExhausted,
|
|
564
|
+
warnings,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Classify one project's `.chats/` against its host store.
|
|
569
|
+
*
|
|
570
|
+
* Returns `null` when there is nothing there to migrate: no `.chats/`, an
|
|
571
|
+
* already-planted symlink, or an empty directory. Everything else — including
|
|
572
|
+
* an unreadable store — comes back as a scan carrying its own warnings, because
|
|
573
|
+
* the execute half has to decide what to do about it rather than have it
|
|
574
|
+
* swallowed here.
|
|
575
|
+
*
|
|
576
|
+
* The stage-3 candidates are returned UNSETTLED. Settling them is one shared
|
|
577
|
+
* budget across the whole request, so the caller has to own it — see
|
|
578
|
+
* {@link settleCandidates}.
|
|
579
|
+
*/
|
|
580
|
+
export async function scanProject(project, input) {
|
|
581
|
+
const warnings = [];
|
|
582
|
+
const candidates = [];
|
|
583
|
+
{
|
|
584
|
+
const chatsDir = projectChatsDir(project.dir);
|
|
585
|
+
const hostStore = encodedTranscriptDir(input.userHome, project.workingDir);
|
|
586
|
+
const read = await readChatsDir(chatsDir);
|
|
587
|
+
if (read === null)
|
|
588
|
+
return null;
|
|
589
|
+
if ("error" in read) {
|
|
590
|
+
return {
|
|
591
|
+
ref: project,
|
|
592
|
+
chatsDir,
|
|
593
|
+
hostStore,
|
|
594
|
+
preserveDir: path.join(project.dir, PRESERVE_DIR_NAME),
|
|
595
|
+
listing: {
|
|
596
|
+
chats: [],
|
|
597
|
+
chatDirs: new Set(),
|
|
598
|
+
projectExtras: [],
|
|
599
|
+
unexpected: [],
|
|
600
|
+
hasReverts: false,
|
|
601
|
+
hasMemory: false,
|
|
602
|
+
entryCount: -1,
|
|
603
|
+
},
|
|
604
|
+
hostReadable: false,
|
|
605
|
+
revertsByChat: new Map(),
|
|
606
|
+
rows: [],
|
|
607
|
+
projectExtras: [],
|
|
608
|
+
warnings: [
|
|
609
|
+
{
|
|
610
|
+
code: "chats-dir-unreadable",
|
|
611
|
+
slug: project.slug,
|
|
612
|
+
message: `Could not read ${chatsDir}: ${read.error.message}`,
|
|
613
|
+
paths: [chatsDir],
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
candidates: [],
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
const listing = read.listing;
|
|
620
|
+
if (listing.entryCount === 0)
|
|
621
|
+
return null;
|
|
622
|
+
// Stage 0's other half. ENOENT is the normal case — the user has never run
|
|
623
|
+
// `claude` in this directory — and means every chat is `new`.
|
|
624
|
+
let hostEntries = [];
|
|
625
|
+
try {
|
|
626
|
+
hostEntries = await fs.readdir(hostStore);
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
const e = err;
|
|
630
|
+
if (e.code === "ENOENT") {
|
|
631
|
+
hostEntries = [];
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
// We cannot tell `new` from `diverged` without this. Reporting every
|
|
635
|
+
// chat as `new` would default them all to CHECKED on the strength of a
|
|
636
|
+
// read that failed, so the rows go to `unknown` instead — which already
|
|
637
|
+
// means "not settled" and already defaults to unchecked.
|
|
638
|
+
hostEntries = null;
|
|
639
|
+
warnings.push({
|
|
640
|
+
code: "host-store-unreadable",
|
|
641
|
+
slug: project.slug,
|
|
642
|
+
message: `Could not read ${hostStore}: ${e.message}. Every chat in this project is ` +
|
|
643
|
+
`reported as \`unknown\` because new and diverged cannot be told apart ` +
|
|
644
|
+
`without it.`,
|
|
645
|
+
paths: [hostStore],
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const hostChats = new Set((hostEntries ?? [])
|
|
650
|
+
.filter((n) => n.endsWith(".jsonl") && SESSION_ID_RE.test(n.slice(0, -6)))
|
|
651
|
+
.map((n) => n.slice(0, -6)));
|
|
652
|
+
// `.reverts/` is shared across sessions (`herdctl.ts:1988`), so a partial
|
|
653
|
+
// migration has to split it by prefix. One readdir, grouped once.
|
|
654
|
+
const revertsByChat = new Map();
|
|
655
|
+
if (listing.hasReverts) {
|
|
656
|
+
const revertsDir = path.join(chatsDir, ".reverts");
|
|
657
|
+
const chatIds = new Set(listing.chats);
|
|
658
|
+
for (const name of await fs.readdir(revertsDir).catch(() => [])) {
|
|
659
|
+
// `<id>-<stamp>.jsonl`, where the id itself contains dashes. Resolved by
|
|
660
|
+
// testing each dash position against the ids actually present rather
|
|
661
|
+
// than by assuming a 36-char UUID — session ids are only guaranteed to
|
|
662
|
+
// match `[A-Za-z0-9._-]+`, and a fixed offset silently mis-files every
|
|
663
|
+
// revert belonging to a chat named anything else.
|
|
664
|
+
const owner = ownerOfRevert(name, chatIds);
|
|
665
|
+
if (owner === undefined) {
|
|
666
|
+
// A revert for a chat that is no longer here. It still has to move —
|
|
667
|
+
// the postcondition is about the directory, not about the rows.
|
|
668
|
+
listing.projectExtras.push(path.join(revertsDir, name));
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
// BASE NAMES, not paths: the execute half replays them against BOTH
|
|
672
|
+
// store roots (a superseded host copy has its own `.reverts/<name>`),
|
|
673
|
+
// and a path baked to `.chats/` cannot be replayed against `~/.claude`.
|
|
674
|
+
const list = revertsByChat.get(owner) ?? [];
|
|
675
|
+
list.push(name);
|
|
676
|
+
revertsByChat.set(owner, list);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (listing.hasMemory) {
|
|
680
|
+
await collectMemoryCollisions(chatsDir, hostStore, project.slug, warnings);
|
|
681
|
+
}
|
|
682
|
+
if (listing.unexpected.length > 0) {
|
|
683
|
+
warnings.push({
|
|
684
|
+
code: "unexpected-entries",
|
|
685
|
+
slug: project.slug,
|
|
686
|
+
message: `${listing.unexpected.length} ` +
|
|
687
|
+
(listing.unexpected.length === 1 ? "entry" : "entries") +
|
|
688
|
+
` in ${chatsDir}: neither a transcript nor a known sidecar. Still migrated — ` +
|
|
689
|
+
`\`.chats/\` has to end up empty for the redirect symlink to be planted — and ` +
|
|
690
|
+
`named here rather than moving unannounced.`,
|
|
691
|
+
paths: listing.unexpected,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const rows = [];
|
|
695
|
+
for (const sessionId of listing.chats.sort()) {
|
|
696
|
+
const ownPath = path.join(chatsDir, `${sessionId}.jsonl`);
|
|
697
|
+
const own = await sideOf(ownPath);
|
|
698
|
+
if (!own)
|
|
699
|
+
continue; // vanished between readdir and stat
|
|
700
|
+
const extras = [];
|
|
701
|
+
if (listing.chatDirs.has(sessionId)) {
|
|
702
|
+
const dir = path.join(chatsDir, sessionId);
|
|
703
|
+
const kids = await fs.readdir(dir).catch(() => []);
|
|
704
|
+
extras.push(...(kids.length ? kids.map((k) => path.join(dir, k)) : [dir]));
|
|
705
|
+
}
|
|
706
|
+
extras.push(...(revertsByChat.get(sessionId) ?? []).map((n) => path.join(chatsDir, ".reverts", n)));
|
|
707
|
+
const meta = input.names?.get(sessionId);
|
|
708
|
+
const row = {
|
|
709
|
+
sessionId,
|
|
710
|
+
...(meta?.name ? { name: meta.name } : {}),
|
|
711
|
+
...(meta?.preview ? { preview: meta.preview } : {}),
|
|
712
|
+
state: "new",
|
|
713
|
+
defaultSelected: true,
|
|
714
|
+
own,
|
|
715
|
+
extras,
|
|
716
|
+
};
|
|
717
|
+
if (hostEntries === null) {
|
|
718
|
+
row.state = "unknown";
|
|
719
|
+
row.defaultSelected = false;
|
|
720
|
+
rows.push(row);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (!hostChats.has(sessionId)) {
|
|
724
|
+
rows.push(row); // `new` — settled with no stat of the other side, no read
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const host = await sideOf(path.join(hostStore, `${sessionId}.jsonl`));
|
|
728
|
+
if (!host) {
|
|
729
|
+
rows.push(row);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
row.host = host;
|
|
733
|
+
// Stage 1.
|
|
734
|
+
if (own.sizeBytes === host.sizeBytes) {
|
|
735
|
+
if (own.mtime === host.mtime) {
|
|
736
|
+
row.identical = true;
|
|
737
|
+
rows.push(row);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
// Same length, different mtime. Append-only means an ancestor is
|
|
741
|
+
// strictly shorter unless it is the same content, so one tail read each
|
|
742
|
+
// settles it without any scan: same last uuid ⇒ same file.
|
|
743
|
+
const [a, b] = await Promise.all([
|
|
744
|
+
lastRecordUuid(own.path, own.sizeBytes),
|
|
745
|
+
lastRecordUuid(host.path, host.sizeBytes),
|
|
746
|
+
]);
|
|
747
|
+
if (a !== undefined && a === b) {
|
|
748
|
+
row.identical = true;
|
|
749
|
+
rows.push(row);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
row.state = "diverged";
|
|
753
|
+
row.defaultSelected = false;
|
|
754
|
+
candidates.push({
|
|
755
|
+
row,
|
|
756
|
+
longerPath: own.path,
|
|
757
|
+
longerBytes: own.sizeBytes,
|
|
758
|
+
shorterPath: host.path,
|
|
759
|
+
shorterBytes: host.sizeBytes,
|
|
760
|
+
longerSide: "own",
|
|
761
|
+
uuid: undefined, // already settled; queued only for its detail columns
|
|
762
|
+
});
|
|
763
|
+
rows.push(row);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const ownLonger = own.sizeBytes > host.sizeBytes;
|
|
767
|
+
const longer = ownLonger ? own : host;
|
|
768
|
+
const shorter = ownLonger ? host : own;
|
|
769
|
+
// Stage 2 — the bounded probe at the known offset.
|
|
770
|
+
const uuid = await lastRecordUuid(shorter.path, shorter.sizeBytes);
|
|
771
|
+
if (uuid !== undefined && (await probeAtOffset(longer.path, uuid, shorter.sizeBytes))) {
|
|
772
|
+
row.state = "fast-forward";
|
|
773
|
+
row.defaultSelected = true;
|
|
774
|
+
row.ahead = ownLonger ? "own" : "host";
|
|
775
|
+
rows.push(row);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// Stage 3, deferred: it is the only unbounded read in the classifier, so
|
|
779
|
+
// the candidates are collected and run smallest-first against a shared
|
|
780
|
+
// budget rather than each one spending whatever it likes.
|
|
781
|
+
row.state = "diverged";
|
|
782
|
+
row.defaultSelected = false;
|
|
783
|
+
candidates.push({
|
|
784
|
+
row,
|
|
785
|
+
longerPath: longer.path,
|
|
786
|
+
longerBytes: longer.sizeBytes,
|
|
787
|
+
shorterPath: shorter.path,
|
|
788
|
+
shorterBytes: shorter.sizeBytes,
|
|
789
|
+
longerSide: ownLonger ? "own" : "host",
|
|
790
|
+
uuid,
|
|
791
|
+
});
|
|
792
|
+
rows.push(row);
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
ref: project,
|
|
796
|
+
chatsDir,
|
|
797
|
+
hostStore,
|
|
798
|
+
preserveDir: path.join(project.dir, PRESERVE_DIR_NAME),
|
|
799
|
+
listing,
|
|
800
|
+
hostReadable: hostEntries !== null,
|
|
801
|
+
revertsByChat,
|
|
802
|
+
rows,
|
|
803
|
+
projectExtras: listing.projectExtras,
|
|
804
|
+
warnings,
|
|
805
|
+
candidates,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Stage 3 over the deferred candidates, smallest-first against one shared
|
|
811
|
+
* budget. Ascending order means the budget settles as many rows as it can
|
|
812
|
+
* before it runs out, and — because it is ascending — the first row that does
|
|
813
|
+
* not fit proves every later one does not either.
|
|
814
|
+
*
|
|
815
|
+
* Returns whether any row came back `unknown`.
|
|
816
|
+
*/
|
|
817
|
+
export async function settleCandidates(candidates, budgetBytes) {
|
|
818
|
+
let remaining = budgetBytes;
|
|
819
|
+
let exhausted = false;
|
|
820
|
+
candidates.sort((a, b) => a.longerBytes - b.longerBytes);
|
|
821
|
+
for (const c of candidates) {
|
|
822
|
+
if (c.uuid !== undefined) {
|
|
823
|
+
if (remaining < c.longerBytes) {
|
|
824
|
+
exhausted = true;
|
|
825
|
+
c.row.state = "unknown";
|
|
826
|
+
c.row.defaultSelected = false;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
remaining -= c.longerBytes;
|
|
830
|
+
if (await fullScanFor(c.longerPath, c.uuid)) {
|
|
831
|
+
// A rewritten history: the ancestor's tip is in there, just not where
|
|
832
|
+
// an append-only file would have put it. Still lossless.
|
|
833
|
+
c.row.state = "fast-forward";
|
|
834
|
+
c.row.defaultSelected = true;
|
|
835
|
+
c.row.ahead = c.longerSide;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Confirmed diverged. The comparison columns are what makes the user's
|
|
840
|
+
// choice informed, and they cost a parse of both sides — charged to the
|
|
841
|
+
// same budget, and simply omitted when it will not stretch. An absent
|
|
842
|
+
// `messageCount` is a legitimate answer meaning "we stopped reading".
|
|
843
|
+
const detailCost = c.longerBytes + c.shorterBytes;
|
|
844
|
+
if (remaining < detailCost) {
|
|
845
|
+
exhausted = true;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
remaining -= detailCost;
|
|
849
|
+
const [ownDetail, hostDetail] = await Promise.all([
|
|
850
|
+
readSideDetail(c.row.own.path),
|
|
851
|
+
c.row.host ? readSideDetail(c.row.host.path) : Promise.resolve(null),
|
|
852
|
+
]);
|
|
853
|
+
Object.assign(c.row.own, ownDetail);
|
|
854
|
+
if (c.row.host && hostDetail)
|
|
855
|
+
Object.assign(c.row.host, hostDetail);
|
|
856
|
+
}
|
|
857
|
+
return exhausted;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* `memory/` is merged at FILE granularity and never overwrites anything in the
|
|
861
|
+
* user's real `~/.claude` (§10.2). This PR only reads, but the collisions have
|
|
862
|
+
* to be surfaced now: `memory/MEMORY.md` is a single well-known path, so on the
|
|
863
|
+
* machines this feature is aimed at a collision is the COMMON case — and the
|
|
864
|
+
* skip-if-present rule the transcripts use would leave `memory/` sitting in
|
|
865
|
+
* `.chats/`, fail the postcondition, and block the flip for the whole project.
|
|
866
|
+
*/
|
|
867
|
+
async function collectMemoryCollisions(chatsDir, hostStore, slug, warnings) {
|
|
868
|
+
const from = path.join(chatsDir, "memory");
|
|
869
|
+
const to = path.join(hostStore, "memory");
|
|
870
|
+
const collisions = [];
|
|
871
|
+
const walk = async (rel) => {
|
|
872
|
+
const entries = await fs
|
|
873
|
+
.readdir(path.join(from, rel), { withFileTypes: true })
|
|
874
|
+
.catch(() => []);
|
|
875
|
+
for (const e of entries) {
|
|
876
|
+
const next = path.join(rel, e.name);
|
|
877
|
+
if (e.isDirectory()) {
|
|
878
|
+
await walk(next);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
const dest = path.join(to, next);
|
|
882
|
+
if (await fs.lstat(dest).then(() => true).catch(() => false)) {
|
|
883
|
+
collisions.push(path.join(from, next), dest);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
await walk("");
|
|
888
|
+
if (collisions.length === 0)
|
|
889
|
+
return;
|
|
890
|
+
warnings.push({
|
|
891
|
+
code: "memory-collision",
|
|
892
|
+
slug,
|
|
893
|
+
message: `${collisions.length / 2} agent-memory file(s) already exist in the host store. ` +
|
|
894
|
+
`Nothing in your own ~/.claude is ever overwritten: Paddock's copies are set ` +
|
|
895
|
+
`aside under ${path.join(path.dirname(chatsDir), PRESERVE_DIR_NAME, "memory")} ` +
|
|
896
|
+
`for you to merge by hand.`,
|
|
897
|
+
paths: collisions,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Sweeper stores (`<dataDir>/sweepers/<slug>/.chats`), which get their own
|
|
902
|
+
* redirect symlink exactly like a project's and would be split by a
|
|
903
|
+
* project-only migration. Migrated silently with their project — no rows, no
|
|
904
|
+
* user choice (#882) — so they are reported as counts alone.
|
|
905
|
+
*/
|
|
906
|
+
async function countSweepers(input) {
|
|
907
|
+
let stores = 0;
|
|
908
|
+
let chats = 0;
|
|
909
|
+
for (const dir of input.sweeperDirs?.values() ?? []) {
|
|
910
|
+
const read = await readChatsDir(projectChatsDir(dir));
|
|
911
|
+
if (read === null || "error" in read || read.listing.entryCount === 0)
|
|
912
|
+
continue;
|
|
913
|
+
stores++;
|
|
914
|
+
chats += read.listing.chats.length;
|
|
915
|
+
}
|
|
916
|
+
return { stores, chats };
|
|
917
|
+
}
|