@edspencer/paddock 0.71.2 → 0.72.1

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.
Files changed (106) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +27 -0
  3. package/packages/server/dist/cli/args.d.ts +56 -5
  4. package/packages/server/dist/cli/args.js +193 -31
  5. package/packages/server/dist/cli/config.d.ts +157 -0
  6. package/packages/server/dist/cli/config.js +462 -0
  7. package/packages/server/dist/cli/paddock.js +53 -3
  8. package/packages/server/dist/config.d.ts +36 -1
  9. package/packages/server/dist/config.js +59 -44
  10. package/packages/server/dist/herdctl.d.ts +45 -0
  11. package/packages/server/dist/herdctl.js +58 -4
  12. package/packages/server/dist/instance-config.d.ts +149 -21
  13. package/packages/server/dist/instance-config.js +228 -5
  14. package/packages/server/dist/last-activity.d.ts +21 -0
  15. package/packages/server/dist/last-activity.js +7 -2
  16. package/packages/server/dist/profiles.d.ts +154 -0
  17. package/packages/server/dist/profiles.js +214 -0
  18. package/packages/server/dist/routes/transcripts.d.ts +61 -0
  19. package/packages/server/dist/routes/transcripts.js +676 -0
  20. package/packages/server/dist/routes.d.ts +3 -0
  21. package/packages/server/dist/routes.js +2 -0
  22. package/packages/server/dist/transcript-move.d.ts +62 -0
  23. package/packages/server/dist/transcript-move.js +179 -0
  24. package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
  25. package/packages/server/dist/transcripts-migration-execute.js +630 -0
  26. package/packages/server/dist/transcripts-migration.d.ts +336 -0
  27. package/packages/server/dist/transcripts-migration.js +917 -0
  28. package/packages/server/dist/transcripts.d.ts +46 -2
  29. package/packages/server/dist/transcripts.js +126 -3
  30. package/packages/server/package.json +1 -1
  31. package/packages/web/dist/assets/DiscoverPage-DhrXvgkl.js +2 -0
  32. package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +2 -0
  33. package/packages/web/dist/assets/InstanceConfigPage-aOgiBFLq.js +2 -0
  34. package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-BjrB0TDO.js} +1 -1
  35. package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +2 -0
  36. package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-N4g7WrZm.js} +1 -1
  37. package/packages/web/dist/assets/ProjectView-B-7AIt--.js +13 -0
  38. package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-DZF5VHJF.js} +1 -1
  39. package/packages/web/dist/assets/RootHome-C9wEtWHu.js +2 -0
  40. package/packages/web/dist/assets/Surfaces-BidCkENw.js +2 -0
  41. package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-BctfAqLY.js} +1 -1
  42. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BJgwSqOO.js +37 -0
  43. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-BD2skmST.js} +1 -1
  44. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-DckMrhMv.js} +1 -1
  45. package/packages/web/dist/assets/channel-CsZOz8jm.js +2 -0
  46. package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-CQDUH7rp.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-Bae1J961.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-CXxS5wOa.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-DSNFkPB3.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-B2mqFyB7.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BfqEy3lu.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-Bm6CBVK7.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-NmnLtgYc.js} +1 -1
  54. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +2 -0
  55. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +2 -0
  56. package/packages/web/dist/assets/{core-DnpcT0s6.js → core-YKeFSK1q.js} +1 -1
  57. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-qf_mXpa9.js +2 -0
  58. package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-C31v-j3s.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-C_TiPHdS.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-BC8gUFK-.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-vO_QYWxi.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-vxJ-YZOg.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-BNIfJG6l.js} +1 -1
  64. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-B37HsOFM.js} +1 -1
  65. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DqA5X81H.js} +1 -1
  66. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BMP0zpIg.js +293 -0
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-P0nXaCzx.js} +1 -1
  68. package/packages/web/dist/assets/index-BkY_Nc_i.css +1 -0
  69. package/packages/web/dist/assets/index-C3MdluUb.js +71 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-DpKYQ7VK.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BZeRMg3C.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-Cg2O0qzv.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-DBwVxUvI.js} +1 -1
  74. package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-R8qRwYqx.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-jctTs2en.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-CtmsNfpb.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C-UiLFDv.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-BS4SWa01.js} +1 -1
  79. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-LPPAC6Q1.js} +1 -1
  80. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-tjF8KyS3.js} +1 -1
  81. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-kmddkdk2.js} +1 -1
  82. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-B07mykTY.js} +1 -1
  83. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +2 -0
  84. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-DrUgi0cT.js} +1 -1
  85. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-Dz6p0m7l.js} +1 -1
  86. package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DM16lA5x.js} +1 -1
  87. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CCFQkFGB.js} +1 -1
  88. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DiUI5Dos.js} +1 -1
  89. package/packages/web/dist/index.html +2 -2
  90. package/packages/web/dist/sw.js +1 -1
  91. package/packages/web/dist/assets/Chip-Cp32dDmz.js +0 -2
  92. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +0 -2
  93. package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +0 -2
  94. package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +0 -2
  95. package/packages/web/dist/assets/ProjectMenu-DXXXtXeh.js +0 -2
  96. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +0 -13
  97. package/packages/web/dist/assets/RootHome-rWodzOC8.js +0 -2
  98. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-UzIlxaCQ.js +0 -37
  99. package/packages/web/dist/assets/channel-C-sodtn7.js +0 -2
  100. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +0 -2
  101. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +0 -2
  102. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-B-4NadKI.js +0 -2
  103. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BxliloOH.js +0 -293
  104. package/packages/web/dist/assets/index-Cx9y6c3f.js +0 -71
  105. package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
  106. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +0 -2
