@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.
- 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-DhrXvgkl.js +2 -0
- package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-aOgiBFLq.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-BjrB0TDO.js} +1 -1
- package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +2 -0
- package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-N4g7WrZm.js} +1 -1
- package/packages/web/dist/assets/ProjectView-B-7AIt--.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-DZF5VHJF.js} +1 -1
- package/packages/web/dist/assets/RootHome-C9wEtWHu.js +2 -0
- package/packages/web/dist/assets/Surfaces-BidCkENw.js +2 -0
- package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-BctfAqLY.js} +1 -1
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BJgwSqOO.js +37 -0
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-BD2skmST.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-DckMrhMv.js} +1 -1
- package/packages/web/dist/assets/channel-CsZOz8jm.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-CQDUH7rp.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-Bae1J961.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-CXxS5wOa.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-DSNFkPB3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-B2mqFyB7.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BfqEy3lu.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-Bm6CBVK7.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-NmnLtgYc.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +2 -0
- package/packages/web/dist/assets/{core-DnpcT0s6.js → core-YKeFSK1q.js} +1 -1
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-qf_mXpa9.js +2 -0
- package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-C31v-j3s.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-C_TiPHdS.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-BC8gUFK-.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-vO_QYWxi.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-vxJ-YZOg.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-BNIfJG6l.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-B37HsOFM.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DqA5X81H.js} +1 -1
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BMP0zpIg.js +293 -0
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-P0nXaCzx.js} +1 -1
- package/packages/web/dist/assets/index-BkY_Nc_i.css +1 -0
- package/packages/web/dist/assets/index-C3MdluUb.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-DpKYQ7VK.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BZeRMg3C.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-Cg2O0qzv.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-DBwVxUvI.js} +1 -1
- package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-R8qRwYqx.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-jctTs2en.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-CtmsNfpb.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C-UiLFDv.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-BS4SWa01.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-LPPAC6Q1.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-tjF8KyS3.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-kmddkdk2.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-B07mykTY.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-DrUgi0cT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-Dz6p0m7l.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DM16lA5x.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CCFQkFGB.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DiUI5Dos.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,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `own → host` transcript migration — the WRITE half (#882).
|
|
3
|
+
*
|
|
4
|
+
* `transcripts-migration.ts` answers "what would happen"; this does it. One
|
|
5
|
+
* call, synchronous, no job id: measured at 24 ms of `rename` for 500 chats on
|
|
6
|
+
* one filesystem (design §6), which is comfortably inside a request and does
|
|
7
|
+
* not justify inventing the codebase's first resumable job system.
|
|
8
|
+
*
|
|
9
|
+
* ## The ordering, and where the commit point is (§4.1)
|
|
10
|
+
*
|
|
11
|
+
* 1. single-flight guard ─┐ in routes/transcripts.ts: they need the
|
|
12
|
+
* 2. expectedVersion check │ hub, and a refusal must happen before
|
|
13
|
+
* 3. quiesceProject × N ─┘ this module is entered at all
|
|
14
|
+
* 4. re-enumerate from disk ─┐
|
|
15
|
+
* 5. move, project by project │ here
|
|
16
|
+
* 6. write claude.transcripts │ <-- THE COMMIT POINT
|
|
17
|
+
* 7. respond ─┘
|
|
18
|
+
*
|
|
19
|
+
* Steps 1–3 live in the route because they are refusals: nothing may have moved
|
|
20
|
+
* when they fire, and the cleanest way to guarantee that is for them to run
|
|
21
|
+
* before the mover exists. Step 6 is a callback ({@link MigrationExecuteInput.commitConfig})
|
|
22
|
+
* for the same reason in reverse — this module then has no opinion about YAML,
|
|
23
|
+
* and a test can make the commit throw to prove step 5 is safe without it.
|
|
24
|
+
*
|
|
25
|
+
* **The config write is last and is deliberately the commit point.** Until it
|
|
26
|
+
* lands nothing has semantically happened: the running server still resolves
|
|
27
|
+
* `own`, and a partly-emptied `.chats/` is indistinguishable from the interim
|
|
28
|
+
* blank-list state #882 already tells the user to expect. Re-running reconciles
|
|
29
|
+
* it. The reverse order was rejected — a crash between a `host` config and files
|
|
30
|
+
* still in `.chats/` is a genuine #708 split rather than a transient blank list.
|
|
31
|
+
*
|
|
32
|
+
* ## Under `host` there is no commit point, and that is not a failure
|
|
33
|
+
*
|
|
34
|
+
* #882 §2 widened this to run on an instance ALREADY on `host` — the one that
|
|
35
|
+
* flipped while `.chats/` was non-empty, had the redirect declined
|
|
36
|
+
* (`transcripts.ts:145`) and cannot see its own pre-flip transcripts. Nothing
|
|
37
|
+
* about steps 4–5 changes; step 6 simply has nothing to write, because the
|
|
38
|
+
* config already says what it would say. So the run reports `configWritten:
|
|
39
|
+
* false` with `restartRequired: true`, and the next boot finds an empty
|
|
40
|
+
* `.chats/`, plants the redirect and the stranded chats reappear.
|
|
41
|
+
*
|
|
42
|
+
* That makes `configWritten` the wrong measure of success, which is what
|
|
43
|
+
* `configOwed` is for: a recovery that moved every stranded chat and emptied
|
|
44
|
+
* every `.chats/` used to report `ok: false` — while the dry run that predicted
|
|
45
|
+
* it reported `ok: true`.
|
|
46
|
+
*
|
|
47
|
+
* ## The survivor rule — NOT skip-if-present
|
|
48
|
+
*
|
|
49
|
+
* §4.2 made the move skip-if-present ("a destination that already exists is
|
|
50
|
+
* never overwritten"). §5 requires `.chats/` to end up EMPTY. For any chat
|
|
51
|
+
* present on both sides those deadlock: the destination exists, the move skips,
|
|
52
|
+
* the file stays, `chatsDirEmpty: false`, the config is never written. On the
|
|
53
|
+
* instance §10.1 describes — everything adopted from the CLI, nothing net-new —
|
|
54
|
+
* that is *every* chat, and the migration cannot succeed at all. The feature
|
|
55
|
+
* would fail hardest for the user it was designed around. Ruled on in #882 and
|
|
56
|
+
* replaced by:
|
|
57
|
+
*
|
|
58
|
+
* > The copy that does **not** survive is moved to the preserve dir. The
|
|
59
|
+
* > survivor ends up in the host store. `.chats/` ends empty. Nothing is ever
|
|
60
|
+
* > deleted, and nothing in `~/.claude` is ever overwritten in place.
|
|
61
|
+
*
|
|
62
|
+
* | Row | Survivor | Where the other copy goes |
|
|
63
|
+
* |---|---|---|
|
|
64
|
+
* | `new` | Paddock's | — (nothing on the host side) |
|
|
65
|
+
* | `identical` | the user's | Paddock's copy → preserve dir |
|
|
66
|
+
* | `fast-forward`, host ahead | the user's | Paddock's ancestor → preserve dir |
|
|
67
|
+
* | `fast-forward`, own ahead | Paddock's | the user's ancestor → preserve dir FIRST |
|
|
68
|
+
* | `diverged`, ticked | Paddock's | the user's copy → preserve dir FIRST |
|
|
69
|
+
* | `diverged`, unticked | the user's | Paddock's copy → preserve dir |
|
|
70
|
+
*
|
|
71
|
+
* The two rows that supersede a file in `~/.claude` move the superseded copy
|
|
72
|
+
* out **before** the replacement lands — never an unlink, never an overwrite in
|
|
73
|
+
* place. That ordering is the whole of "nothing is deleted", and it is not free:
|
|
74
|
+
* POSIX `rename(2)` silently replaces an existing destination, so
|
|
75
|
+
* `transcript-move.ts` refuses one rather than relying on callers to remember.
|
|
76
|
+
*
|
|
77
|
+
* Read as a decision the table collapses to four lines — see {@link disposition}.
|
|
78
|
+
*
|
|
79
|
+
* ## The postcondition is the contract, not the file list
|
|
80
|
+
*
|
|
81
|
+
* `pointChatsDirAt` (`transcripts.ts:145`) declines to plant the redirect
|
|
82
|
+
* symlink if `.chats/` is a real directory holding **anything** — dotfiles
|
|
83
|
+
* included. So the rule is not "move these artifact types" (an enumeration
|
|
84
|
+
* fails open: whatever a future release starts writing into `.chats/` silently
|
|
85
|
+
* breaks the flip) but "afterwards, `.chats/` contains nothing" — which fails
|
|
86
|
+
* closed and is checkable in one `readdir`. Hence {@link drainChatsDir}: after
|
|
87
|
+
* every planned move, anything still there is moved too, and any project still
|
|
88
|
+
* reporting `chatsDirEmpty: false` blocks the config write for the whole
|
|
89
|
+
* instance.
|
|
90
|
+
*/
|
|
91
|
+
import { promises as fs } from "node:fs";
|
|
92
|
+
import path from "node:path";
|
|
93
|
+
import { scanProject, settleCandidates, DIVERGENCE_SCAN_BUDGET_BYTES, TRANSCRIPTS_ENV_VAR, } from "./transcripts-migration.js";
|
|
94
|
+
import { collectChatArtifacts, DestinationExistsError, moveEntry, pathExists, pruneEmptyTree, uniqueDestination, } from "./transcript-move.js";
|
|
95
|
+
const realMover = {
|
|
96
|
+
dry: false,
|
|
97
|
+
exists: pathExists,
|
|
98
|
+
move: moveEntry,
|
|
99
|
+
unique: uniqueDestination,
|
|
100
|
+
pruneEmpty: pruneEmptyTree,
|
|
101
|
+
remaining: (dir) => fs.readdir(dir).catch(() => []),
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* The dry mover overlays a virtual filesystem on the real one: paths it has
|
|
105
|
+
* "moved" read as gone, paths it has "created" read as present.
|
|
106
|
+
*
|
|
107
|
+
* Shallow by design — it tracks the exact paths it was handed, not their
|
|
108
|
+
* subtrees — which is sound here because a chat's `<id>/` directory is moved
|
|
109
|
+
* whole and nothing ever asks about a path inside one.
|
|
110
|
+
*/
|
|
111
|
+
class DryMover {
|
|
112
|
+
dry = true;
|
|
113
|
+
gone = new Set();
|
|
114
|
+
made = new Set();
|
|
115
|
+
async exists(p) {
|
|
116
|
+
if (this.made.has(p))
|
|
117
|
+
return true;
|
|
118
|
+
if (this.gone.has(p))
|
|
119
|
+
return false;
|
|
120
|
+
return pathExists(p);
|
|
121
|
+
}
|
|
122
|
+
async move(from, to) {
|
|
123
|
+
if (await this.exists(to))
|
|
124
|
+
throw new DestinationExistsError(to);
|
|
125
|
+
this.gone.add(from);
|
|
126
|
+
this.made.delete(from);
|
|
127
|
+
this.made.add(to);
|
|
128
|
+
this.gone.delete(to);
|
|
129
|
+
}
|
|
130
|
+
async unique(dir, name) {
|
|
131
|
+
const first = path.join(dir, name);
|
|
132
|
+
if (!(await this.exists(first)))
|
|
133
|
+
return first;
|
|
134
|
+
for (let n = 1; n < 1000; n++) {
|
|
135
|
+
const candidate = path.join(dir, `${name}.${n}`);
|
|
136
|
+
if (!(await this.exists(candidate)))
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`no free destination for ${name} in ${dir}`);
|
|
140
|
+
}
|
|
141
|
+
async pruneEmpty(dir) {
|
|
142
|
+
for (const name of await this.remaining(dir)) {
|
|
143
|
+
const child = path.join(dir, name);
|
|
144
|
+
if ((await fs.lstat(child).catch(() => null))?.isDirectory())
|
|
145
|
+
await this.pruneEmpty(child);
|
|
146
|
+
}
|
|
147
|
+
if ((await this.remaining(dir)).length === 0)
|
|
148
|
+
this.gone.add(dir);
|
|
149
|
+
}
|
|
150
|
+
async remaining(dir) {
|
|
151
|
+
if (this.gone.has(dir))
|
|
152
|
+
return [];
|
|
153
|
+
const names = await fs.readdir(dir).catch(() => []);
|
|
154
|
+
return names.filter((n) => !this.gone.has(path.join(dir, n)));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The survivor table (module doc), as a decision.
|
|
159
|
+
*
|
|
160
|
+
* It collapses because "ticked" and "which side is the descendant" are the only
|
|
161
|
+
* two questions: a chat with no counterpart has nothing to lose to, an
|
|
162
|
+
* identical pair has nothing to choose between, an unticked chat was declined,
|
|
163
|
+
* and a fast-forward where the HOST is ahead is the one case where migrating
|
|
164
|
+
* Paddock's copy would move an ancestor over a descendant — which is data loss
|
|
165
|
+
* dressed as a migration, so ticking it cannot mean that.
|
|
166
|
+
*/
|
|
167
|
+
function disposition(row, ticked) {
|
|
168
|
+
if (!row.host)
|
|
169
|
+
return ticked ? "migrate" : "preserve";
|
|
170
|
+
if (row.identical)
|
|
171
|
+
return "preserve";
|
|
172
|
+
if (!ticked)
|
|
173
|
+
return "preserve";
|
|
174
|
+
if (row.state === "fast-forward" && row.ahead === "host")
|
|
175
|
+
return "preserve";
|
|
176
|
+
return "migrate";
|
|
177
|
+
}
|
|
178
|
+
function preserveReason(row, ticked, unplanned) {
|
|
179
|
+
if (row.identical)
|
|
180
|
+
return "identical";
|
|
181
|
+
if (ticked)
|
|
182
|
+
return "already-ahead"; // the only ticked row that preserves
|
|
183
|
+
if (unplanned)
|
|
184
|
+
return "unplanned-diverged";
|
|
185
|
+
return "unchecked";
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Move one project's store, then assert the postcondition.
|
|
189
|
+
*
|
|
190
|
+
* Every chat is wrapped individually: one unreadable transcript must not abort
|
|
191
|
+
* the other 499, and a `failed[]` entry already blocks the config write for the
|
|
192
|
+
* whole instance, so a partial run is reported rather than committed.
|
|
193
|
+
*/
|
|
194
|
+
async function migrateOneProject(scan, opts) {
|
|
195
|
+
const slug = scan.ref.slug;
|
|
196
|
+
const run = {
|
|
197
|
+
result: { slug, outcome: "migrated", migrated: 0, preserved: 0, chatsDirEmpty: false },
|
|
198
|
+
migrated: [],
|
|
199
|
+
preserved: [],
|
|
200
|
+
unplanned: [],
|
|
201
|
+
failed: [],
|
|
202
|
+
matched: new Set(),
|
|
203
|
+
};
|
|
204
|
+
// §4.5. A turn that woke up between the quiesce and here holds a transcript
|
|
205
|
+
// path in a directory we are about to empty. Abandon the project UNTOUCHED
|
|
206
|
+
// and say so — `skipped-busy` is the disclosure, and a skipped project is
|
|
207
|
+
// never counted as migrated (§10.3).
|
|
208
|
+
if (opts.busy && opts.busy.length > 0) {
|
|
209
|
+
run.result.outcome = "skipped-busy";
|
|
210
|
+
run.result.error = `a turn started on ${opts.busy.join(", ")} after the quiesce`;
|
|
211
|
+
return run;
|
|
212
|
+
}
|
|
213
|
+
const { chatsDir } = scan;
|
|
214
|
+
for (const row of scan.rows) {
|
|
215
|
+
const id = row.sessionId;
|
|
216
|
+
const inRequest = opts.ticked.has(id);
|
|
217
|
+
// An identical chat was never offered, so it can be neither ticked nor
|
|
218
|
+
// unplanned — it has no decision attached to it at all.
|
|
219
|
+
const unplanned = !row.identical && opts.planned !== null && !opts.planned.has(id) && !inRequest;
|
|
220
|
+
const ticked = opts.migrateEverything || inRequest || (unplanned && row.defaultSelected);
|
|
221
|
+
if (inRequest)
|
|
222
|
+
run.matched.add(id);
|
|
223
|
+
const what = disposition(row, ticked);
|
|
224
|
+
try {
|
|
225
|
+
if (what === "migrate") {
|
|
226
|
+
await migrateChatIn(scan, row, opts.mover, run);
|
|
227
|
+
run.migrated.push(id);
|
|
228
|
+
run.result.migrated++;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const to = await preserveChat(scan, row, opts.mover);
|
|
232
|
+
run.preserved.push({
|
|
233
|
+
sessionId: id,
|
|
234
|
+
slug,
|
|
235
|
+
side: "own",
|
|
236
|
+
path: to,
|
|
237
|
+
reason: preserveReason(row, ticked, unplanned),
|
|
238
|
+
});
|
|
239
|
+
run.result.preserved++;
|
|
240
|
+
}
|
|
241
|
+
if (unplanned) {
|
|
242
|
+
run.unplanned.push({
|
|
243
|
+
sessionId: id,
|
|
244
|
+
slug,
|
|
245
|
+
state: row.state,
|
|
246
|
+
action: what === "migrate" ? "migrated" : "preserved",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
run.failed.push(failureOf(err, id, slug));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// `memory/` is merged at FILE granularity (§10.2): it is a single well-known
|
|
255
|
+
// path, so on the machines this feature is aimed at a collision is the COMMON
|
|
256
|
+
// case, and the transcripts' whole-directory rule would strand it.
|
|
257
|
+
if (scan.listing.hasMemory) {
|
|
258
|
+
try {
|
|
259
|
+
await mergeMemory(scan, opts.mover, run);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
run.failed.push(failureOf(err, "memory", slug, path.join(chatsDir, "memory")));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Project-level artifacts: flat `agent-<hex>.jsonl` sidechains, an orphaned
|
|
266
|
+
// `<id>/` with no transcript, a revert whose chat is gone, and anything the
|
|
267
|
+
// scan could not classify. All of it moves — the postcondition is about the
|
|
268
|
+
// directory, not about the rows — and the plan already NAMED it, via
|
|
269
|
+
// `projectExtras` and the `unexpected-entries` warning, so nothing here moves
|
|
270
|
+
// unannounced.
|
|
271
|
+
// `memory/` appears in `projectExtras` too — the PLAN lists it there because
|
|
272
|
+
// it does move with the project. It must not be moved AGAIN as a directory
|
|
273
|
+
// here: doing so found the host store's merged `memory/` occupying the
|
|
274
|
+
// destination, swept the user's real memory dir into the preserve dir, and
|
|
275
|
+
// then failed on a source that `mergeMemory` had already emptied. Caught by
|
|
276
|
+
// the merge test; the fix is that the merge owns it outright.
|
|
277
|
+
const memoryDir = path.join(chatsDir, "memory");
|
|
278
|
+
const extras = [...scan.projectExtras, ...scan.listing.unexpected].filter((e) => e !== memoryDir);
|
|
279
|
+
for (const extra of extras) {
|
|
280
|
+
try {
|
|
281
|
+
await moveIntoStore(scan, path.relative(chatsDir, extra), opts.mover, run, "extras");
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
run.failed.push(failureOf(err, path.basename(extra), slug, extra));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await drainChatsDir(scan, opts.mover, run);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
run.failed.push(failureOf(err, "-", slug, chatsDir));
|
|
292
|
+
}
|
|
293
|
+
const left = await opts.mover.remaining(chatsDir);
|
|
294
|
+
run.result.chatsDirEmpty = left.length === 0;
|
|
295
|
+
if (run.failed.length > 0 || !run.result.chatsDirEmpty) {
|
|
296
|
+
run.result.outcome = "failed";
|
|
297
|
+
if (!run.result.chatsDirEmpty) {
|
|
298
|
+
run.result.error =
|
|
299
|
+
`${left.length} entr${left.length === 1 ? "y" : "ies"} still in ${chatsDir} ` +
|
|
300
|
+
`(${left.slice(0, 5).join(", ")}) — the redirect symlink will not be planted ` +
|
|
301
|
+
`while any remain, so the config was not written.`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else if (run.result.migrated === 0 && run.result.preserved === 0) {
|
|
305
|
+
run.result.outcome = "nothing-to-do";
|
|
306
|
+
}
|
|
307
|
+
return run;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Move Paddock's copy of a chat into the host store, setting aside anything
|
|
311
|
+
* already occupying a destination FIRST.
|
|
312
|
+
*
|
|
313
|
+
* Clearing the way is what makes "nothing in `~/.claude` is overwritten in
|
|
314
|
+
* place" structural rather than a convention: by the time anything is renamed
|
|
315
|
+
* into the host store, no destination exists to clobber. It runs per artifact
|
|
316
|
+
* rather than per chat because the sidecars collide independently — a host
|
|
317
|
+
* store can hold `<id>/subagents/` for a chat whose `<id>.jsonl` it does not
|
|
318
|
+
* have, which is `new` by classification and a collision in practice.
|
|
319
|
+
*/
|
|
320
|
+
async function migrateChatIn(scan, row, mover, run) {
|
|
321
|
+
const id = row.sessionId;
|
|
322
|
+
const reverts = scan.revertsByChat.get(id) ?? [];
|
|
323
|
+
const own = await collectChatArtifacts(scan.chatsDir, id, reverts);
|
|
324
|
+
for (const rel of own.relative) {
|
|
325
|
+
await moveIntoStore(scan, rel, mover, run, "chat", id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* One store-relative artifact from `.chats/` into the host store, superseding
|
|
330
|
+
* whatever is there.
|
|
331
|
+
*/
|
|
332
|
+
async function moveIntoStore(scan, rel, mover, run, kind, sessionId) {
|
|
333
|
+
const dest = path.join(scan.hostStore, rel);
|
|
334
|
+
if (await mover.exists(dest)) {
|
|
335
|
+
const aside = await mover.unique(path.join(scan.preserveDir, path.dirname(rel)), path.basename(rel));
|
|
336
|
+
await mover.move(dest, aside);
|
|
337
|
+
run.preserved.push({
|
|
338
|
+
sessionId: sessionId ?? path.basename(rel),
|
|
339
|
+
slug: scan.ref.slug,
|
|
340
|
+
side: "host",
|
|
341
|
+
path: aside,
|
|
342
|
+
reason: "superseded",
|
|
343
|
+
});
|
|
344
|
+
if (kind === "chat")
|
|
345
|
+
run.result.preserved++;
|
|
346
|
+
}
|
|
347
|
+
await mover.move(path.join(scan.chatsDir, rel), dest);
|
|
348
|
+
}
|
|
349
|
+
/** Move Paddock's copy of a chat to the preserve dir. Returns the transcript's
|
|
350
|
+
* new path — the one the completion screen sends the user to. */
|
|
351
|
+
async function preserveChat(scan, row, mover) {
|
|
352
|
+
const id = row.sessionId;
|
|
353
|
+
const reverts = scan.revertsByChat.get(id) ?? [];
|
|
354
|
+
const own = await collectChatArtifacts(scan.chatsDir, id, reverts);
|
|
355
|
+
let transcriptAt = path.join(scan.preserveDir, `${id}.jsonl`);
|
|
356
|
+
for (const rel of own.relative) {
|
|
357
|
+
const to = await mover.unique(path.join(scan.preserveDir, path.dirname(rel)), path.basename(rel));
|
|
358
|
+
await mover.move(path.join(scan.chatsDir, rel), to);
|
|
359
|
+
if (rel === `${id}.jsonl`)
|
|
360
|
+
transcriptAt = to;
|
|
361
|
+
}
|
|
362
|
+
return transcriptAt;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* `memory/`, merged file by file, never overwriting anything in the user's real
|
|
366
|
+
* `~/.claude` (§10.2).
|
|
367
|
+
*
|
|
368
|
+
* Not a merge in the semantic sense — nobody reconciles two hand-curated
|
|
369
|
+
* `MEMORY.md` indexes automatically. A colliding file is set aside under
|
|
370
|
+
* `<preserveDir>/memory/<relpath>` and named in the completion summary so the
|
|
371
|
+
* user can merge it by hand. Silently clobbering a curated `MEMORY.md` is the
|
|
372
|
+
* one outcome that is unrecoverable, so it is the one outcome ruled out.
|
|
373
|
+
*/
|
|
374
|
+
async function mergeMemory(scan, mover, run) {
|
|
375
|
+
const from = path.join(scan.chatsDir, "memory");
|
|
376
|
+
const walk = async (rel) => {
|
|
377
|
+
const entries = await fs
|
|
378
|
+
.readdir(path.join(from, rel), { withFileTypes: true })
|
|
379
|
+
.catch(() => []);
|
|
380
|
+
for (const e of entries) {
|
|
381
|
+
const next = path.join(rel, e.name);
|
|
382
|
+
if (e.isDirectory()) {
|
|
383
|
+
await walk(next);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const dest = path.join(scan.hostStore, "memory", next);
|
|
387
|
+
if (await mover.exists(dest)) {
|
|
388
|
+
const aside = await mover.unique(path.join(scan.preserveDir, "memory", path.dirname(next)), e.name);
|
|
389
|
+
await mover.move(path.join(from, next), aside);
|
|
390
|
+
run.preserved.push({
|
|
391
|
+
sessionId: path.join("memory", next),
|
|
392
|
+
slug: scan.ref.slug,
|
|
393
|
+
side: "own",
|
|
394
|
+
path: aside,
|
|
395
|
+
reason: "unchecked",
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
await mover.move(path.join(from, next), dest);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
await walk("");
|
|
404
|
+
// The emptied `memory/` shell would keep `.chats/` non-empty on its own.
|
|
405
|
+
await mover.pruneEmpty(from);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The postcondition, enforced rather than hoped for: whatever is still in
|
|
409
|
+
* `.chats/` after every planned move goes too.
|
|
410
|
+
*
|
|
411
|
+
* Empty directories are removed (`.reverts/` once its snapshots have gone is
|
|
412
|
+
* structure, not data). Anything else is moved into the host store, because
|
|
413
|
+
* under `own` the store IS `.chats/` — an entry paddock or herdctl wrote there
|
|
414
|
+
* belongs with the transcripts it sits beside, and leaving it would break the
|
|
415
|
+
* flip for the whole project rather than for itself.
|
|
416
|
+
*
|
|
417
|
+
* This is the part that fails CLOSED. A future release that starts writing a
|
|
418
|
+
* new sidecar into `.chats/` gets migrated by this loop and warned about by the
|
|
419
|
+
* scan's `unexpected-entries`, instead of silently declining the symlink.
|
|
420
|
+
*/
|
|
421
|
+
async function drainChatsDir(scan, mover, run) {
|
|
422
|
+
for (const name of await mover.remaining(scan.chatsDir)) {
|
|
423
|
+
const full = path.join(scan.chatsDir, name);
|
|
424
|
+
const st = await fs.lstat(full).catch(() => null);
|
|
425
|
+
if (st?.isDirectory()) {
|
|
426
|
+
await mover.pruneEmpty(full);
|
|
427
|
+
if (!(await mover.exists(full)))
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
await moveIntoStore(scan, name, mover, run, "extras");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function failureOf(err, sessionId, slug, where) {
|
|
434
|
+
if (err instanceof DestinationExistsError) {
|
|
435
|
+
return {
|
|
436
|
+
sessionId,
|
|
437
|
+
slug,
|
|
438
|
+
reason: "destination-exists",
|
|
439
|
+
message: err.message,
|
|
440
|
+
path: err.destination,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const e = err;
|
|
444
|
+
const reason = e?.code === "ENOENT" || e?.code === "EACCES" ? "unreadable" : "move-failed";
|
|
445
|
+
return {
|
|
446
|
+
sessionId,
|
|
447
|
+
slug,
|
|
448
|
+
reason,
|
|
449
|
+
message: e?.message ?? String(err),
|
|
450
|
+
...(where ? { path: where } : {}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/* -------------------------------------------------------------------------- */
|
|
454
|
+
/* the whole run */
|
|
455
|
+
/* -------------------------------------------------------------------------- */
|
|
456
|
+
/**
|
|
457
|
+
* Steps 4–6: re-enumerate from disk, move, and — only if every project reached
|
|
458
|
+
* the postcondition — write the config.
|
|
459
|
+
*
|
|
460
|
+
* Steps 1–3 (single-flight, `expectedVersion`, quiesce) are the caller's; see
|
|
461
|
+
* the module doc for why.
|
|
462
|
+
*/
|
|
463
|
+
export async function executeMigration(input) {
|
|
464
|
+
const dryRun = input.dryRun === true;
|
|
465
|
+
const mover = dryRun ? new DryMover() : realMover;
|
|
466
|
+
const ticked = new Set(input.sessionIds);
|
|
467
|
+
const planned = input.plannedSessionIds ? new Set(input.plannedSessionIds) : null;
|
|
468
|
+
const projects = [];
|
|
469
|
+
const migrated = [];
|
|
470
|
+
const preserved = [];
|
|
471
|
+
const unplanned = [];
|
|
472
|
+
const failed = [];
|
|
473
|
+
const warnings = [];
|
|
474
|
+
const matched = new Set();
|
|
475
|
+
const sweepers = { stores: 0, chats: 0 };
|
|
476
|
+
if (input.envShadowed) {
|
|
477
|
+
warnings.push({
|
|
478
|
+
code: "env-shadowed",
|
|
479
|
+
message: `${TRANSCRIPTS_ENV_VAR} is set in the environment, which overrides the ` +
|
|
480
|
+
`config file, so the write would be inert.`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// Step 4 — re-enumerate. The PLAN the user was looking at is not trusted:
|
|
484
|
+
// between preview and submit a chat can appear, grow, or be resumed in a
|
|
485
|
+
// terminal, and the whole point of doing this server-side is that the moves
|
|
486
|
+
// are decided against what is on disk NOW.
|
|
487
|
+
const scans = [];
|
|
488
|
+
for (const ref of input.projects) {
|
|
489
|
+
const scan = await scanProject(ref, input);
|
|
490
|
+
if (scan === null) {
|
|
491
|
+
projects.push({
|
|
492
|
+
slug: ref.slug,
|
|
493
|
+
outcome: "nothing-to-do",
|
|
494
|
+
migrated: 0,
|
|
495
|
+
preserved: 0,
|
|
496
|
+
chatsDirEmpty: true,
|
|
497
|
+
});
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
scans.push(scan);
|
|
501
|
+
}
|
|
502
|
+
// One shared divergence budget across the whole request, exactly as the
|
|
503
|
+
// preview spends it — so execute classifies a pathological instance the same
|
|
504
|
+
// way the table the user ticked did.
|
|
505
|
+
await settleCandidates(scans.flatMap((s) => s.candidates), input.scanBudgetBytes ?? DIVERGENCE_SCAN_BUDGET_BYTES);
|
|
506
|
+
for (const scan of scans) {
|
|
507
|
+
warnings.push(...scan.warnings);
|
|
508
|
+
const run = await migrateOneProject(scan, {
|
|
509
|
+
ticked,
|
|
510
|
+
planned,
|
|
511
|
+
mover,
|
|
512
|
+
migrateEverything: false,
|
|
513
|
+
busy: input.busySessions?.(scan.ref.slug),
|
|
514
|
+
});
|
|
515
|
+
projects.push(run.result);
|
|
516
|
+
migrated.push(...run.migrated);
|
|
517
|
+
preserved.push(...run.preserved);
|
|
518
|
+
unplanned.push(...run.unplanned);
|
|
519
|
+
failed.push(...run.failed);
|
|
520
|
+
for (const id of run.matched)
|
|
521
|
+
matched.add(id);
|
|
522
|
+
}
|
|
523
|
+
// Sweeper stores (`<dataDir>/sweepers/<slug>/.chats`) get their own redirect
|
|
524
|
+
// symlink exactly like a project's, so a project-only migration splits them.
|
|
525
|
+
// No rows and no user choice (#882): everything in them migrates, and they
|
|
526
|
+
// are reported as counts. Their failures still block the commit — a sweeper
|
|
527
|
+
// whose `.chats/` stays non-empty is half-blind after the restart just like a
|
|
528
|
+
// project's would be.
|
|
529
|
+
for (const [slug, dir] of input.sweeperDirs ?? new Map()) {
|
|
530
|
+
const ref = { slug, name: slug, dir, workingDir: dir };
|
|
531
|
+
const scan = await scanProject(ref, input);
|
|
532
|
+
if (scan === null)
|
|
533
|
+
continue;
|
|
534
|
+
sweepers.stores++;
|
|
535
|
+
sweepers.chats += scan.rows.length;
|
|
536
|
+
const run = await migrateOneProject(scan, {
|
|
537
|
+
ticked,
|
|
538
|
+
planned,
|
|
539
|
+
mover,
|
|
540
|
+
migrateEverything: true,
|
|
541
|
+
busy: input.busySessions?.(slug),
|
|
542
|
+
});
|
|
543
|
+
warnings.push(...scan.warnings);
|
|
544
|
+
failed.push(...run.failed);
|
|
545
|
+
if (!run.result.chatsDirEmpty) {
|
|
546
|
+
failed.push({
|
|
547
|
+
sessionId: "-",
|
|
548
|
+
slug,
|
|
549
|
+
reason: "move-failed",
|
|
550
|
+
message: run.result.error ?? `sweeper store ${scan.chatsDir} is not empty`,
|
|
551
|
+
path: scan.chatsDir,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const ignoredSessionIds = input.sessionIds.filter((id) => !matched.has(id));
|
|
556
|
+
const everyProjectClean = failed.length === 0 &&
|
|
557
|
+
projects.every((p) => p.chatsDirEmpty && p.outcome !== "skipped-busy" && p.outcome !== "failed");
|
|
558
|
+
const movedSomething = migrated.length > 0 || preserved.length > 0;
|
|
559
|
+
// Idempotency (§4.2). "Already migrated" is a statement about the CONFIG FILE
|
|
560
|
+
// and the stores, not about this process's frozen mode — a second POST
|
|
561
|
+
// arriving before the restart must be a no-op, and at that moment
|
|
562
|
+
// `input.mode` still says `own`.
|
|
563
|
+
const alreadyMigrated = !movedSomething && failed.length === 0 && input.pendingMode === "host";
|
|
564
|
+
// No write is OWED when the config file already says `host`. Two runs land
|
|
565
|
+
// here: the repeat POST above, and — since #882 §2 — the STRANDED recovery,
|
|
566
|
+
// where an instance flipped to `host` with a non-empty `.chats/`, had the
|
|
567
|
+
// redirect declined (`transcripts.ts:145`), and is running this to make its
|
|
568
|
+
// own transcripts reachable again. That run moves every file and correctly
|
|
569
|
+
// writes nothing, so success cannot be measured by the write.
|
|
570
|
+
const configOwed = input.pendingMode !== "host";
|
|
571
|
+
// Step 6 — THE COMMIT POINT. Refused when the env shadows the key (the write
|
|
572
|
+
// would be inert), when anything failed or was skipped, and when any project
|
|
573
|
+
// still holds an entry in `.chats/`.
|
|
574
|
+
let configWritten = false;
|
|
575
|
+
let configVersion;
|
|
576
|
+
if (!dryRun && !input.envShadowed && everyProjectClean && configOwed) {
|
|
577
|
+
try {
|
|
578
|
+
const v = await input.commitConfig();
|
|
579
|
+
configWritten = true;
|
|
580
|
+
if (v !== null)
|
|
581
|
+
configVersion = v;
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
failed.push({
|
|
585
|
+
sessionId: "-",
|
|
586
|
+
slug: "-",
|
|
587
|
+
reason: "unknown",
|
|
588
|
+
message: `files moved but the config write failed: ${err.message}. ` +
|
|
589
|
+
`Re-run the migration — it reconciles.`,
|
|
590
|
+
path: input.configPath,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const effectivePending = configWritten ? "host" : input.pendingMode;
|
|
595
|
+
return {
|
|
596
|
+
// On a dry run `ok` is the PREDICTION — "this would succeed" — because a
|
|
597
|
+
// dry run can never write the config, and reporting `ok: false` for a plan
|
|
598
|
+
// that is entirely healthy would tell a confirm step the opposite of the
|
|
599
|
+
// truth.
|
|
600
|
+
//
|
|
601
|
+
// The same trap on the real path, and the reason `configOwed` exists: `ok`
|
|
602
|
+
// read `configWritten || (alreadyMigrated && everyProjectClean)`, and a
|
|
603
|
+
// stranded `host` recovery satisfies neither — it moves files, so it is not
|
|
604
|
+
// `alreadyMigrated`, and it owes no config write, so nothing is written. A
|
|
605
|
+
// run that recovered every stranded chat and emptied every `.chats/`
|
|
606
|
+
// reported `ok: false` while the dry run that predicted it reported `true`.
|
|
607
|
+
// Success is "every project reached the postcondition, and any write that
|
|
608
|
+
// was owed landed".
|
|
609
|
+
ok: dryRun
|
|
610
|
+
? everyProjectClean
|
|
611
|
+
: everyProjectClean && (configOwed ? configWritten : true),
|
|
612
|
+
alreadyMigrated,
|
|
613
|
+
dryRun,
|
|
614
|
+
projects,
|
|
615
|
+
migrated,
|
|
616
|
+
preserved,
|
|
617
|
+
unplanned,
|
|
618
|
+
ignoredSessionIds,
|
|
619
|
+
failed,
|
|
620
|
+
sweepers,
|
|
621
|
+
warnings,
|
|
622
|
+
configWritten,
|
|
623
|
+
configPath: input.configPath,
|
|
624
|
+
...(configVersion !== undefined ? { configVersion } : {}),
|
|
625
|
+
// Truthful rather than "always true when configWritten": a second POST
|
|
626
|
+
// arriving before the restart writes nothing and still needs one, and a
|
|
627
|
+
// fully-migrated, already-restarted instance needs none.
|
|
628
|
+
restartRequired: !dryRun && (effectivePending !== input.mode || movedSomething),
|
|
629
|
+
};
|
|
630
|
+
}
|