diff --git a/CHANGELOG.md b/CHANGELOG.md index 168d4058f..c25e89708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes - `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (a project scope keeps only the sessions under its own root; the user scope resolves no root, so nothing is excluded and the log passes through unfiltered) and adds only what that scope has not reported yet, derived from the same per-session `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — the local `reported-*` snapshots are machine-global while the team file is per-scope, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). +- The data-partition migration no longer retires a legacy `/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)). - The legacy `teamai dashboard-report` command no longer records dashboard events in a directory that never set up teamai. A current install writes only `teamai hook-dispatch`, whose dashboard-report handler declares `requiresConfig` and is dropped when no config resolves for the hook's `cwd`; the old subcommand stayed ungated, so a hook left behind by an earlier install kept recording events for every project it fired in, and those sessions were then reported by whichever scope pulled next. It now applies the same gate `teamai contribute-check` was given, asked about the session's `cwd` — or, for a host that sends none, the directory the hook runs in (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)). - Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 46df6a2f0..b439c902e 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -189,10 +189,18 @@ effect, both of which would mask the raw legacy state). Act iff: so moving it would break "knowledge on main". The plan's **mode** then depends on the partition: a full copy when -`/config.yaml` does not exist yet, or **retire-only** when it does (a prior -run built the partition but was interrupted before retiring the source — see Interrupt -recovery). retire-only never re-copies onto the authoritative partition; it only cleans -up the leftover legacy dir. +`/config.yaml` does not exist yet, or **retire-only** when it exists and +detection can read it (a prior run built the partition but was interrupted before +retiring the source — see Interrupt recovery). retire-only never re-copies onto the +authoritative partition; it only cleans up the leftover legacy dir. A partition +`config.yaml` that exists but that detection cannot read (it is empty or cannot be +opened, does not parse, does not validate, or is not `scope: project`) plans nothing: +the legacy dir holds the only config that still loads, so it stays in place (a warning +names the file) until the member fixes the partition file, and the next write command +then gets the retire-only cleanup. A partition dir with no `config.yaml` at all (say, +one moved aside by hand) plans nothing either, with a warning: the full copy replaces +the whole dir, so it would take that dir's data with it. The full copy's re-check under +the lock in `runMigration` applies the same rules, warning included. **Steps** (`runMigration`) — copy → verify → atomic rename, so an interruption never leaves data half-in-both-places: @@ -226,7 +234,7 @@ leaves data half-in-both-places: Interrupt recovery: staging is a separate sibling dir, so a crash before step 3 leaves the partition absent and the source intact — a rerun discards `.staging/` and starts clean. A crash between steps 3 and 5 leaves the partition built with the legacy dir -still present; the next write command's `planMigration` sees "partition exists AND +still present; the next write command's `planMigration` sees "readable partition AND legacy lingers" and returns a **retire-only** plan that finishes the job — it retires the leftover legacy dir to `.teamai.bak/` WITHOUT re-copying onto the now-authoritative partition. This closes the gap where the legacy dir (including its plaintext `env`) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cb1021023..fc178b138 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -161,7 +161,11 @@ for tools you have not opened in this project. > **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` after > upgrading automatically migrates an existing `/.teamai/` into the partition > (copy → verify → atomic switch), then leaves the old directory as `/.teamai.bak/` -> for you to delete once you've confirmed everything works. Read-only commands and the +> for you to delete once you've confirmed everything works. If the partition already +> exists but its `config.yaml` cannot be read, or is missing, the migration keeps +> `/.teamai/` and warns with the path: fix or restore that file (or move the +> config-less partition aside), and the next `init` / `pull` / `push` finishes the job. +> Read-only commands and the > `hook-dispatch` path never migrate; `teamai --dry-run pull` previews the move. > **Downgrading afterwards is not supported** — an older teamai would treat the project > as uninitialized; `.teamai.bak/` is the manual rollback path. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 0c5771df4..84a2eb949 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -156,8 +156,10 @@ teamai init https://github.com/yourorg/yourrepo > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` 会自动把已有的 > `/.teamai/` 迁移进分区(复制 → 校验 → 原子切换),并把旧目录保留为 -> `/.teamai.bak/`,待你确认一切正常后自行删除。只读命令与 `hook-dispatch` 路径 -> 永不触发迁移;`teamai --dry-run pull` 可预演。**迁移后不支持降级**——旧版会把项目判定为 +> `/.teamai.bak/`,待你确认一切正常后自行删除。若分区已存在但其 `config.yaml` +> 无法读取或缺失,迁移会保留 `/.teamai/` 并给出带路径的警告:修复或恢复该文件 +> (或把缺少 config 的分区移开)后,下一次 `init` / `pull` / `push` 会完成迁移。 +> 只读命令与 `hook-dispatch` 路径永不触发迁移;`teamai --dry-run pull` 可预演。**迁移后不支持降级**——旧版会把项目判定为 > 未初始化;`.teamai.bak/` 是人工回滚路径。 如果仓库启用了角色化 skills(存在 `manifest/roles.yaml`),`teamai init` 还会交互式要求你选择: diff --git a/src/__tests__/migrate.test.ts b/src/__tests__/migrate.test.ts index 23eba1cca..98871508f 100644 --- a/src/__tests__/migrate.test.ts +++ b/src/__tests__/migrate.test.ts @@ -19,6 +19,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { planMigration, runMigration, maybeMigrate } from '../migrate.js'; +import { log } from '../utils/logger.js'; import { projectDataHome } from '../utils/partition.js'; // ─── Real-git migration tests (issue #374 P1-3) ───────────────────────────── @@ -59,6 +60,19 @@ async function writeLegacyConfig(overrides: Record = {}): Promi await fse.writeFile(path.join(legacyDir, 'config.yaml'), YAML.stringify(cfg)); } +/** Write a schema-valid project config into a partition dir, as a real run leaves it. */ +async function writePartitionConfig(dir: string): Promise { + await fse.ensureDir(dir); + await fse.writeFile( + path.join(dir, 'config.yaml'), + YAML.stringify({ + repo: { localPath: path.join(dir, 'team-repo'), remote: 'git@example.com:team/repo.git', kind: 'git' }, + username: 'tester', + scope: 'project', + }), + ); +} + /** Build a real, non-empty legacy `.teamai/` with a genuine git team-repo clone. */ async function seedLegacyLayout(): Promise { await writeLegacyConfig(); @@ -113,6 +127,7 @@ describe('planMigration', () => { await seedLegacyLayout(); const plan = await planMigration(repoRoot); expect(plan).not.toBeNull(); + expect(plan!.mode).toBe('full'); expect(plan!.legacyDir).toBe(legacyDir); expect(plan!.partitionDir).toBe(projectDataHome(repoRoot)); expect(plan!.anchor).toBe(repoRoot); @@ -127,9 +142,7 @@ describe('planMigration', () => { // skipping (which would leave the legacy dir — incl. plaintext env — forever), // planMigration must return a retire-only plan to finish the cleanup. await seedLegacyLayout(); - const partition = projectDataHome(repoRoot); - await fse.ensureDir(partition); - await fse.writeFile(path.join(partition, 'config.yaml'), 'repo: {}\n'); + await writePartitionConfig(projectDataHome(repoRoot)); const plan = await planMigration(repoRoot); expect(plan).not.toBeNull(); expect(plan!.mode).toBe('retire-only'); @@ -143,8 +156,7 @@ describe('planMigration', () => { await seedLegacyLayout(); const { legacyProjectSlug } = await import('../utils/partition.js'); const legacyNamed = path.join(homeDir, '.teamai', 'projects', legacyProjectSlug(repoRoot)); - await fse.ensureDir(legacyNamed); - await fse.writeFile(path.join(legacyNamed, 'config.yaml'), 'repo: {}\n'); + await writePartitionConfig(legacyNamed); const plan = await planMigration(repoRoot); @@ -324,8 +336,7 @@ describe('runMigration', () => { // partition is already built AND the legacy dir still lingers. await seedLegacyLayout(); const partition = projectDataHome(repoRoot); - await fse.ensureDir(partition); - await fse.writeFile(path.join(partition, 'config.yaml'), 'repo:\n kind: git\n'); + await writePartitionConfig(partition); await fse.writeFile(path.join(partition, 'sentinel'), 'authoritative\n'); const plan = await planMigration(repoRoot); @@ -341,6 +352,49 @@ describe('runMigration', () => { expect(await planMigration(repoRoot)).toBeNull(); }); + it('keeps the legacy dir when an unreadable partition config appears after planning (#797)', async () => { + // The re-check under the lock must use the same notion of "built" as the plan. + await seedLegacyLayout(); + const plan = await planMigration(repoRoot); + expect(plan?.mode).toBe('full'); + const partition = projectDataHome(repoRoot); + await fse.ensureDir(partition); + await fse.writeFile(path.join(partition, 'config.yaml'), ':::not yaml:::\n'); + + expect(await runMigration(plan!)).toBe('skipped'); + expect(await fse.pathExists(path.join(legacyDir, 'env'))).toBe(true); + expect(await fse.pathExists(`${legacyDir}.bak`)).toBe(false); + expect(await fse.readFile(path.join(partition, 'config.yaml'), 'utf-8')).toBe(':::not yaml:::\n'); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(partition, 'config.yaml'))); + }); + + it('keeps the legacy dir when a partition dir without config appears after planning (#797)', async () => { + await seedLegacyLayout(); + const plan = await planMigration(repoRoot); + expect(plan?.mode).toBe('full'); + const partition = projectDataHome(repoRoot); + await fse.outputFile(path.join(partition, 'pending-learnings', 'l1.md'), 'queued\n'); + + expect(await runMigration(plan!)).toBe('skipped'); + expect(await fse.pathExists(path.join(legacyDir, 'env'))).toBe(true); + expect(await fse.pathExists(path.join(partition, 'pending-learnings', 'l1.md'))).toBe(true); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`${partition} exists without a config.yaml`)); + }); + + it('retires the legacy dir when a readable partition config appears after planning', async () => { + await seedLegacyLayout(); + const plan = await planMigration(repoRoot); + expect(plan?.mode).toBe('full'); + const partition = projectDataHome(repoRoot); + await writePartitionConfig(partition); + await fse.writeFile(path.join(partition, 'sentinel'), 'authoritative\n'); + + expect(await runMigration(plan!)).toBe('migrated'); + expect(await fse.pathExists(legacyDir)).toBe(false); + expect(await fse.pathExists(path.join(`${legacyDir}.bak`, 'env'))).toBe(true); + expect(await fse.pathExists(path.join(partition, 'sentinel'))).toBe(true); + }); + it('aborts without touching the source when the staged clone is corrupt', async () => { // A team-repo whose .git is present but not a real repo → verify's rev-parse // smoke-check must fail, leaving the source intact and no partition/.bak. @@ -623,9 +677,7 @@ describe('self mode migration (P2)', () => { await fse.ensureDir(path.join(legacyDir, 'skills')); await fse.writeFile(path.join(legacyDir, 'skills', 'team-skill.md'), '# committed knowledge\n'); // Partition already built → git-mode plan is 'retire-only' → calls retireLegacy. - const partition = projectDataHome(repoRoot); - await fse.ensureDir(partition); - await fse.writeFile(path.join(partition, 'config.yaml'), 'repo:\n kind: git\n'); + await writePartitionConfig(projectDataHome(repoRoot)); const plan = await planMigration(repoRoot); expect(plan!.mode).toBe('retire-only'); @@ -637,6 +689,80 @@ describe('self mode migration (P2)', () => { }); describe('maybeMigrate', () => { + /** Run the pre-command migration from inside the business repo. */ + async function migrateFromRepo(): Promise { + const spy = vi.spyOn(process, 'cwd').mockReturnValue(repoRoot); + try { + await maybeMigrate(); + } finally { + spy.mockRestore(); + } + } + + // #797: the legacy dir holds the only config that still loads while the + // partition's cannot be read, so it must not be retired until that is fixed. + it.each([ + ['does not parse', 'repo: "unterminated\n'], + ['does not validate', 'repo:\n kind: git\n'], + ['is empty', ''], + [ + 'is not scope: project', + YAML.stringify({ repo: { localPath: '/x', remote: 'r', kind: 'git' }, username: 'tester', scope: 'user' }), + ], + ])('keeps the legacy dir while the partition config.yaml %s (#797)', async (_label, content) => { + await seedLegacyLayout(); + const partition = projectDataHome(repoRoot); + await fse.ensureDir(partition); + await fse.writeFile(path.join(partition, 'config.yaml'), content); + + expect(await planMigration(repoRoot)).toBeNull(); + await migrateFromRepo(); + + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(true); + expect(await fse.pathExists(path.join(legacyDir, 'env'))).toBe(true); + expect(await fse.pathExists(`${legacyDir}.bak`)).toBe(false); + // The broken partition file is left for the member to fix, not overwritten. + expect(await fse.readFile(path.join(partition, 'config.yaml'), 'utf-8')).toBe(content); + // The member is told which file holds the migration back. + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(partition, 'config.yaml'))); + }); + + it('retires the legacy dir on the next run once the partition config.yaml is fixed (#797)', async () => { + await seedLegacyLayout(); + const partition = projectDataHome(repoRoot); + await fse.ensureDir(partition); + await fse.writeFile(path.join(partition, 'config.yaml'), ':::not yaml:::\n'); + await migrateFromRepo(); + expect(await fse.pathExists(legacyDir)).toBe(true); + + await writePartitionConfig(partition); + await migrateFromRepo(); + + expect(await fse.pathExists(legacyDir)).toBe(false); + expect(await fse.pathExists(path.join(`${legacyDir}.bak`, 'env'))).toBe(true); + }); + + it('keeps the partition and the legacy dir when the broken partition config is moved aside (#797)', async () => { + // Following "move it aside and run `teamai init`" leaves a partition dir with + // no config.yaml. A full copy would replace that dir, and its data with it. + await seedLegacyLayout(); + const partition = projectDataHome(repoRoot); + await fse.ensureDir(path.join(partition, 'pending-learnings')); + await fse.writeFile(path.join(partition, 'pending-learnings', 'l1.md'), 'queued\n'); + await fse.writeFile(path.join(partition, 'config.yaml'), 'repo: "unterminated\n'); + await migrateFromRepo(); + await fse.move(path.join(partition, 'config.yaml'), path.join(partition, 'config.yaml.broken')); + + expect(await planMigration(repoRoot)).toBeNull(); + await migrateFromRepo(); + + expect(await fse.pathExists(path.join(partition, 'pending-learnings', 'l1.md'))).toBe(true); + expect(await fse.pathExists(path.join(partition, 'config.yaml.broken'))).toBe(true); + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(true); + expect(await fse.pathExists(`${legacyDir}.bak`)).toBe(false); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`${partition} exists without a config.yaml`)); + }); + it('is a no-op when there is nothing to migrate', async () => { // No legacy layout; must not throw. const spy = vi.spyOn(process, 'cwd').mockReturnValue(repoRoot); diff --git a/src/migrate.ts b/src/migrate.ts index 6eb932bc0..285ac6ae7 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -7,6 +7,7 @@ import { resolvePartitionDir, writeAnchorFile } from './utils/partition.js'; import { realpath } from 'node:fs/promises'; import { expandHome, pathExists, readFileSafe, remove, writeFile } from './utils/fs.js'; import { acquireLock, releaseLock } from './update.js'; +import { readConfigFrom } from './config.js'; import { log } from './utils/logger.js'; /** @@ -114,8 +115,9 @@ const SELF_A1_ENTRIES = [ * - the legacy config is user scope (user data never lives under `.teamai/`), * - the legacy config is self mode (its `.teamai/` is committed team knowledge). * - * When a partition config already exists AND a legacy dir still lingers, returns - * a 'retire-only' plan to finish an interrupted migration instead of skipping. + * When a readable partition config already exists AND a legacy dir still + * lingers, returns a 'retire-only' plan to finish an interrupted migration + * instead of skipping. A partition config that exists but cannot be read skips. */ export async function planMigration(cwd?: string): Promise { const anchors = await resolveAnchors(cwd ?? process.cwd()); @@ -174,10 +176,57 @@ export async function planMigration(cwd?: string): Promise // run that crashed before retiring the source). Don't re-copy onto the // authoritative partition — just finish the job by retiring the leftover // legacy dir, so the workspace really does end up residue-free. - const mode: MigrationPlan['mode'] = - (await pathExists(path.join(partitionDir, 'config.yaml'))) ? 'retire-only' : 'full'; + const partition = await readPartitionState(partitionDir, anchors.workspaceRoot); + if (partition.state === 'built') { + return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'retire-only' }; + } + // A partition config that detection cannot read is not "built": the legacy dir + // holds the only config that still loads. Leave it in place; once the member + // fixes the file, the next run retires it (#797). + // A partition dir without its config (e.g. one moved aside by hand) is no place + // for a full copy: the copy replaces the whole dir, and its data with it. + if (await pathExists(partitionDir)) { + warnKeptLegacy(legacyDir, partitionDir, partition); + return null; + } - return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode }; + return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'full' }; +} + +/** Tell the member why the legacy dir stays and what lets the next run migrate it (#797). */ +function warnKeptLegacy(legacyDir: string, partitionDir: string, partition: PartitionState): void { + if (partition.state === 'unreadable') { + // A YAML error carries a code frame after its first line; keep one line. + const reason = partition.error.split('\n')[0]; + log.warn( + `Kept ${legacyDir}: ${partition.configPath} cannot be read (${reason}). ` + + `If that persists, fix the file; the next init, pull or push then retires ${legacyDir} to a .teamai.bak backup.`, + ); + return; + } + log.warn( + `Kept ${legacyDir}: ${partitionDir} exists without a config.yaml. Restore that file, ` + + `or move ${partitionDir} aside so the next init, pull or push migrates ${legacyDir} into a fresh one.`, + ); +} + +type PartitionState = + | { state: 'built' } + | { state: 'unreadable'; configPath: string; error: string } + | { state: 'absent' }; + +/** + * Read the partition config the way detection does: 'built' when it loads, + * 'unreadable' when a config is there but detection would report it, 'absent' + * when there is none. + */ +async function readPartitionState(partitionDir: string, workspaceRoot: string): Promise { + let unreadable: PartitionState | undefined; + const config = await readConfigFrom(partitionDir, workspaceRoot, undefined, (configPath, error) => { + unreadable = { state: 'unreadable', configPath, error }; + }); + if (config) return { state: 'built' }; + return unreadable ?? { state: 'absent' }; } /** True if any of `names` exists directly under `dir`. */ @@ -267,15 +316,21 @@ export async function runMigration( } // Re-check under the lock: a sibling worktree may have migrated while we - // waited (TOCTOU). If the partition config now exists, retire our leftover - // legacy dir rather than copying onto the authoritative partition. - if (await pathExists(path.join(partitionDir, 'config.yaml'))) { + // waited (TOCTOU). If the partition config now reads, retire our leftover + // legacy dir rather than copying onto the authoritative partition; if any + // other partition dir is there, keep the legacy dir as planMigration does. + const partition = await readPartitionState(partitionDir, path.dirname(legacyDir)); + if (partition.state === 'built') { await releaseLock(lockPath); lockReleased = true; const backup = await retireLegacy(legacyDir); log.debug(`partition built by a concurrent process; retired ${legacyDir} to ${backup}`); return 'migrated'; } + if (await pathExists(partitionDir)) { + warnKeptLegacy(legacyDir, partitionDir, partition); + return 'skipped'; + } // 1. Copy into a sibling staging dir (NOT the partition itself) so an // interrupted copy never looks like a built partition. Use raw fse.copy @@ -305,8 +360,8 @@ export async function runMigration( // 3. Atomic switch: same-filesystem rename of the staged dir onto the final // partition path. partitionDir does not exist yet (planMigration + the - // under-lock re-check both gate on its config.yaml, and nothing else - // creates it), so the rename lands on a clean name. + // under-lock re-check both stand down on an existing one), so the rename + // lands on a clean name. await remove(partitionDir); await fse.rename(staging, partitionDir);