@@ -0,0 +1,676 @@
1
+ import { instanceConfigPath, instanceConfigVersion, pendingTranscriptsMode, writeInstanceConfig, } from "../instance-config.js";
2
+ import { ROOT_KEY } from "../project-paths.js";
3
+ import { sweeperWorkingDir } from "../herdctl-agent-config.js";
4
+ import { quiesceProject, turnRunningError } from "../turn-interlock.js";
5
+ import { buildMigrationPlan, probeMigration, resetMigrationProbeCache, TRANSCRIPTS_ENV_VAR, } from "../transcripts-migration.js";
6
+ import { executeMigration } from "../transcripts-migration-execute.js";
7
+ /** `MigrationState` — the three-state classification plus the budget escape. */
8
+ const stateSchema = {
9
+ type: "string",
10
+ enum: ["new", "fast-forward", "diverged", "unknown"],
11
+ description: "new = no counterpart in the host store (default checked). fast-forward = a counterpart exists and one side is strictly ahead; lossless (default checked). diverged = both sides advanced independently (default UNCHECKED, requires an explicit choice). unknown = the divergence scan budget was exhausted before this row could be settled, or the host store could not be read; treated as diverged (default unchecked).",
12
+ };
13
+ /** `MigrationSide` — one copy of a chat, for the diverged row's columns. */
14
+ const sideSchema = {
15
+ type: "object",
16
+ additionalProperties: false,
17
+ required: ["path", "sizeBytes", "mtime"],
18
+ properties: {
19
+ path: { type: "string", description: "Absolute path of this copy." },
20
+ sizeBytes: { type: "integer", description: "Transcript size in bytes." },
21
+ mtime: {
22
+ type: "string",
23
+ format: "date-time",
24
+ description: "Filesystem mtime. Present always; NOT a proxy for activity (#863).",
25
+ },
26
+ messageCount: {
27
+ type: "integer",
28
+ description: "Conversation records in this copy — `user`/`assistant`, excluding meta and task-notification records, the same rule as last-activity.ts. Present ONLY on `diverged` rows, and only while the scan budget lasts: counting requires a full parse, so populating it on every row would mean reading every transcript on the instance to render a table whose other rows need no decision.",
29
+ },
30
+ lastMessageAt: {
31
+ type: "string",
32
+ format: "date-time",
33
+ description: "ISO 8601 timestamp of the last real message. Present under the same rule as `messageCount`; absent when the transcript holds no datable conversation record, in which case the client falls back to `mtime`.",
34
+ },
35
+ },
36
+ };
37
+ const chatRowSchema = {
38
+ type: "object",
39
+ additionalProperties: false,
40
+ required: ["sessionId", "state", "defaultSelected", "own", "extras"],
41
+ properties: {
42
+ sessionId: { type: "string", description: "The chat id. Never rewritten by the migration." },
43
+ name: {
44
+ type: "string",
45
+ description: "Display name: the chat's set name, else its auto-name. Absent when neither exists.",
46
+ },
47
+ preview: { type: "string", description: "First user message, truncated. Absent when unreadable." },
48
+ state: stateSchema,
49
+ defaultSelected: {
50
+ type: "boolean",
51
+ description: "How the checkbox starts: true for new and fast-forward, false for diverged and unknown.",
52
+ },
53
+ own: { ...sideSchema, description: "The copy in `.chats/`. Always present." },
54
+ host: {
55
+ ...sideSchema,
56
+ description: "The copy in the host store. Absent if and only if state is `new` (or `unknown` because the store could not be read).",
57
+ },
58
+ ahead: {
59
+ type: "string",
60
+ enum: ["own", "host"],
61
+ description: "For `fast-forward`, which side is the descendant and will therefore survive. Absent for other states.",
62
+ },
63
+ extras: {
64
+ type: "array",
65
+ description: "Sidecars that move with THIS chat: the contents of its `<id>/` directory (`subagents/`, `tool-results/`) and prefix-matched `.reverts/<id>-*.jsonl`. Listed so the completion summary can be specific about what moved.",
66
+ items: { type: "string" },
67
+ },
68
+ },
69
+ };
70
+ const projectSchema = {
71
+ type: "object",
72
+ additionalProperties: false,
73
+ required: ["slug", "name", "chatsDir", "hostStore", "preserveDir", "chats", "projectExtras"],
74
+ properties: {
75
+ slug: { type: "string", description: "Project slug; the empty string is the root workspace." },
76
+ name: { type: "string", description: "Display name, for the group header." },
77
+ chatsDir: { type: "string", description: "Absolute path of the source `<project.dir>/.chats/`." },
78
+ hostStore: {
79
+ type: "string",
80
+ description: "Absolute destination `~/.claude/projects/<encoded-workingDir>/`. Keyed on workingDir, which for a repo-backed or linked project is the checkout, NOT the project dir.",
81
+ },
82
+ preserveDir: {
83
+ type: "string",
84
+ description: "Absolute path unchecked chats are moved to. A SIBLING of `.chats/`, not a child: a `pre-migration/` directory INSIDE `.chats/` would leave it non-empty, which makes `pointChatsDirAt` decline the redirect symlink and ships #708's own symptom. See DESIGN-transcripts-migration.md §5.1.",
85
+ },
86
+ chats: { type: "array", items: chatRowSchema },
87
+ projectExtras: {
88
+ type: "array",
89
+ description: "Entries of `.chats/` that move with the PROJECT rather than with any one chat: `memory/`, flat `agent-<hex>.jsonl` sidechain transcripts, an orphaned `<id>/` with no transcript. They move because the postcondition is that `.chats/` ends up empty, and they are named so nothing moves unannounced.",
90
+ items: { type: "string" },
91
+ },
92
+ },
93
+ };
94
+ const warningSchema = {
95
+ type: "object",
96
+ additionalProperties: false,
97
+ required: ["code", "message"],
98
+ properties: {
99
+ code: {
100
+ type: "string",
101
+ enum: [
102
+ "host-store-unreadable",
103
+ "chats-dir-unreadable",
104
+ "env-shadowed",
105
+ "memory-collision",
106
+ "unexpected-entries",
107
+ ],
108
+ description: "`unexpected-entries` means `.chats/` holds entries that are neither transcripts nor known sidecars; they will still be moved (the postcondition is that `.chats/` ends up empty), but they are named so nothing moves unannounced. `memory-collision` means the host store already has an agent-memory file at the same relative path: Paddock's copy is set aside in the preserve dir rather than overwriting anything in your own ~/.claude.",
109
+ },
110
+ slug: { type: "string", description: "The project it applies to. Absent for instance-wide warnings." },
111
+ message: { type: "string", description: "Human-readable detail, safe to render." },
112
+ paths: {
113
+ type: "array",
114
+ items: { type: "string" },
115
+ description: "Absolute paths the warning concerns. For `memory-collision` they are in source/destination pairs.",
116
+ },
117
+ },
118
+ };
119
+ /* -------------------------------------------------------------------------- */
120
+ /* POST schemas */
121
+ /* -------------------------------------------------------------------------- */
122
+ const preservedSchema = {
123
+ type: "object",
124
+ additionalProperties: false,
125
+ required: ["sessionId", "slug", "side", "path", "reason"],
126
+ properties: {
127
+ sessionId: {
128
+ type: "string",
129
+ description: "The chat id — or, for an agent-memory file set aside by the `memory/` merge, its path relative to the store (`memory/MEMORY.md`). A memory file is not a chat and is in this array anyway: this is the one place the completion screen renders as the recovery path, and a recovery path with a hole in it is worse than a slightly loose field name.",
130
+ },
131
+ slug: { type: "string" },
132
+ side: {
133
+ type: "string",
134
+ enum: ["own", "host"],
135
+ description: "Which store the preserved copy came OUT of. `own` is Paddock's copy; `host` is the user's own, moved aside so Paddock's could supersede it. Not in the design's schema, and needed: a completion screen that told a user their terminal transcript was 'not ticked' would be both false and alarming.",
136
+ },
137
+ path: { type: "string", description: "Absolute path of the preserved transcript, as actually written." },
138
+ reason: {
139
+ type: "string",
140
+ enum: ["unchecked", "unplanned-diverged", "identical", "already-ahead", "superseded"],
141
+ description: "`unchecked` = the user did not tick it. `unplanned-diverged` = it appeared after the plan was built and classified diverged, so its own default was applied. `identical` = byte-identical on both sides, so no choice was ever offered and Paddock's redundant copy was set aside. `already-ahead` = a fast-forward with the HOST side ahead: the user's copy is the descendant and survives. `superseded` = the user's copy was moved out of ~/.claude BEFORE Paddock's landed on top. The last three are additions: replacing the design's skip-if-present rule (see the endpoint description) made them reachable.",
142
+ },
143
+ },
144
+ };
145
+ const failedSchema = {
146
+ type: "object",
147
+ additionalProperties: false,
148
+ required: ["sessionId", "slug", "reason"],
149
+ properties: {
150
+ sessionId: { type: "string" },
151
+ slug: { type: "string" },
152
+ reason: {
153
+ type: "string",
154
+ enum: ["destination-exists", "unreadable", "move-failed", "preserve-failed", "unknown"],
155
+ description: "Open vocabulary in practice: a client must render an unrecognised value verbatim rather than swallow it, as discoverImport.ts already does for adoption reasons.",
156
+ },
157
+ message: { type: "string" },
158
+ path: {
159
+ type: "string",
160
+ description: "The path the failure is about. Present because a project and its sweeper store share a slug, so the slug alone cannot say which one failed.",
161
+ },
162
+ },
163
+ };
164
+ const projectResultSchema = {
165
+ type: "object",
166
+ additionalProperties: false,
167
+ required: ["slug", "outcome", "migrated", "preserved", "chatsDirEmpty"],
168
+ properties: {
169
+ slug: { type: "string" },
170
+ outcome: {
171
+ type: "string",
172
+ enum: ["migrated", "nothing-to-do", "skipped-busy", "failed"],
173
+ description: "`skipped-busy` means a turn woke up between the quiesce and this project's moves and it was abandoned UNTOUCHED (#731). A skipped project is never counted as migrated.",
174
+ },
175
+ migrated: { type: "integer", description: "Chats moved into the host store." },
176
+ preserved: { type: "integer", description: "Chat copies moved to the preserve dir, from either side." },
177
+ chatsDirEmpty: {
178
+ type: "boolean",
179
+ description: "Whether `<project.dir>/.chats/` is empty afterwards. FALSE IS A FAILURE — a non-empty `.chats/` means `pointChatsDirAt` declines the redirect symlink on restart and the project is half-blind (#708). The config is not written while any project reports false.",
180
+ },
181
+ error: { type: "string", description: "Present only when the outcome is `failed` or `skipped-busy`." },
182
+ },
183
+ };
184
+ /**
185
+ * §4.3 — one execute at a time, instance-wide.
186
+ *
187
+ * The client-side `if (busy) return` guard is not enough: it does not survive a
188
+ * reload and does not exist in a second tab. In-memory and restart-forgetting,
189
+ * which is correct here for the same reason the undo map is
190
+ * (`herdctl.ts:973-987`) — and doubly so, since this flow ENDS in a restart.
191
+ *
192
+ * Armed synchronously, before the handler's first `await`, which is what makes
193
+ * it airtight on a single-threaded event loop.
194
+ */
195
+ let migrationInFlight = null;
196
+ /** Drop the single-flight latch. For tests, and for a process that somehow
197
+ * leaked one — a stuck latch would refuse every future migration. */
198
+ export function resetMigrationSingleFlight() {
199
+ migrationInFlight = null;
200
+ }
201
+ /** Same guard as `readFirstUserText`: a session id must stay inside `.chats/`. */
202
+ const SAFE_SESSION_ID = /^[A-Za-z0-9._-]+$/;
203
+ export function registerTranscriptsRoutes(app, ctx) {
204
+ const { cfg, projects, herdctl } = ctx;
205
+ /** Everything the classifier needs, assembled per request. */
206
+ async function input(slug) {
207
+ const all = [await projects.get(ROOT_KEY), ...(await projects.list())];
208
+ // `slug === ""` is the ROOT workspace and is falsy — `if (!slug)` would
209
+ // silently drop the one workspace every instance has. The presence test is
210
+ // against `undefined`, deliberately.
211
+ const wanted = slug === undefined ? all : all.filter((p) => p.slug === slug);
212
+ const refs = wanted.map((p) => ({
213
+ slug: p.slug,
214
+ name: p.name,
215
+ dir: p.dir,
216
+ workingDir: p.workingDir,
217
+ }));
218
+ const configPath = instanceConfigPath(cfg);
219
+ return {
220
+ mode: cfg.claude.transcripts,
221
+ profile: cfg.profile,
222
+ envShadowed: (process.env[TRANSCRIPTS_ENV_VAR] ?? "").trim().length > 0,
223
+ projects: refs,
224
+ // The user's own home, taken from the one place config resolves it, so
225
+ // this can never disagree with the home `ensureProjectChats` writes into
226
+ // under `host`.
227
+ userHome: cfg.legacyClaudeHome,
228
+ configPath,
229
+ configVersion: instanceConfigVersion(configPath),
230
+ sweeperDirs: new Map(refs.map((p) => [p.slug, sweeperWorkingDir(cfg, p.slug)])),
231
+ };
232
+ }
233
+ app.get("/api/transcripts/migration", {
234
+ schema: {
235
+ tags: ["System"],
236
+ summary: "Whether this instance has transcripts to migrate from own to host",
237
+ description: "The banner's probe (#882). Answers 'is there anything to migrate?' without classifying anything and without reading a transcript or the host store: it readdirs each project's `.chats/` and asks whether it is non-empty. That is the right question because of the migration's postcondition — the redirect symlink is only planted if `.chats/` ends up EMPTY, so every entry has to move, including a chat that is byte-identical on both sides. (An earlier draft short-circuited on the first chat absent from the host store, which is invisible to a user who adopted their CLI history and then worked on both sides: every id exists in both places, so there are zero such chats and the banner never appeared for the exact person the feature is for.) Memoised per project on `mtimeMs:size` of `.chats/` plus the config version, so it costs one stat per project when nothing has changed and is cheap enough for every page load. `eligible` is false with a `reason` when PADDOCK_CLAUDE_TRANSCRIPTS shadows the config file (the write would be inert), when the posture profile is `paranoid` and mode is `own` (which chose isolation deliberately — the migration stays reachable from the Config screen), or when nothing is pending. **Mode alone is NOT a reason.** `host` shipped as an unconditional `already-host` refusal, which was wrong for the instance that needs the offer most: one that flipped to `host` while `.chats/` was non-empty is #708's STRANDED state — `pointChatsDirAt` declines the redirect against a non-empty real directory, so the pre-flip transcripts sit in a `.chats/` nothing can read, and the probe told exactly those users there was nothing to do (#882 §2, superseding DESIGN-transcripts-migration.md §7.2). The predicate is now the same under both modes and the distinction is the DIRECTORY: under `host`, a `.chats` SYMLINK into the host store or an EMPTY real directory is the healthy, already-migrated instance and keeps reporting `already-host`; only a NON-EMPTY REAL directory is eligible. Under `host` the run is a recovery rather than a flip — execute moves the files and skips the config write (`configWritten: false`, `restartRequired: true`), and the next boot plants the redirect. `pendingChats` counts transcripts and is contractually a LOWER BOUND; eligibility is about ENTRIES, so a `.chats/` holding only `memory/` is eligible with a count of 0. Key the banner off `eligible`, never off the count.",
238
+ response: {
239
+ 200: {
240
+ description: "Whether a migration is available, and roughly how much.",
241
+ type: "object",
242
+ additionalProperties: false,
243
+ required: [
244
+ "mode",
245
+ "eligible",
246
+ "pendingChats",
247
+ "pendingProjects",
248
+ "scannedProjects",
249
+ "computedAt",
250
+ ],
251
+ properties: {
252
+ mode: {
253
+ type: "string",
254
+ enum: ["own", "host"],
255
+ description: "The transcripts mode this process resolved at boot.",
256
+ },
257
+ eligible: {
258
+ type: "boolean",
259
+ description: "True when a migration is available to offer. The banner shows if and only if this is true.",
260
+ },
261
+ reason: {
262
+ type: "string",
263
+ enum: [
264
+ "already-host",
265
+ "env-shadowed",
266
+ "profile-paranoid",
267
+ "nothing-pending",
268
+ "scan-failed",
269
+ ],
270
+ description: "Why `eligible` is false; absent when it is true. Reported in that order of precedence, so a `paranoid` instance whose config write would also be inert reports `env-shadowed` — the blocking condition beats the posture one. `already-host` means MIGRATED, not merely on `host`: it is reported when mode is `host` and every `.chats/` is a symlink into the host store or an empty real directory. A `host` instance with a NON-EMPTY real `.chats/` is stranded (#708) and is eligible instead.",
271
+ },
272
+ envVar: {
273
+ type: "string",
274
+ description: "The environment variable shadowing the config file. Present only with reason `env-shadowed`; always PADDOCK_CLAUDE_TRANSCRIPTS.",
275
+ },
276
+ pendingChats: {
277
+ type: "integer",
278
+ description: "Lower bound on chats that would migrate. See the endpoint description for the one case where it is 0 while `eligible` is true.",
279
+ },
280
+ pendingProjects: { type: "integer", description: "Projects with a non-empty `.chats/`." },
281
+ scannedProjects: { type: "integer", description: "Projects examined." },
282
+ computedAt: {
283
+ type: "string",
284
+ format: "date-time",
285
+ description: "When this answer was computed. May predate the request when served from cache.",
286
+ },
287
+ },
288
+ },
289
+ },
290
+ },
291
+ }, async () => probeMigration(await input()));
292
+ app.get("/api/transcripts/migration/chats", {
293
+ schema: {
294
+ tags: ["System"],
295
+ summary: "Per-chat migration plan, grouped by project",
296
+ description: "The checkbox table behind the #882 modal. Classifies every chat in every project's `.chats/` against its host store: `new` (no counterpart), `fast-forward` (a counterpart exists and one side is strictly ahead — lossless), `diverged` (both advanced independently). Chats identical on both sides are omitted entirely — there is no decision to make — but they are counted in `totals.identical`, and they still migrate, because the postcondition is about `.chats/` ending up empty rather than about what the user ticked. Classification is staged so cost tracks conflicts rather than chat count: a `new` chat is settled by two readdirs, and the fast-forward test is a bounded read at the byte offset an append-only ancestor's last record must end at, not a scan. Only a genuine divergence costs a full read of the longer file, and those draw from a shared 256 MB budget; rows past it come back `unknown`, default to unchecked, and set `scanBudgetExhausted`. A diverged row's `messageCount` / `lastMessageAt` comparison columns are read from the same budget and are absent when it will not stretch. Sweeper stores migrate silently with their project and appear only as a count. `configVersion` is the value to echo back as `expectedVersion` on the POST, and is read fresh for every response rather than cached.",
297
+ querystring: {
298
+ type: "object",
299
+ additionalProperties: false,
300
+ properties: {
301
+ slug: {
302
+ type: "string",
303
+ description: "Optional. Restrict the plan to one project. The ROOT workspace is the EMPTY STRING, so `?slug=` is a real request for it and is not the same as omitting the parameter.",
304
+ },
305
+ },
306
+ },
307
+ response: {
308
+ 200: {
309
+ description: "The full migration plan.",
310
+ type: "object",
311
+ additionalProperties: false,
312
+ required: [
313
+ "mode",
314
+ "configPath",
315
+ "configVersion",
316
+ "projects",
317
+ "sweepers",
318
+ "totals",
319
+ "scanBudgetExhausted",
320
+ "warnings",
321
+ ],
322
+ properties: {
323
+ mode: { type: "string", enum: ["own", "host"] },
324
+ configPath: { type: "string", description: "Absolute path of the file the POST writes to." },
325
+ configVersion: {
326
+ type: ["string", "null"],
327
+ description: "Fingerprint of paddock.config.yaml as read for THIS response; null when the file does not exist yet. Echo as `expectedVersion` on the POST to make the write conditional.",
328
+ },
329
+ projects: {
330
+ type: "array",
331
+ description: "One entry per project with at least one row or one project-level artifact. Projects whose chats are all identical on both sides, and projects with an empty `.chats/`, are omitted.",
332
+ items: projectSchema,
333
+ },
334
+ sweepers: {
335
+ type: "object",
336
+ additionalProperties: false,
337
+ required: ["stores", "chats"],
338
+ description: "Sweeper transcript stores under `<dataDir>/sweepers/<slug>/`. Migrated silently with their project, with no rows and no user choice (#882). Reported as counts only, so the completion summary can mention them.",
339
+ properties: {
340
+ stores: { type: "integer", description: "Sweeper stores that will be migrated." },
341
+ chats: { type: "integer", description: "Sweeper transcripts inside them." },
342
+ },
343
+ },
344
+ totals: {
345
+ type: "object",
346
+ additionalProperties: false,
347
+ required: [
348
+ "chats",
349
+ "new",
350
+ "fastForward",
351
+ "diverged",
352
+ "unknown",
353
+ "identical",
354
+ "defaultSelected",
355
+ ],
356
+ properties: {
357
+ chats: { type: "integer", description: "Rows in `projects[].chats`." },
358
+ new: { type: "integer" },
359
+ fastForward: { type: "integer" },
360
+ diverged: { type: "integer" },
361
+ unknown: { type: "integer" },
362
+ identical: {
363
+ type: "integer",
364
+ description: "Chats identical on both sides, omitted from the rows. Reported so a row count lower than the user's chat total always has an explanation.",
365
+ },
366
+ defaultSelected: {
367
+ type: "integer",
368
+ description: "Rows starting checked — the modal's initial 'N of M'.",
369
+ },
370
+ },
371
+ },
372
+ scanBudgetExhausted: {
373
+ type: "boolean",
374
+ description: "True when the divergence-scan budget ran out. Never silently true — `totals.unknown` counts the rows it cost, and a `diverged` row missing its `messageCount` is the other thing it costs.",
375
+ },
376
+ warnings: {
377
+ type: "array",
378
+ description: "Non-fatal conditions that do not stop the migration but that the modal should surface.",
379
+ items: warningSchema,
380
+ },
381
+ },
382
+ },
383
+ },
384
+ },
385
+ }, async (req) => {
386
+ const slug = req.query.slug;
387
+ const built = await input(slug);
388
+ // Names and previews come from the same listing the chat list renders
389
+ // from (memoised inside herdctl + `last-activity`), rather than from a
390
+ // second reader that could disagree with it about what a chat is called.
391
+ // Best-effort: an unreadable store must degrade to an unnamed row, not to
392
+ // a failed plan.
393
+ const names = new Map();
394
+ const wanted = slug === undefined ? undefined : slug;
395
+ for (const p of [await projects.get(ROOT_KEY), ...(await projects.list())]) {
396
+ if (wanted !== undefined && p.slug !== wanted)
397
+ continue;
398
+ for (const s of await herdctl.listSessions(p).catch(() => [])) {
399
+ names.set(s.sessionId, {
400
+ ...(s.customName ?? s.autoName ? { name: s.customName ?? s.autoName } : {}),
401
+ ...(s.preview ? { preview: s.preview } : {}),
402
+ });
403
+ }
404
+ }
405
+ return buildMigrationPlan({ ...built, names });
406
+ });
407
+ app.post("/api/transcripts/migration", {
408
+ schema: {
409
+ tags: ["System"],
410
+ summary: "Move project transcripts into the host store (own → host, or recover a stranded host)",
411
+ description: "Runs the migration (#882): quiesce every project, re-enumerate `.chats/` from disk, move, and — only if EVERY project ends with an empty `.chats/` — write `claude.transcripts: host`. That write is the COMMIT POINT and is deliberately last: until it lands the running server still resolves `own` and a partly-emptied `.chats/` is the transient blank-list state the modal already warns about, which re-running reconciles. The reverse order was rejected because a crash between a `host` config and files still in `.chats/` is a genuine #708 split. " +
412
+ "ON AN INSTANCE ALREADY RESOLVING `host` this is a RECOVERY, not a flip (#882 §2). Flipping to `host` while `.chats/` was non-empty makes `pointChatsDirAt` decline the redirect symlink, leaving the pre-flip transcripts in a real `.chats/` that nothing reads — #708's remaining half. The moves are identical; step 6 simply has nothing to write, because the config already says what it would say. Such a run returns `configWritten: false` with `restartRequired: true` and `ok: true`, and on the next boot `pointChatsDirAt` finds an empty `.chats/`, plants the redirect, and the stranded chats reappear. " +
413
+ "`sessionIds` are the chats the user TICKED; everything else in `.chats/` is preserved rather than migrated, and an empty array is a legal choice meaning 'migrate nothing, preserve everything, and empty `.chats/`'. " +
414
+ "NOTHING IS EVER DELETED and nothing in your own `~/.claude` is overwritten in place. Where a chat exists on both sides, the copy that does not survive is MOVED to `<project.dir>/.chats-pre-migration/` — a SIBLING of `.chats/`, because a `pre-migration/` child would leave `.chats/` non-empty and make the redirect symlink be declined, shipping #708's own symptom. When Paddock's copy supersedes the user's (a fast-forward Paddock is ahead on, or a diverged chat the user ticked), the user's copy is moved aside FIRST and the replacement lands on an empty destination. " +
415
+ "This replaces the design's skip-if-present move rule, which deadlocked against the empty-`.chats/` postcondition for every chat present on both sides — on an instance where the user adopted their CLI history and then worked in both places, that is every chat, and the migration could not succeed at all. " +
416
+ "`memory/` is merged at FILE granularity for the same reason: `memory/MEMORY.md` is a single well-known path, so a collision is the common case, and a colliding file is set aside with a `memory-collision` warning rather than overwriting a hand-curated index. " +
417
+ "Sweeper stores migrate silently with their project and are reported as counts. Returns 409 (nothing moved) for `turn_running`, `config_conflict` and `migration_in_progress`, and 400 for `env_shadowed` (the write would be inert) or a malformed session id.",
418
+ body: {
419
+ type: ["object", "null"],
420
+ additionalProperties: false,
421
+ properties: {
422
+ sessionIds: {
423
+ type: "array",
424
+ items: { type: "string" },
425
+ minItems: 0,
426
+ maxItems: 5000,
427
+ description: "The chat ids the user TICKED. Omitted or empty means 'migrate nothing, preserve everything, and empty `.chats/`' — a real choice, so it is not a 400. Ids not currently in any `.chats/` are ignored and named back in `ignoredSessionIds`.",
428
+ },
429
+ plannedSessionIds: {
430
+ type: "array",
431
+ items: { type: "string" },
432
+ minItems: 0,
433
+ maxItems: 5000,
434
+ description: "Every id the plan this selection was made against contained — i.e. `projects[].chats[].sessionId` from GET /chats. Optional, and load-bearing when present: without it a chat CREATED between preview and submit is indistinguishable from one the user deliberately unticked (both are 'on disk, absent from sessionIds'), so §4.6's rule — apply the new chat's own classification default and report it — cannot be implemented. Omit and every untricked chat is treated as a deliberate choice, and `unplanned` comes back empty.",
435
+ },
436
+ expectedVersion: {
437
+ type: ["string", "null"],
438
+ description: "The `configVersion` from the plan this selection was made against. When the property is present the config write is conditional: 409 `config_conflict` if paddock.config.yaml changed underneath you. Omit the property entirely to write unconditionally.",
439
+ },
440
+ dryRun: {
441
+ type: "boolean",
442
+ description: "Default false. Skips the quiesce, moves nothing and writes nothing; the response reports exactly what WOULD happen, decided by the same code the real run uses rather than by a parallel description of it.",
443
+ },
444
+ },
445
+ },
446
+ response: {
447
+ 200: {
448
+ description: "The migration ran. A 200 with a non-empty `failed`, or any project reporting `chatsDirEmpty: false`, is a PARTIAL migration and the config was NOT written; re-running is safe and skips what already moved.",
449
+ type: "object",
450
+ additionalProperties: false,
451
+ required: [
452
+ "ok",
453
+ "alreadyMigrated",
454
+ "dryRun",
455
+ "projects",
456
+ "migrated",
457
+ "preserved",
458
+ "unplanned",
459
+ "ignoredSessionIds",
460
+ "failed",
461
+ "sweepers",
462
+ "warnings",
463
+ "configWritten",
464
+ "configPath",
465
+ "restartRequired",
466
+ ],
467
+ properties: {
468
+ ok: {
469
+ type: "boolean",
470
+ description: "True when every project reached the postcondition AND the config now says host (either this call wrote it, or it already did — a stranded-`host` recovery is the second case, and owes no write at all). On a `dryRun` it is the PREDICTION — 'this would succeed' — because a dry run can never write the config, and reporting false for an entirely healthy plan would tell a confirm step the opposite of the truth.",
471
+ },
472
+ alreadyMigrated: {
473
+ type: "boolean",
474
+ description: "Nothing to do: the stores were already migrated and the config FILE already said host. Judged against the file rather than this process's boot-frozen mode, because the two disagree for the whole window between a successful migration and the restart it asks for — which is exactly when a repeat POST arrives.",
475
+ },
476
+ dryRun: { type: "boolean" },
477
+ projects: {
478
+ type: "array",
479
+ description: "Per-project outcome, in the order they were processed.",
480
+ items: projectResultSchema,
481
+ },
482
+ migrated: {
483
+ type: "array",
484
+ items: { type: "string" },
485
+ description: "Session ids whose Paddock copy is now in a host store.",
486
+ },
487
+ preserved: {
488
+ type: "array",
489
+ description: "Copies deliberately set aside, with where they went. NOTHING IS EVER DELETED; this array is the recovery path and the completion screen must render it in full.",
490
+ items: preservedSchema,
491
+ },
492
+ unplanned: {
493
+ type: "array",
494
+ description: "Chats present at execute time that were in neither the plan nor `sessionIds` — created between preview and submit. Each was handled by its own classification's default (§4.6) and is reported so the summary stays honest. Always empty unless `plannedSessionIds` was sent.",
495
+ items: {
496
+ type: "object",
497
+ additionalProperties: false,
498
+ required: ["sessionId", "slug", "state", "action"],
499
+ properties: {
500
+ sessionId: { type: "string" },
501
+ slug: { type: "string" },
502
+ state: stateSchema,
503
+ action: { type: "string", enum: ["migrated", "preserved"] },
504
+ },
505
+ },
506
+ },
507
+ ignoredSessionIds: {
508
+ type: "array",
509
+ items: { type: "string" },
510
+ description: "Ids in `sessionIds` that are not in any `.chats/`. Ignored, and named rather than silently dropped — the design says they are 'reported under unknown' and then gives the 200 body nowhere to report them.",
511
+ },
512
+ failed: {
513
+ type: "array",
514
+ description: "Per-chat failures. A non-empty array means the config was NOT written and the instance is still on `own`.",
515
+ items: failedSchema,
516
+ },
517
+ sweepers: {
518
+ type: "object",
519
+ additionalProperties: false,
520
+ required: ["stores", "chats"],
521
+ properties: {
522
+ stores: { type: "integer" },
523
+ chats: { type: "integer", description: "Sweeper transcripts migrated silently (#882)." },
524
+ },
525
+ },
526
+ warnings: {
527
+ type: "array",
528
+ description: "Carried through from the re-enumeration. `memory-collision` in particular is REQUIRED by the design's §10.2 and had nowhere to live in its 200 body.",
529
+ items: warningSchema,
530
+ },
531
+ configWritten: {
532
+ type: "boolean",
533
+ description: "Whether `claude.transcripts: host` was written. THE COMMIT POINT when the instance is flipping: false there means nothing semantically happened and it is still resolving `own`. NOT a failure when the config file already said `host` — a stranded-`host` recovery (#882 §2) moves every file and correctly writes nothing, because there is nothing left to write. Read `ok` for success and `restartRequired` for what to tell the user; `configWritten` alone cannot tell a refused commit from an unnecessary one.",
534
+ },
535
+ configPath: { type: "string" },
536
+ configVersion: {
537
+ type: "string",
538
+ description: "Fingerprint after the write. Present only when `configWritten`.",
539
+ },
540
+ restartRequired: {
541
+ type: "boolean",
542
+ description: "Whether a restart is still needed for the on-disk state to take effect. WHY it is needed differs, and so does what the user sees meanwhile — the completion screen should not promise the same thing in both cases. On a FLIP, config is frozen at boot (app.ts:128), so the running process is still resolving `own` against a `.chats/` this call just emptied and the chat list stays BLANK until the restart. On a stranded-`host` RECOVERY the process already resolves `host`, so it reads the host store the transcripts just moved INTO and the list repopulates immediately (measured: a project went 0 → 3 chats with no restart); the restart is still required, but for the redirect symlink, which is only planted at boot by the `pointChatsDirAt` call that this run's now-empty `.chats/` finally lets succeed. Until it is planted the direct `<projectDir>/.chats/<id>.jsonl` readers — sub-agent panels, token usage, tool details, slash-command names, full previews — stay empty even though the chat renders. TRUE for a repeat POST that wrote nothing but whose earlier write has not been restarted into yet; the design's 'always true when configWritten' would have reported false there, and would report false for every recovery.",
543
+ },
544
+ },
545
+ },
546
+ 400: {
547
+ description: "`env_shadowed` when PADDOCK_CLAUDE_TRANSCRIPTS is set — env beats the config file, so the write would be inert and the flip would never happen. `invalid` for a malformed session id. Nothing was moved.",
548
+ type: "object",
549
+ additionalProperties: false,
550
+ required: ["error", "code"],
551
+ properties: {
552
+ error: { type: "string" },
553
+ code: { type: "string", enum: ["env_shadowed", "invalid"] },
554
+ envVar: { type: "string" },
555
+ },
556
+ },
557
+ 409: {
558
+ description: "Nothing was moved and no config was written. `turn_running` — a turn could not be stopped on some chat, so the WHOLE migration refused: a chat left behind leaves `.chats/` non-empty and breaks the flip for its entire project, so this is not a per-item failure. `config_conflict` — `expectedVersion` no longer matches. `migration_in_progress` — another execute is already running.",
559
+ type: "object",
560
+ additionalProperties: false,
561
+ required: ["error", "code"],
562
+ properties: {
563
+ error: { type: "string" },
564
+ code: {
565
+ type: "string",
566
+ enum: ["turn_running", "config_conflict", "migration_in_progress"],
567
+ },
568
+ sessionIds: {
569
+ type: "array",
570
+ items: { type: "string" },
571
+ description: "For `turn_running`: the chats that would not stop. Matches `turnRunningError` exactly, so a client can reuse whatever it already does with that body.",
572
+ },
573
+ configVersion: {
574
+ type: ["string", "null"],
575
+ description: "For `config_conflict`: the fingerprint the file actually has now.",
576
+ },
577
+ },
578
+ },
579
+ },
580
+ },
581
+ }, async (req, reply) => {
582
+ // STEP 1 — single-flight. Checked and armed with no `await` between, so
583
+ // two simultaneous requests cannot both pass.
584
+ if (migrationInFlight !== null) {
585
+ return reply.code(409).send({
586
+ error: "A transcript migration is already running on this instance. Wait for it to finish — it is a single operation and re-running it while it works could interleave two sets of moves.",
587
+ code: "migration_in_progress",
588
+ });
589
+ }
590
+ let release;
591
+ migrationInFlight = new Promise((r) => {
592
+ release = r;
593
+ });
594
+ try {
595
+ const body = req.body ?? {};
596
+ const sessionIds = body.sessionIds ?? [];
597
+ const bad = sessionIds.filter((id) => typeof id !== "string" || !SAFE_SESSION_ID.test(id));
598
+ if (bad.length > 0) {
599
+ return reply.code(400).send({
600
+ error: `Not a valid session id: ${JSON.stringify(bad[0])}. Nothing was moved.`,
601
+ code: "invalid",
602
+ });
603
+ }
604
+ // An env var shadowing `claude.transcripts` beats the config file, so the
605
+ // commit would be inert: the files would move and the instance would come
606
+ // back on `own` with an empty `.chats/`. Refuse before anything moves.
607
+ if ((process.env[TRANSCRIPTS_ENV_VAR] ?? "").trim().length > 0) {
608
+ return reply.code(400).send({
609
+ error: `${TRANSCRIPTS_ENV_VAR} is set in the environment, which overrides the config file. Unset it and try again — writing claude.transcripts: host would have no effect, and the transcripts would be moved for nothing.`,
610
+ code: "env_shadowed",
611
+ envVar: TRANSCRIPTS_ENV_VAR,
612
+ });
613
+ }
614
+ // STEP 2 — optimistic concurrency, same opt-in shape as the config PUT
615
+ // (#722): a client that sends no `expectedVersion` writes unconditionally.
616
+ const configPath = instanceConfigPath(cfg);
617
+ if (Object.prototype.hasOwnProperty.call(body, "expectedVersion")) {
618
+ const current = instanceConfigVersion(configPath);
619
+ if (current !== (body.expectedVersion ?? null)) {
620
+ return reply.code(409).send({
621
+ error: "The config file changed on disk since the migration plan was built. Nothing was moved — reload the plan and try again.",
622
+ code: "config_conflict",
623
+ configVersion: current,
624
+ });
625
+ }
626
+ }
627
+ const built = await input();
628
+ const dryRun = body.dryRun === true;
629
+ const interlock = { hub: ctx.managementOpsContext?.hub, herdctl };
630
+ // STEP 3 — quiesce, concurrently, BEFORE anything moves. A project that
631
+ // will not stop refuses the WHOLE migration rather than going into a
632
+ // `failed` bucket: a chat left behind leaves `.chats/` non-empty, which
633
+ // breaks the flip for its entire project (§4.4). Skipped on a dry run,
634
+ // which by definition mutates nothing.
635
+ if (!dryRun) {
636
+ const stuck = (await Promise.all(built.projects.map((p) => quiesceProject(interlock, p.slug)))).flat();
637
+ if (stuck.length > 0)
638
+ return reply.code(409).send(turnRunningError(stuck));
639
+ }
640
+ // STEPS 4–6.
641
+ const result = await executeMigration({
642
+ ...built,
643
+ sessionIds,
644
+ ...(body.plannedSessionIds ? { plannedSessionIds: body.plannedSessionIds } : {}),
645
+ dryRun,
646
+ pendingMode: pendingTranscriptsMode(cfg),
647
+ // §4.5: `quiesceSession` stops what is running, it does not take a
648
+ // lock, so a turn can start between step 3 and a project's own moves.
649
+ // Re-checked immediately before them, which narrows the window to
650
+ // microseconds without inventing a fleet-wide turn lock (§10.3).
651
+ busySessions: (slug) => (interlock.hub?.runningSessions() ?? [])
652
+ .filter((i) => i.projectSlug === slug)
653
+ .map((i) => i.sessionId),
654
+ commitConfig: async () => {
655
+ // Written straight through `writeInstanceConfig` rather than via
656
+ // `validatePatch`, because `claude.transcripts` is `editable: false`
657
+ // — deliberately, so the lever can only move through this guided
658
+ // flow and never through a raw config PUT that would strand every
659
+ // existing chat. This route IS the sanctioned path.
660
+ writeInstanceConfig(configPath, [{ key: "claude.transcripts", value: "host" }]);
661
+ return instanceConfigVersion(configPath);
662
+ },
663
+ });
664
+ // The probe memoises on `.chats/`'s dirKey plus the config version, both
665
+ // of which this just changed. The key would notice on its own; dropping
666
+ // it explicitly means the banner cannot linger for even one request.
667
+ if (!dryRun)
668
+ resetMigrationProbeCache();
669
+ return result;
670
+ }
671
+ finally {
672
+ release();
673
+ migrationInFlight = null;
674
+ }
675
+ });
676
+ }