multi-codex 0.7.0__py3-none-any.whl

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.
multi_codex/migrate.py ADDED
@@ -0,0 +1,672 @@
1
+ """默认目录迁移 `migrate-default`(方案 §5.1.5)。
2
+
3
+ 把 S(默认 ~/.codex)变成账号目录 T(<root>/<名称>),并在 S 位置留兼容软链。
4
+ 这是本工具唯一可能丢数据的操作,所以按“事务记录 + 按实际状态续跑”的方式实现:
5
+
6
+ - 事务记录 migrate-journal.json 只提供路径、模式和参数;
7
+ - 真实进度一律从文件系统推断(observe + classify),阶段字段只在状态有歧义时区分;
8
+ - 进程在“动作已完成、记录还没更新”之间被杀,重跑也能判断正确。
9
+
10
+ 记号:S 源位置,T 目标目录,B copy 模式下的备份目录(与 S 同目录,改名是原子操作)。
11
+ 核心不变量:只要 B 存在,原始数据就在 B 里,任何分支都不得删除 T 或丢掉事务记录。
12
+ """
13
+
14
+ import errno
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ from typing import Optional, Tuple
20
+
21
+ from . import accounts, identity, platform
22
+ from .actions import error, info, warn
23
+ from .config import Account, Config, load_config, normalize_proxy
24
+ from .fsutil import (KIND_DIR, KIND_LINK, KIND_MISSING, atomic_write, build_manifest, copy_tree,
25
+ diff_manifests, entry_kind, expand, is_under, remove_path, same_target)
26
+
27
+ JOURNAL_VERSION = 1
28
+
29
+ PHASE_PLANNED = "planned"
30
+ PHASE_COPYING = "copying"
31
+ PHASE_MOVED = "moved"
32
+ PHASE_PARKED = "parked"
33
+ PHASE_LINKED = "linked"
34
+ PHASE_REGISTERED = "registered"
35
+ PHASE_ROLLED_BACK = "rolled-back"
36
+
37
+ MODE_RENAME = "rename"
38
+ MODE_COPY = "copy"
39
+
40
+
41
+ class InjectedFailure(Exception):
42
+ """测试钩子抛出的可捕获异常,用来触发回滚路径。"""
43
+
44
+
45
+ def _test_hook(point: str) -> None:
46
+ """测试专用注入点,正常使用时不设置这两个环境变量,函数什么也不做。
47
+
48
+ MULTI_CODEX_TEST_FAIL_AT:在该点抛出可捕获异常,走回滚。
49
+ MULTI_CODEX_TEST_CRASH_AT:在该点直接 os._exit(137),模拟进程被杀,不做任何清理。
50
+ """
51
+ if os.environ.get("MULTI_CODEX_TEST_FAIL_AT") == point:
52
+ raise InjectedFailure(point)
53
+ if os.environ.get("MULTI_CODEX_TEST_CRASH_AT") == point:
54
+ sys.stdout.flush()
55
+ sys.stderr.flush()
56
+ os._exit(137)
57
+
58
+
59
+ def journal_path() -> str:
60
+ return os.path.join(platform.state_dir(), "migrate-journal.json")
61
+
62
+
63
+ class JournalError(Exception):
64
+ """事务记录存在但无法读取或内容不完整;此时不能猜测迁移进度,只能交给用户处理。"""
65
+
66
+
67
+ _JOURNAL_KEYS = ("name", "source", "target", "backup", "mode", "phase")
68
+
69
+
70
+ def load_journal() -> Optional[dict]:
71
+ path = journal_path()
72
+ try:
73
+ with open(path, "r", encoding="utf-8") as handle:
74
+ journal = json.load(handle)
75
+ except FileNotFoundError:
76
+ return None
77
+ except (OSError, ValueError) as exc:
78
+ raise JournalError("cannot read the migration journal {}: {}".format(path, exc))
79
+ if not isinstance(journal, dict) or any(not isinstance(journal.get(key), str) for key in _JOURNAL_KEYS):
80
+ raise JournalError("the migration journal {} is incomplete".format(path))
81
+ return journal
82
+
83
+
84
+ def _save_journal(journal: dict) -> None:
85
+ atomic_write(journal_path(), json.dumps(journal, indent=2, ensure_ascii=False) + "\n", mode=0o600)
86
+
87
+
88
+ def _set_phase(journal: dict, phase: str) -> None:
89
+ journal["phase"] = phase
90
+ _save_journal(journal)
91
+
92
+
93
+ def _delete_journal() -> None:
94
+ try:
95
+ os.unlink(journal_path())
96
+ except FileNotFoundError:
97
+ pass
98
+
99
+
100
+ # 当前正在执行的步骤名,只用于错误输出里的 phase=,取值与方案 §9 一致。
101
+ _current_step = {"name": "precheck"}
102
+
103
+
104
+ def _enter_step(name: str) -> None:
105
+ _current_step["name"] = name
106
+
107
+
108
+ class MigrationStop(Exception):
109
+ """迁移按设计停下(冲突、占用、S 被重建等),带退出码,不触发回滚。"""
110
+
111
+ def __init__(self, code: int, message: str = "") -> None:
112
+ super().__init__(message)
113
+ self.code = code
114
+
115
+
116
+ def migrate_default(name: str, source: Optional[str], force_copy: bool, keep_backup: bool,
117
+ proxy: Optional[str], skip_process_check: bool, dry_run: bool,
118
+ accept_relogin: bool = False) -> int:
119
+ """迁移入口。调用方已持有写锁。
120
+
121
+ 有事务记录时一律按记录续跑,参数以记录为准;名称或 --source 与记录不同返回 2。
122
+ accept_relogin:凭据存在系统钥匙串时,用户接受迁移后重新登录(见 _check_credentials_store)。
123
+ """
124
+ _enter_step("precheck")
125
+ journal = load_journal()
126
+ if journal is not None:
127
+ return _resume(journal, name, source, force_copy, keep_backup, proxy, skip_process_check, dry_run)
128
+
129
+ config, config_exists = load_config()
130
+ source_path = expand(source or platform.default_source())
131
+ target_path = os.path.join(expand(config.root), name)
132
+ normalized_proxy = normalize_proxy(proxy) if proxy is not None else None
133
+
134
+ _warn_environment(source_path)
135
+ try:
136
+ done = _precheck_without_journal(config, config_exists, name, source_path, target_path,
137
+ normalized_proxy, dry_run)
138
+ except MigrationStop as stop:
139
+ if stop.args[0]:
140
+ error(stop.args[0], phase="precheck", path=source_path)
141
+ return stop.code
142
+ if done is not None:
143
+ return done
144
+
145
+ # 放在占用检查之前:会被拒绝时,用户不必先关掉 Codex。
146
+ code, relogin_needed = _check_credentials_store(source_path, name, accept_relogin)
147
+ if code is not None:
148
+ return code
149
+
150
+ if not skip_process_check:
151
+ code = _busy_check(source_path)
152
+ if code:
153
+ return code
154
+
155
+ root = expand(config.root)
156
+ mode = MODE_COPY if force_copy or not _same_device(source_path, root) else MODE_RENAME
157
+ backup = "{}.multi-codex-bak.{}".format(source_path, time.strftime("%Y%m%d_%H%M%S"))
158
+ if dry_run:
159
+ info("(dry-run) would move {} to {} using {} mode and leave a link at {}".format(
160
+ source_path, target_path, mode, source_path))
161
+ return accounts.EXIT_OK
162
+
163
+ if not os.path.isdir(root):
164
+ os.makedirs(root, mode=0o700)
165
+ journal = {
166
+ "version": JOURNAL_VERSION,
167
+ "name": name,
168
+ "source": source_path,
169
+ "target": target_path,
170
+ "backup": backup,
171
+ "mode": mode,
172
+ "phase": PHASE_PLANNED,
173
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
174
+ "copy": force_copy,
175
+ "keep_backup": keep_backup,
176
+ "proxy": normalized_proxy,
177
+ "skip_process_check": skip_process_check,
178
+ # 回滚后重新开始(_advance 的第 1 行)要按首次的选择重做钥匙串检查;
179
+ # 中断后续跑要据此在完成时提示重新登录。0.2.0 写的记录没有这两项,读取一律按 False。
180
+ "accept_relogin": accept_relogin,
181
+ "relogin_needed": relogin_needed,
182
+ }
183
+ _save_journal(journal)
184
+ _test_hook("journal-planned")
185
+ info("migrating {} to {} ({} mode)".format(source_path, target_path, mode))
186
+ return _run(journal)
187
+
188
+
189
+ def _warn_environment(source_path: str) -> None:
190
+ codex_home = os.environ.get("CODEX_HOME")
191
+ if codex_home and os.path.realpath(expand(codex_home)) != os.path.realpath(source_path):
192
+ warn("CODEX_HOME is set to {}; running `codex` directly uses that directory, "
193
+ "not {}".format(codex_home, source_path))
194
+ warn_isolation_env()
195
+
196
+
197
+ def warn_isolation_env() -> None:
198
+ for variable in platform.ISOLATION_BREAKING_ENV:
199
+ if os.environ.get(variable):
200
+ warn("{} is set in the environment; every account launched from this shell "
201
+ "will share it".format(variable))
202
+
203
+
204
+ def _precheck_without_journal(config: Config, config_exists: bool, name: str, source: str,
205
+ target: str, proxy: Optional[str], dry_run: bool) -> Optional[int]:
206
+ """没有事务记录时的预检(§5.1.5 第 1 步)。返回退出码表示已结束,返回 None 表示继续迁移。"""
207
+ registered = config.find(name)
208
+ source_kind = entry_kind(source)
209
+
210
+ if source_kind == KIND_LINK and entry_kind(target) != KIND_MISSING and same_target(source, target):
211
+ if registered is not None and registered.name == name:
212
+ info("already migrated: {} -> {}".format(source, target))
213
+ return accounts.EXIT_OK
214
+ if registered is None:
215
+ info("{} already links to {}; registering the account".format(source, target))
216
+ return _register(config, config_exists, name, proxy, dry_run)
217
+
218
+ if registered is not None:
219
+ raise MigrationStop(accounts.EXIT_CONFLICT,
220
+ "account {!r} is already registered".format(registered.name))
221
+ if source_kind == KIND_LINK:
222
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source is a link to another location")
223
+ if source_kind == KIND_MISSING:
224
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source does not exist")
225
+ if source_kind != KIND_DIR:
226
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source is not a directory")
227
+ if entry_kind(target) != KIND_MISSING:
228
+ raise MigrationStop(accounts.EXIT_CONFLICT, "target {} already exists".format(target))
229
+ if is_under(target, os.path.realpath(source)) or is_under(os.path.realpath(source), target):
230
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source and target must not contain each other")
231
+
232
+ _check_registration_conflicts(config, config_exists, name, target, proxy)
233
+ return None
234
+
235
+
236
+ def _check_credentials_store(source: str, name: str, accept_relogin: bool,
237
+ relogin_command: Optional[str] = None) -> Tuple[Optional[int], bool]:
238
+ """凭据存在系统钥匙串时,移动目录后会丢登录:钥匙串条目的键由 CODEX_HOME 的真实路径算出
239
+ (上游 login/src/auth/storage.rs:243-257),迁移或 restore 改变了真实路径,Codex 再也找不到旧条目。
240
+
241
+ relogin_command:提示用户重新登录的命令。默认是迁移后的 `codex-<名> login`;
242
+ restore 之后启动命令已被删除,调用方传 `codex login`。
243
+
244
+ 返回 (退出码或 None, relogin_needed)。退出码为 None 表示继续迁移;
245
+ relogin_needed 表示“本来会被拒绝、因为带了 --accept-relogin 才放行”,迁移完成时要提示重新登录。
246
+ 不抛 MigrationStop:调用点在 migrate_default 的 try 之外,cli.main 也不捕获它,抛出会打出 traceback。
247
+ """
248
+ store, origin = identity.credentials_store(source)
249
+ has_auth_file = os.path.lexists(os.path.join(source, "auth.json"))
250
+ if store == identity.STORE_KEYRING or (store == identity.STORE_AUTO and not has_auth_file):
251
+ detail = ("credentials are stored in the system keyring (cli_auth_credentials_store = {!r}, from {}); "
252
+ "the keyring entry is tied to the directory path, so after the move Codex will be "
253
+ "logged out".format(store, origin))
254
+ if not accept_relogin:
255
+ error("{}. Rerun with --accept-relogin and log in again with `{}`, "
256
+ "or switch to file storage first".format(detail, relogin_command or "codex-{} login".format(name)),
257
+ phase="precheck", path=source)
258
+ return accounts.EXIT_CONFLICT, False
259
+ warn("{}; continuing because of --accept-relogin, you will need to log in again".format(detail))
260
+ return None, True
261
+ if store == identity.STORE_AUTO:
262
+ # 当初写钥匙串失败、回落到了文件;迁移后读钥匙串查不到,仍会读到这个文件。
263
+ warn("cli_auth_credentials_store = 'auto' (from {}): Codex will use {} after migration; "
264
+ "an older keyring entry, if any, will no longer be found".format(
265
+ origin, os.path.join(source, "auth.json")))
266
+ elif store not in (identity.STORE_FILE, identity.STORE_EPHEMERAL):
267
+ warn("unrecognized cli_auth_credentials_store = {!r} (from {}); if credentials are in the system "
268
+ "keyring, you will need to log in again after migration".format(store, origin))
269
+ return None, False
270
+
271
+
272
+ def _check_registration_conflicts(config: Config, config_exists: bool, name: str, target: str,
273
+ proxy: Optional[str]) -> None:
274
+ """预检登记阶段会不会冲突(R3):必须在移动数据之前发现,移动后就无法干净退出了。"""
275
+ new = _config_with_account(config, name, proxy)
276
+ actions = accounts.plan(config, new, config_exists=config_exists, assume_dirs=[target])
277
+ conflicts = [action for action in actions if action.status == accounts.CONFLICT]
278
+ if conflicts:
279
+ for action in conflicts:
280
+ error(action.reason, phase="precheck", path=action.path)
281
+ raise MigrationStop(accounts.EXIT_CONFLICT, "registration would conflict; nothing was moved")
282
+
283
+
284
+ def _config_with_account(config: Config, name: str, proxy: Optional[str]) -> Config:
285
+ new = config.copy()
286
+ existing = new.find(name)
287
+ if existing is None:
288
+ new.accounts[name] = Account(name, proxy or "inherit", False)
289
+ elif proxy is not None:
290
+ existing.proxy = proxy
291
+ return new
292
+
293
+
294
+ def _register(config: Config, config_exists: bool, name: str, proxy: Optional[str], dry_run: bool) -> int:
295
+ new = _config_with_account(config, name, proxy)
296
+ return accounts.converge(config, new, config_exists=config_exists, dry_run=dry_run)
297
+
298
+
299
+ def _busy_check(source: str) -> int:
300
+ try:
301
+ busy = platform.find_busy_processes(source)
302
+ except platform.BusyCheckError as exc:
303
+ error("cannot check whether {} is in use ({}); close Codex and rerun with "
304
+ "--skip-process-check to proceed anyway".format(source, exc), phase="busy-check", path=source)
305
+ return accounts.EXIT_ERROR
306
+ if not busy:
307
+ return accounts.EXIT_OK
308
+ error("{} is in use; close these processes and rerun:".format(source), phase="busy-check", path=source)
309
+ for process in busy:
310
+ print(" pid={} command={} usage={} path={}".format(process.pid, process.command, process.usage,
311
+ process.path),
312
+ file=sys.stderr)
313
+ if any(process.pid == os.getppid() for process in busy):
314
+ print(" note: the shell running this command has its working directory inside the source; "
315
+ "cd out of it first", file=sys.stderr)
316
+ return accounts.EXIT_BUSY
317
+
318
+
319
+ def _same_device(source: str, root: str) -> bool:
320
+ probe = root if os.path.isdir(root) else os.path.dirname(root)
321
+ while not os.path.isdir(probe):
322
+ probe = os.path.dirname(probe)
323
+ return os.stat(source).st_dev == os.stat(probe).st_dev
324
+
325
+
326
+ def _resume(journal: dict, name: str, source: Optional[str], force_copy: bool, keep_backup: bool,
327
+ proxy: Optional[str], skip_process_check: bool, dry_run: bool) -> int:
328
+ """按事务记录续跑一次未完成的迁移。
329
+
330
+ 参数一律以记录为准:续跑时用户可能忘了带上首次的 --keep-backup 等选项,
331
+ 按命令行走会删掉本想保留的备份。名称或 --source 不同说明用户想做另一次迁移,
332
+ 返回 2 并提示先完成记录中的那次;其它选项不同只给出警告。
333
+ """
334
+ _enter_step("resume")
335
+ if name.casefold() != journal["name"].casefold() or (
336
+ source is not None and expand(source) != journal["source"]):
337
+ error("an unfinished migration of {!r} from {} exists; rerun `multi-codex migrate-default {}` "
338
+ "to finish it first".format(journal["name"], journal["source"], journal["name"]),
339
+ phase="resume", path=journal_path())
340
+ return accounts.EXIT_USAGE
341
+ if keep_backup != journal.get("keep_backup") or (proxy is not None and
342
+ normalize_proxy(proxy) != journal.get("proxy")):
343
+ warn("options differ from the unfinished migration; using the recorded options "
344
+ "(keep_backup={}, proxy={})".format(journal.get("keep_backup"), journal.get("proxy")))
345
+ if skip_process_check != bool(journal.get("skip_process_check")):
346
+ warn("--skip-process-check differs from the unfinished migration; using the recorded value ({})".format(
347
+ bool(journal.get("skip_process_check"))))
348
+ if force_copy and journal.get("mode") != MODE_COPY:
349
+ warn("--copy is ignored while resuming; the recorded mode is {}".format(journal.get("mode")))
350
+ info("resuming migration of {} (recorded phase: {})".format(journal["source"], journal["phase"]))
351
+ if dry_run:
352
+ state = _observe(journal)
353
+ info("(dry-run) current state: source={} target={} backup={}".format(*state))
354
+ return accounts.EXIT_OK
355
+ return _run(journal)
356
+
357
+
358
+ def _observe(journal: dict):
359
+ """返回 (S 的状态, T 是否存在, B 是否存在)。S 的状态:link-target、link-other、dir、missing、other。"""
360
+ source, target, backup = journal["source"], journal["target"], journal["backup"]
361
+ kind = entry_kind(source)
362
+ if kind == KIND_LINK:
363
+ source_state = "link-target" if same_target(source, target) and os.path.isdir(target) else "link-other"
364
+ elif kind in (KIND_DIR, KIND_MISSING):
365
+ source_state = kind
366
+ else:
367
+ source_state = "other"
368
+ return source_state, entry_kind(target) != KIND_MISSING, entry_kind(backup) != KIND_MISSING
369
+
370
+
371
+ def _run(journal: dict) -> int:
372
+ """按实际状态逐步推进,直到完成或按设计停下;可捕获的异常走回滚。"""
373
+ try:
374
+ return _advance(journal)
375
+ except MigrationStop as stop:
376
+ if stop.args[0]:
377
+ error(stop.args[0], phase=_current_step["name"], path=journal["source"])
378
+ _print_state(journal)
379
+ return stop.code
380
+ except Exception as exc: # noqa: BLE001 —— 任何未预期的异常都要尝试把数据还原到完整位置
381
+ error("{}: {}".format(type(exc).__name__, exc), phase=_current_step["name"], path=journal["source"])
382
+ return _rollback(journal)
383
+
384
+
385
+ def _advance(journal: dict) -> int:
386
+ verified_this_run = False
387
+ while True:
388
+ source_state, target_exists, backup_exists = _observe(journal)
389
+ phase = journal["phase"]
390
+ mode = journal["mode"]
391
+
392
+ # 第 1 行:回滚未完成,先把回滚做完,再按“没有事务记录”从头开始。
393
+ if phase == PHASE_ROLLED_BACK:
394
+ _finish_rollback(journal)
395
+ info("previous rollback finished; starting the migration again")
396
+ return migrate_default(journal["name"], journal["source"], bool(journal.get("copy")) or mode == MODE_COPY,
397
+ bool(journal.get("keep_backup")), journal.get("proxy"),
398
+ bool(journal.get("skip_process_check")), False,
399
+ accept_relogin=bool(journal.get("accept_relogin")))
400
+
401
+ # 第 2 行:兼容软链已建好,只剩登记与清理。
402
+ if source_state == "link-target":
403
+ return _step_register_and_cleanup(journal)
404
+
405
+ # 第 3 行:S 已改名为 B,还没建软链。T 必须仍在,否则(只会是人工删除)按第 10 行处理,
406
+ # 避免在 S 位置建出一条断链。
407
+ if source_state == KIND_MISSING and backup_exists and target_exists:
408
+ _step_link(journal)
409
+ continue
410
+
411
+ # 第 4 行:已 park,但 S 又被重建。原始数据在 B,只能报错等用户处理。
412
+ if source_state == KIND_DIR and backup_exists:
413
+ raise MigrationStop(accounts.EXIT_ERROR, _rebuilt_message(journal, journal["backup"]))
414
+
415
+ # 第 5 行:rename 已完成,还没建软链。
416
+ if source_state == KIND_MISSING and not backup_exists and target_exists and mode == MODE_RENAME:
417
+ _step_link(journal)
418
+ continue
419
+
420
+ if source_state == KIND_DIR and not backup_exists:
421
+ # 第 6 行:还没移动任何数据,重新预检后开始移动。
422
+ if not target_exists:
423
+ _step_move(journal)
424
+ verified_this_run = mode == MODE_COPY
425
+ continue
426
+ if mode == MODE_COPY and phase in (PHASE_PLANNED, PHASE_COPYING):
427
+ # 第 7 行:复制被中断,T 不完整,删掉重来。
428
+ info("removing incomplete copy {}".format(journal["target"]))
429
+ remove_path(journal["target"])
430
+ _set_phase(journal, PHASE_PLANNED)
431
+ continue
432
+ if mode == MODE_COPY and phase == PHASE_MOVED:
433
+ # 第 8 行:已复制并校验,尚未 park。
434
+ _step_park(journal, verified_this_run)
435
+ verified_this_run = False
436
+ continue
437
+ if mode == MODE_RENAME:
438
+ # 第 9 行:rename 后 S 被重建。
439
+ raise MigrationStop(accounts.EXIT_ERROR, _rebuilt_message(journal, journal["target"]))
440
+
441
+ # 第 10 行:只会在人工改动后出现,不自动处理。
442
+ raise MigrationStop(accounts.EXIT_ERROR, "cannot determine the migration state automatically")
443
+
444
+
445
+ def _step_move(journal: dict) -> None:
446
+ """第 1~3 步:重新预检、占用检查、移动数据。只在 S 是真实目录、T 与 B 都不存在时调用。
447
+
448
+ 预检或占用检查不通过时,数据一点都还没动,直接删除事务记录后停下,
449
+ 这样不会留下一条记录挡住其它命令。
450
+ """
451
+ _enter_step("precheck")
452
+ config, config_exists = load_config()
453
+ _check_registration_conflicts_or_abandon(config, config_exists, journal)
454
+ _enter_step("busy-check")
455
+ if not journal.get("skip_process_check"):
456
+ code = _busy_check(journal["source"])
457
+ if code:
458
+ # 还没移动任何数据,放弃这次迁移,不留事务记录挡住其它命令。
459
+ _delete_journal()
460
+ raise MigrationStop(code)
461
+
462
+ source, target = journal["source"], journal["target"]
463
+ _enter_step("move")
464
+ if journal["mode"] == MODE_RENAME:
465
+ try:
466
+ os.rename(source, target)
467
+ except OSError as exc:
468
+ if exc.errno != errno.EXDEV:
469
+ raise
470
+ # 跨设备无法 rename:先把模式写进记录再复制,中断后续跑才会按 copy 规则处理。
471
+ journal["mode"] = MODE_COPY
472
+ _set_phase(journal, PHASE_PLANNED)
473
+ info("source and target are on different file systems; switching to copy mode")
474
+ return
475
+ _test_hook("rename-target")
476
+ _set_phase(journal, PHASE_MOVED)
477
+ _test_hook("journal-moved")
478
+ return
479
+
480
+ _set_phase(journal, PHASE_COPYING)
481
+ _test_hook("journal-copying")
482
+ copied = [0]
483
+
484
+ def on_file(_path: str) -> None:
485
+ copied[0] += 1
486
+ if copied[0] == 1:
487
+ _test_hook("copy-midway")
488
+
489
+ skipped = copy_tree(source, target, on_file)
490
+ _test_hook("copy-done")
491
+ for rel in skipped:
492
+ info("skipped special file (socket/FIFO/device): {}".format(os.path.join(source, rel)))
493
+ _verify_copy(journal)
494
+ _set_phase(journal, PHASE_MOVED)
495
+ _test_hook("journal-moved")
496
+
497
+
498
+ def _check_registration_conflicts_or_abandon(config: Config, config_exists: bool, journal: dict) -> None:
499
+ try:
500
+ _check_registration_conflicts(config, config_exists, journal["name"], journal["target"],
501
+ journal.get("proxy"))
502
+ except MigrationStop:
503
+ _delete_journal()
504
+ raise
505
+
506
+
507
+ def _verify_copy(journal: dict) -> None:
508
+ """比对 S 与 T 的清单和 SHA-256;不一致时删除 T,S 不动,返回错误。"""
509
+ _enter_step("verify")
510
+ source_manifest, _ = build_manifest(journal["source"])
511
+ target_manifest, _ = build_manifest(journal["target"])
512
+ _test_hook("verify")
513
+ mismatched = diff_manifests(source_manifest, target_manifest)
514
+ if mismatched:
515
+ remove_path(journal["target"])
516
+ _delete_journal()
517
+ shown = ", ".join(mismatched[:5]) + (" ..." if len(mismatched) > 5 else "")
518
+ raise MigrationStop(accounts.EXIT_ERROR,
519
+ "copy verification failed for {} entries ({}); the copy was removed and "
520
+ "the source is untouched".format(len(mismatched), shown))
521
+
522
+
523
+ def _step_park(journal: dict, verified_this_run: bool) -> None:
524
+ """copy 模式:再做一次占用检查,然后把 S 改名为 B(§5.1.5 第 3 步第 4 点)。
525
+
526
+ 复制和校验可能耗时几分钟,这期间写进 S 的数据不会出现在 T 里,所以 park 前必须再查一次。
527
+ 续跑时(本次运行没有校验过)还要重新校验,确认 T 仍与 S 一致。
528
+ """
529
+ _enter_step("park")
530
+ if not journal.get("skip_process_check"):
531
+ code = _busy_check(journal["source"])
532
+ if code:
533
+ raise MigrationStop(code)
534
+ if not verified_this_run:
535
+ source_manifest, _ = build_manifest(journal["source"])
536
+ target_manifest, _ = build_manifest(journal["target"])
537
+ if diff_manifests(source_manifest, target_manifest):
538
+ info("the source changed since it was copied; copying again")
539
+ remove_path(journal["target"])
540
+ _set_phase(journal, PHASE_PLANNED)
541
+ return
542
+ os.rename(journal["source"], journal["backup"])
543
+ _test_hook("rename-backup")
544
+ _set_phase(journal, PHASE_PARKED)
545
+ _test_hook("journal-parked")
546
+
547
+
548
+ def _step_link(journal: dict) -> None:
549
+ """第 4 步:在 S 位置建指向 T 的兼容软链。S 被别的进程重建时报错,不回滚。"""
550
+ _enter_step("link")
551
+ source, target = journal["source"], journal["target"]
552
+ kind = entry_kind(source)
553
+ if kind != KIND_MISSING:
554
+ if kind == KIND_LINK and same_target(source, target):
555
+ return
556
+ where = journal["backup"] if entry_kind(journal["backup"]) != KIND_MISSING else target
557
+ raise MigrationStop(accounts.EXIT_ERROR, _rebuilt_message(journal, where))
558
+ platform.create_link(target, source)
559
+ _test_hook("link")
560
+ _set_phase(journal, PHASE_LINKED)
561
+ _test_hook("journal-linked")
562
+
563
+
564
+ def _step_register_and_cleanup(journal: dict) -> int:
565
+ """第 5 步:登记账号、生成启动命令,然后按记录决定是否删除 B,最后删除事务记录。"""
566
+ _enter_step("register")
567
+ config, config_exists = load_config()
568
+ code = _register(config, config_exists, journal["name"], journal.get("proxy"), False)
569
+ if code != accounts.EXIT_OK:
570
+ raise MigrationStop(code, "data is in place but registration failed; fix the problem above "
571
+ "and rerun the same command")
572
+ _test_hook("register")
573
+ _set_phase(journal, PHASE_REGISTERED)
574
+ _test_hook("journal-registered")
575
+ _enter_step("cleanup")
576
+ backup = journal["backup"]
577
+ if journal["mode"] == MODE_COPY and entry_kind(backup) != KIND_MISSING:
578
+ if journal.get("keep_backup"):
579
+ info("backup kept at {}".format(backup))
580
+ else:
581
+ try:
582
+ remove_path(backup)
583
+ _test_hook("remove-backup")
584
+ except Exception as exc: # noqa: BLE001 —— 迁移已经完成,删备份失败只能提示,不能让流程卡住
585
+ warn("could not remove backup {}: {}: {}".format(backup, type(exc).__name__, exc))
586
+ _delete_journal()
587
+ info("migrated {} -> {}".format(journal["source"], journal["target"]))
588
+ if journal.get("relogin_needed"):
589
+ info("log in again: codex-{} login".format(journal["name"]))
590
+ return accounts.EXIT_OK
591
+
592
+
593
+ def _rollback(journal: dict) -> int:
594
+ """回滚:只把数据恢复到仍然完整的位置,只在 S 为空(或 S 未动过)时执行。"""
595
+ _enter_step("rollback")
596
+ source_state, target_exists, backup_exists = _observe(journal)
597
+ try:
598
+ if journal["mode"] == MODE_RENAME and source_state == KIND_MISSING and target_exists \
599
+ and not backup_exists:
600
+ os.rename(journal["target"], journal["source"])
601
+ _delete_journal()
602
+ info("rolled back: {} restored".format(journal["source"]))
603
+ elif journal["mode"] == MODE_COPY and source_state == KIND_DIR and not backup_exists:
604
+ # park 之前失败:S 没动过,T 只是副本。
605
+ remove_path(journal["target"])
606
+ _delete_journal()
607
+ info("rolled back: removed the partial copy; {} is untouched".format(journal["source"]))
608
+ elif journal["mode"] == MODE_COPY and source_state == KIND_MISSING and backup_exists:
609
+ # 先写记录再动文件:回滚在任何一步中断,续跑表第 1 行都能接着做完。
610
+ _set_phase(journal, PHASE_ROLLED_BACK)
611
+ _test_hook("journal-rolled-back")
612
+ _finish_rollback(journal)
613
+ info("rolled back: {} restored from backup".format(journal["source"]))
614
+ else:
615
+ info("no automatic rollback in this state; rerun the same command to continue")
616
+ _print_state(journal)
617
+ except Exception as exc: # noqa: BLE001 —— 回滚失败必须保留事务记录并报告现场
618
+ error("rollback failed: {}: {}".format(type(exc).__name__, exc), phase="rollback",
619
+ path=journal["source"])
620
+ _print_state(journal)
621
+ return accounts.EXIT_ERROR
622
+
623
+
624
+ def _finish_rollback(journal: dict) -> None:
625
+ """完成 copy 模式 park 之后的回滚:B 改回 S,删除 T,最后删除事务记录。
626
+
627
+ 调用前事务记录必须已处于 rolled-back:每一步都可能被中断,续跑表第 1 行会再次调用本函数,
628
+ 所以每一步都要能重复执行(B 已改回就跳过,T 已删就忽略)。
629
+ 状态不是“S 为真实目录且 B 不存在”时不删除 T,抛出 MigrationStop 交给用户处理。
630
+ """
631
+ source, target, backup = journal["source"], journal["target"], journal["backup"]
632
+ if entry_kind(source) == KIND_MISSING and entry_kind(backup) != KIND_MISSING:
633
+ os.rename(backup, source)
634
+ _test_hook("rollback-restore")
635
+ if entry_kind(source) != KIND_DIR or entry_kind(backup) != KIND_MISSING:
636
+ raise MigrationStop(accounts.EXIT_ERROR, "cannot finish the rollback automatically")
637
+ remove_path(target)
638
+ _test_hook("rollback-remove-target")
639
+ _delete_journal()
640
+
641
+
642
+ def _rebuilt_message(journal: dict, data_location: str) -> str:
643
+ return ("{} was recreated by another process while migrating. The complete data is in {}. "
644
+ "Close the process that recreated it, move the recreated {} away, and rerun "
645
+ "`multi-codex migrate-default {}`.").format(
646
+ journal["source"], data_location, journal["source"], journal["name"])
647
+
648
+
649
+ def _print_state(journal: dict) -> None:
650
+ """报告现场:S、T、B 的当前状态,事务记录位置,以及放弃迁移时的手工收尾步骤。"""
651
+ source_state, target_exists, backup_exists = _observe(journal)
652
+ lines = [
653
+ " source {}: {}".format(journal["source"], source_state),
654
+ " target {}: {}".format(journal["target"], "exists" if target_exists else "missing"),
655
+ " backup {}: {}".format(journal["backup"], "exists" if backup_exists else "missing"),
656
+ " journal {} (phase {})".format(journal_path(), journal.get("phase")),
657
+ ]
658
+ if entry_kind(journal_path()) != KIND_MISSING:
659
+ data = journal["backup"] if backup_exists else journal["target"]
660
+ lines.append(" to abandon this migration: make sure the complete data in {} is back at {}, "
661
+ "then delete {}".format(data, journal["source"], journal_path()))
662
+ print("\n".join(lines), file=sys.stderr)
663
+
664
+
665
+ def pending_journal_notice() -> Optional[str]:
666
+ journal = load_journal()
667
+ if journal is None:
668
+ return None
669
+ return "an unfinished migration exists; rerun `multi-codex migrate-default {}` to finish it".format(
670
+ journal.get("name"))
671
+
672
+