fix(partition): adopt pre-#546 legacy-named partitions instead of stranding them (#551)

* fix(partition): adopt pre-#546 legacy-named partitions instead of stranding them

#546 widened the partition slug prefix from the anchor's basename to its
whole path but kept the sha256 suffix — without migrating existing
installs. On an upgraded CLI, a partition still named <basename>-<hash>
(every install created before #546) stops matching projectSlug(anchor):
detectProjectConfig finds no config (the project looks uninitialized),
planMigration would re-copy a retired workspace into a second, empty
partition, and status --all flags the perfectly good data as "corrupt —
dir name does not match anchor".

Both formats share the same hash, so an anchor's legacy name is
computable exactly — no directory scanning. resolvePartitionDir becomes
the seam for "the partition that actually holds this project's data"
(detection, init, migration): when the canonical current-format
directory is absent and a legacy-named one exists, it ATOMICALLY RENAMES
the legacy partition into place — a same-parent metadata move, no data
copied, and an interruption leaves either name intact. An authoritative
current-format partition is never clobbered by a leftover legacy one; a
rename that is genuinely impossible (read-only home) keeps serving the
legacy directory so no data is stranded. status --all stays read-only
and reports a legacy-named partition as "active (legacy name; renamed
automatically on next command)" instead of corrupt.

Verified end-to-end with the real CLI in an isolated HOME: a legacy
.teamai migrates into the readable whole-path slug; status --all
reverse-resolves it as [active]; a partition renamed to the pre-#546
format is reported active-legacy (no rename, no corrupt), then adopted
by the next command (detection) with data intact, and pull runs through
the adopted partition.

* fix(partition): rebase repo.localPath when adopting a legacy partition

A pre-#546 partition stores repo.localPath as an ABSOLUTE path to its
team-repo clone (<legacyPartition>/team-repo). resolvePartitionDir renamed
the directory but left config.yaml pointing at the now-gone old path, so
every later `pull` read the team config from a dead directory and silently
skipped the sync ("Team config (teamai.yaml) not found. Skipping.", exit 0)
— the project could never sync again after an upgrade.

Adoption now rebases repo.localPath onto the new partition (mirroring
migrate.ts's rebaseConfigPaths). The rewrite is idempotent and self-healing:
a modern install whose localPath already sits in the canonical dir is left
untouched, an external clone outside the partition is left untouched, and an
adoption interrupted between the rename and the config rewrite is finished by
the next command.

Reproduced with the real CLI (isolated HOME, real local git team repo): a
legacy-named partition + two `pull --force` runs printed "Team config not
found. Skipping." (exit 0) before this fix; after it both runs print
"Synced 1 skills" and localPath points at the live clone.

Regression tests: localPath rebased off the legacy dir / external localPath
left alone / modern-install no-op. Verified they fail when the rebase is
disabled.

* fix(partition): write the adopted config.yaml atomically to prevent truncation

The localPath rebase overwrote config.yaml with a plain (non-atomic) write.
By that point the legacy partition has already been renamed away, so
config.yaml is the partition's ONLY copy — a write that fails partway
(ENOSPC, EFBIG, crash mid-write) truncates it with no source to recover from,
and the CLI still prints "Your original data is unchanged, re-run to retry"
while the data is in fact corrupt and cannot self-recover.

Add writeFileAtomic (same-dir temp + rename, preserving mode) alongside the
existing writeJsonAtomic, and use it for the adopted config.yaml. rename(2)
is atomic, so a failed write removes the temp file and leaves the original
config.yaml byte-for-byte intact; the next command retries the idempotent
rebase and converges.

Reproduced with the real CLI (isolated HOME, real local git team repo, NO fs
mock) by injecting a write failure with RLIMIT_FSIZE=128:
  before: pull --force reports EFBIG, config.yaml truncated 453 -> 128 bytes,
          legacy partition already moved, retry after lifting the limit exits
          0 but never syncs and cannot recover
  after:  config.yaml preserved at 453 bytes, retry after lifting the limit
          prints "Synced 1 skills" and localPath points at the live clone

Regression test: the localPath rewrite failing mid-write leaves config.yaml
intact with no leftover temp file. Verified it fails when the write is made
non-atomic. Full suite: 3109 passed, 0 regressions; tsc clean.
This commit is contained in:
jeff
2026-09-14 15:59:03 +08:00
committed by GitHub
parent 082f40c529
commit d05b9d6b8f
10 changed files with 551 additions and 19 deletions
+40
View File
@@ -65,6 +65,46 @@ worktree reports the same first entry, giving a shared-yet-distinct identity in
cases. Both anchors are `realpath`-normalized so a symlinked prefix (macOS `/tmp` →
`/private/tmp`) does not make one checkout look like two.
### Partition naming (#546 + adoption)
`slug(anchor) = <safe-path>-<sha256(normalized anchor) first 16 hex>` — the whole
anchor path made filesystem-safe (leading separator dropped, separators and other
unsafe chars → `-`), so the directory name reads back to its project, mirroring
Claude Code's `~/.claude/projects/` naming: `/Users/x/Project/app` →
`Users-x-Project-app-<hash>`. The trailing hash is what guarantees uniqueness
(a `/`→`-` escape alone is not injective: `/x/my-proj` and `/x/my/proj` would
collide and silently merge two projects' plaintext env), and the prefix is
length-bounded so a deep path can never overflow `NAME_MAX`. The per-partition
`anchor` file stays the authoritative reverse lookup.
Because #546 changed the prefix without changing the hash, partitions written by
older teamai (`<safe-basename>-<hash>`) are **adopted, not stranded**: every seam
that resolves "this project's partition" (detection, init, migration) goes through
`resolvePartitionDir`, which computes the anchor's exact legacy name and ATOMICALLY
RENAMES the directory into the current name (same-parent metadata move — no data
copied, an interruption leaves either name intact). A partition that cannot be
renamed (read-only home) keeps serving under its legacy name; an authoritative
current-format partition is never clobbered by a leftover legacy one. `status
--all` never renames (read-only) — it reports a legacy-named partition as
`active (legacy name; renamed automatically on next command)` instead of corrupt.
The rename alone is not enough: `repo.localPath` is stored in config.yaml as an
ABSOLUTE path to the team-repo clone (`<oldPartition>/team-repo`), so adoption
also rebases it onto the new directory — otherwise `pull` would read the team
config from a now-gone path and silently skip the sync (exit 0, "Team config not
found"). The rewrite is idempotent (a modern install's localPath already sits in
the canonical dir and is left untouched; an external clone outside the partition
is left untouched) and self-healing (it finishes an adoption that crashed between
the rename and the config rewrite) — the same `repo.localPath` rebase that
`migrate.ts` applies when moving a legacy `.teamai/` into a partition.
The rewrite is ATOMIC (same-dir temp file + rename, via `writeFileAtomic`). By
this point the legacy source has already been renamed away, so config.yaml is the
partition's only copy; a plain overwrite that failed partway (ENOSPC, EFBIG, a
crash mid-write) would truncate it with no way back. rename(2) is atomic, so a
failed write removes the temp file and leaves the original config.yaml intact —
the next command retries the (idempotent) rebase and converges.
## P0 (this PR) — atomic lock + anchor split
P0 is deliberately **structural**: it establishes the primitive and fixes
+37 -1
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import YAML from 'yaml';
import { detectProjectConfig, resolveDataHomeForScope } from '../config.js';
import { getDataHome, getTeamaiHome } from '../types.js';
import { projectDataHome } from '../utils/partition.js';
import { legacyProjectSlug, projectDataHome } from '../utils/partition.js';
// ─── detectProjectConfig subdirectory / worktree awareness (issue #374 P0) ──
//
@@ -167,4 +167,40 @@ describe('resolveDataHomeForScope — desync guard', () => {
if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
}
});
it('adopts a pre-#546 legacy-NAMED partition on detection (renamed to the current slug)', async () => {
// A partition written before the #546 naming widening lives under
// ~/.teamai/projects/<basename>-<hash>/. Detection must resolve it —
// adopting (renaming) it into the current <path>-<hash> name — instead of
// treating the project as uninitialized.
const home = path.join(base, 'adopt-home');
fs.mkdirSync(home, { recursive: true });
const origHome = process.env.HOME;
process.env.HOME = home;
try {
const pRepo = path.join(base, 'adopt-repo');
fs.mkdirSync(pRepo);
git(pRepo, 'init', '-q');
git(pRepo, 'config', 'user.email', 't@e');
git(pRepo, 'config', 'user.name', 'T');
git(pRepo, 'commit', '--allow-empty', '-q', '-m', 'init');
const anchorReal = realpathSync(pRepo);
const legacyPartition = path.join(home, '.teamai', 'projects', legacyProjectSlug(anchorReal));
fs.mkdirSync(legacyPartition, { recursive: true });
fs.writeFileSync(path.join(legacyPartition, 'config.yaml'), YAML.stringify({
repo: { localPath: path.join(legacyPartition, 'team-repo'), remote: 'https://example.com/x.git', kind: 'git' },
username: 'test', scope: 'project', projectRoot: anchorReal, additionalRoles: [],
}));
const detected = await detectProjectConfig(pRepo);
expect(detected).not.toBeNull();
expect(getDataHome(detected!)).toBe(projectDataHome(anchorReal));
// Adopted: the legacy-named dir is gone, the data lives under the new name.
expect(fs.existsSync(legacyPartition)).toBe(false);
expect(fs.existsSync(path.join(projectDataHome(anchorReal), 'config.yaml'))).toBe(true);
} finally {
if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
}
});
});
+21
View File
@@ -135,6 +135,27 @@ describe('planMigration', () => {
expect(plan!.mode).toBe('retire-only');
});
it('adopts a pre-#546 legacy-NAMED partition before planning (retire-only, not a re-copy)', async () => {
// A partition built by a teamai older than the #546 naming widening carries
// the legacy <basename>-<hash> name. planMigration must adopt (rename) it
// FIRST — otherwise it would see "no partition" and plan a FULL re-copy of
// the legacy dir onto a second, empty partition.
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');
const plan = await planMigration(repoRoot);
expect(plan).not.toBeNull();
expect(plan!.mode).toBe('retire-only');
expect(plan!.partitionDir).toBe(projectDataHome(repoRoot));
// Adopted: the data now lives under the current-format slug.
expect(fse.existsSync(path.join(projectDataHome(repoRoot), 'config.yaml'))).toBe(true);
expect(fse.existsSync(legacyNamed)).toBe(false);
});
it('skips a user-scope legacy config', async () => {
await writeLegacyConfig({ scope: 'user' });
expect(await planMigration(repoRoot)).toBeNull();
+21
View File
@@ -184,4 +184,25 @@ describe('P3 status --all orphan verdict rests on the anchor, not a persisted wo
expect(out).toMatch(/\[active\]/);
expect(out).not.toContain('ORPHAN');
});
it('a partition in the pre-#546 legacy name format is active (legacy hint), NOT corrupt', async () => {
// #546 widened the slug prefix from the basename to the whole path. A
// partition named the OLD way (<basename>-<hash>) holds perfectly good
// data until the next command adopts it — status --all must recognize the
// legacy name instead of crying corrupt.
const { legacyProjectSlug, writeAnchorFile } = await import('../utils/partition.js');
const liveProject = path.join(base, 'legacy-named-project');
fs.mkdirSync(liveProject, { recursive: true });
const partition = path.join(home, '.teamai', 'projects', legacyProjectSlug(liveProject));
fs.mkdirSync(partition, { recursive: true });
await writeAnchorFile(partition, liveProject);
const { status } = await import('../status.js');
await status({ all: true } as never);
const out = logLines.join('\n');
expect(out).toMatch(/\[active \(legacy name/);
expect(out).not.toContain('corrupt');
expect(out).not.toContain('ORPHAN');
});
});
+195 -2
View File
@@ -1,8 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { projectSlug, projectDataHome, isCaseInsensitiveFs, resetCaseProbeCache } from '../utils/partition.js';
import { realpathSync } from 'node:fs';
import fse from 'fs-extra';
import YAML from 'yaml';
import {
projectSlug,
legacyProjectSlug,
projectDataHome,
resolvePartitionDir,
isCaseInsensitiveFs,
resetCaseProbeCache,
} from '../utils/partition.js';
const originalHome = process.env.HOME;
@@ -109,3 +119,186 @@ describe('projectSlug / projectDataHome (issue #374 partition identity)', () =>
fs.rmSync(probeDir, { recursive: true, force: true });
});
});
describe('legacyProjectSlug (pre-#546 partition naming)', () => {
it('keeps the old <basename>-<hash> format and shares the hash with the current slug', () => {
resetCaseProbeCache();
vi.spyOn(fs, 'existsSync').mockReturnValue(false); // force case-sensitive
const legacy = legacyProjectSlug('/Users/x/Project/teamai-cli');
expect(legacy).toMatch(/^teamai-cli-[0-9a-f]{16}$/);
// Same hash suffix — this shared suffix is what makes rename-based
// adoption of a legacy partition exact (same anchor, same digest).
expect(legacy.split('-').pop()).toBe(projectSlug('/Users/x/Project/teamai-cli').split('-').pop());
// …while the current slug differs (whole-path prefix).
expect(legacy).not.toBe(projectSlug('/Users/x/Project/teamai-cli'));
});
it('folds the basename like the pre-#546 implementation (cleaned, bounded to 40)', () => {
resetCaseProbeCache();
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
const longName = 'x'.repeat(60);
const legacy = legacyProjectSlug(`/work/${longName}`);
expect(legacy).toMatch(/^x{40}-[0-9a-f]{16}$/);
});
});
describe('resolvePartitionDir (legacy partition adoption)', () => {
let base: string;
let home: string;
let anchor: string;
const projectsRoot = () => path.join(home, '.teamai', 'projects');
const canonical = () => path.join(projectsRoot(), projectSlug(anchor));
const legacy = () => path.join(projectsRoot(), legacyProjectSlug(anchor));
beforeEach(() => {
base = realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-adopt-')));
home = path.join(base, 'home');
fs.mkdirSync(home, { recursive: true });
process.env.HOME = home;
anchor = path.join(base, 'project');
fs.mkdirSync(anchor, { recursive: true });
});
afterEach(() => {
fs.rmSync(base, { recursive: true, force: true });
});
it('returns the canonical path for a fresh install (pure resolution, no mkdir)', async () => {
const dir = await resolvePartitionDir(anchor);
expect(dir).toBe(canonical());
expect(fs.existsSync(dir)).toBe(false);
});
it('adopts a legacy-named partition by renaming it under the current name', async () => {
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(path.join(legacy(), 'config.yaml'), 'repo: {}\n');
fs.writeFileSync(path.join(legacy(), 'env.local'), 'TOKEN=s3cret\n');
const dir = await resolvePartitionDir(anchor);
expect(dir).toBe(canonical());
expect(fs.existsSync(legacy())).toBe(false);
expect(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf-8')).toBe('repo: {}\n');
expect(fs.readFileSync(path.join(dir, 'env.local'), 'utf-8')).toBe('TOKEN=s3cret\n');
});
it('keeps an authoritative canonical partition and leaves a leftover legacy dir alone', async () => {
fs.mkdirSync(canonical(), { recursive: true });
fs.writeFileSync(path.join(canonical(), 'config.yaml'), 'authoritative\n');
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(path.join(legacy(), 'config.yaml'), 'stale\n');
const dir = await resolvePartitionDir(anchor);
expect(dir).toBe(canonical());
expect(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf-8')).toBe('authoritative\n');
// Never clobber the authoritative partition; the stale dir stays for
// `status --all` / manual cleanup (same rule as migration).
expect(fs.readFileSync(path.join(legacy(), 'config.yaml'), 'utf-8')).toBe('stale\n');
});
it('replaces an empty canonical leftover with the full legacy partition', async () => {
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(path.join(legacy(), 'config.yaml'), 'real\n');
fs.mkdirSync(canonical(), { recursive: true }); // bare mkdir from a crashed init
const dir = await resolvePartitionDir(anchor);
expect(dir).toBe(canonical());
expect(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf-8')).toBe('real\n');
expect(fs.existsSync(legacy())).toBe(false);
});
it('is idempotent: a resolve after adoption lands on the canonical dir', async () => {
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(path.join(legacy(), 'state.json'), '{}\n');
await resolvePartitionDir(anchor);
const again = await resolvePartitionDir(anchor);
expect(again).toBe(canonical());
expect(fs.existsSync(path.join(canonical(), 'state.json'))).toBe(true);
expect(fs.existsSync(legacy())).toBe(false);
});
it('rebases repo.localPath off the legacy dir so pull can still find the team clone', async () => {
// The team-repo clone was stored as an ABSOLUTE path inside the legacy dir.
// A bare rename would leave config.yaml pointing at a now-gone path and every
// later `pull` would silently skip the sync ("Team config not found", exit 0).
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(
path.join(legacy(), 'config.yaml'),
YAML.stringify({
repo: { localPath: path.join(legacy(), 'team-repo'), remote: 'https://x', kind: 'git' },
username: 'a', scope: 'project', projectRoot: anchor, additionalRoles: [],
}),
);
const dir = await resolvePartitionDir(anchor);
expect(dir).toBe(canonical());
const doc = YAML.parse(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf-8'));
// localPath now points inside the NEW partition, and that path exists.
expect(doc.repo.localPath).toBe(path.join(canonical(), 'team-repo'));
// Other fields survive the YAML round-trip untouched.
expect(doc.repo.remote).toBe('https://x');
expect(doc.username).toBe('a');
});
it('leaves an external repo.localPath (outside the legacy dir) untouched', async () => {
const external = path.join(base, 'elsewhere', 'team-repo');
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(
path.join(legacy(), 'config.yaml'),
YAML.stringify({ repo: { localPath: external, remote: 'https://x', kind: 'git' } }),
);
const dir = await resolvePartitionDir(anchor);
const doc = YAML.parse(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf-8'));
expect(doc.repo.localPath).toBe(external); // not inside legacyDir → left alone
});
it('is a no-op on a modern install whose localPath is already in the canonical dir', async () => {
// Fresh (current-format) partition; resolve must not rewrite anything.
fs.mkdirSync(canonical(), { recursive: true });
const yaml = YAML.stringify({
repo: { localPath: path.join(canonical(), 'team-repo'), remote: 'https://x', kind: 'git' },
});
fs.writeFileSync(path.join(canonical(), 'config.yaml'), yaml);
const dir = await resolvePartitionDir(anchor);
expect(dir).toBe(canonical());
expect(fs.readFileSync(path.join(canonical(), 'config.yaml'), 'utf-8')).toBe(yaml);
});
it('preserves config.yaml intact when the localPath rewrite fails mid-write (atomic)', async () => {
// The legacy source is already renamed away, so config.yaml is the only copy.
// A partial overwrite (ENOSPC/EFBIG/crash) must never truncate it — the write
// is atomic (same-dir temp + rename), so a failed write leaves the original.
const original = YAML.stringify({
repo: { localPath: path.join(legacy(), 'team-repo'), remote: 'https://x', kind: 'git' },
username: 'a', scope: 'project', projectRoot: anchor, additionalRoles: [],
});
fs.mkdirSync(legacy(), { recursive: true });
fs.writeFileSync(path.join(legacy(), 'config.yaml'), original);
// Fail the temp-file write the atomic writer performs (simulates EFBIG).
const spy = vi.spyOn(fse, 'writeFile').mockRejectedValueOnce(
Object.assign(new Error('EFBIG: file too large, write'), { code: 'EFBIG' }) as never,
);
// The adoption rename still happens; only the config rewrite fails and rethrows.
await expect(resolvePartitionDir(anchor)).rejects.toThrow(/EFBIG/);
spy.mockRestore();
// config.yaml survived byte-for-byte at the canonical location — not truncated,
// not empty — so the next command can retry (the rebase is idempotent).
const after = fs.readFileSync(path.join(canonical(), 'config.yaml'), 'utf-8');
expect(after).toBe(original);
// No temp file left behind.
expect(fs.readdirSync(canonical()).some((f) => f.endsWith('.tmp'))).toBe(false);
});
});
+9 -3
View File
@@ -17,7 +17,7 @@ import {
} from './types.js';
import { readFileSafe, readJson, writeFile, writeJson, expandHome, pathExists } from './utils/fs.js';
import { resolveAnchors } from './utils/git.js';
import { projectDataHome, writeAnchorFile } from './utils/partition.js';
import { resolvePartitionDir, writeAnchorFile } from './utils/partition.js';
import { log } from './utils/logger.js';
import { loadRolesManifest } from './roles.js';
@@ -247,7 +247,9 @@ export async function saveStateForScope(state: State, localConfig: LocalConfig):
*/
export async function resolveProjectDataHome(projectRoot: string): Promise<string> {
const anchors = await resolveAnchors(projectRoot);
return anchors ? projectDataHome(anchors.projectAnchor) : path.join(projectRoot, '.teamai');
// resolvePartitionDir (not bare projectDataHome) so an install whose partition
// predates the #546 naming widening is adopted (renamed) at init time too.
return anchors ? resolvePartitionDir(anchors.projectAnchor) : path.join(projectRoot, '.teamai');
}
/**
@@ -286,7 +288,11 @@ export async function detectProjectConfig(cwd?: string): Promise<LocalConfig | n
// (which may be untracked/unverified) must never hijack it. Switching that
// project to single-repo mode is `init --self`'s job (it retires the
// partition), not detection's.
const partitionDir = projectDataHome(anchors.projectAnchor);
// resolvePartitionDir (not bare projectDataHome): a partition written
// before the #546 naming widening still carries the legacy
// `<basename>-<hash>` name; adoption renames it into the current name so
// detection — and every command after it — keeps finding the config.
const partitionDir = await resolvePartitionDir(anchors.projectAnchor);
const fromPartition = await readConfigFrom(partitionDir, anchors.workspaceRoot);
if (fromPartition) return fromPartition;
// 2. No partition config yet. A workspace that declares `mode: self` self-heals
+7 -2
View File
@@ -3,7 +3,7 @@ import fse from 'fs-extra';
import YAML from 'yaml';
import { LocalConfigSchema, SYNC_LOCK_FILENAME } from './types.js';
import { resolveAnchors } from './utils/git.js';
import { projectDataHome, writeAnchorFile } from './utils/partition.js';
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';
@@ -100,7 +100,12 @@ export async function planMigration(cwd?: string): Promise<MigrationPlan | null>
if (!anchors) return null;
const legacyDir = path.join(anchors.workspaceRoot, '.teamai');
const partitionDir = projectDataHome(anchors.projectAnchor);
// resolvePartitionDir (not bare projectDataHome): a partition written before
// the #546 naming widening still carries the legacy `<basename>-<hash>` name;
// adopting (renaming) it FIRST is what keeps the "partition already built"
// checks below honest — otherwise an upgraded CLI would see "no partition"
// and re-copy a retired workspace's data into a second, empty partition.
const partitionDir = await resolvePartitionDir(anchors.projectAnchor);
const legacyConfig = path.join(legacyDir, 'config.yaml');
// Gate on scope/kind read from config.yaml. It is normally in the repo, but a
+15 -5
View File
@@ -18,7 +18,7 @@ import {
type AgentSkillsView,
} from './agent-skills.js';
import { RESOURCE_TYPES, LocalConfigSchema, getDataHome, type GlobalOptions, type ResourceType } from './types.js';
import { projectsRootDir, readAnchorFile, projectSlug } from './utils/partition.js';
import { projectsRootDir, readAnchorFile, projectSlug, legacyProjectSlug } from './utils/partition.js';
import { maskEnvValue } from './resources/env.js';
import { parseTeamMcpServers } from './resources/mcp.js';
import { parseHooksYaml } from './resources/hooks.js';
@@ -159,7 +159,11 @@ export async function status(options: GlobalOptions): Promise<void> {
* - orphan : anchor is gone (project deleted/moved) → safe to delete
* - unknown : no anchor → cannot confirm orphan (partition may still be active,
* e.g. a pre-P3 partition still loaded by its main checkout)
* - corrupt : the dir name does not match slug(anchor) → tampered/half-written
* - active (legacy name) : dir named in the pre-#546 `<basename>-<hash>`
* format — data is fine, the name just predates the widening;
* the next command that resolves the project adopts it
* - corrupt : the dir name matches neither slug(anchor) nor
* legacyProjectSlug(anchor) → tampered/half-written
*/
async function statusAll(): Promise<void> {
const root = projectsRootDir();
@@ -208,10 +212,16 @@ async function statusAll(): Promise<void> {
} else if (!(await pathExists(anchor))) {
state = 'ORPHAN — project path is gone, safe to delete';
orphanCount++;
} else if (projectSlug(anchor) !== slug) {
state = 'corrupt — dir name does not match anchor';
} else {
} else if (projectSlug(anchor) === slug) {
state = 'active';
} else if (legacyProjectSlug(anchor) === slug) {
// Pre-#546 naming (`<basename>-<hash>`): the data is fine, the name is
// just the older format. status --all never renames anything, so report
// it as active with a hint — the next command that resolves this
// project's partition adopts it under the current name automatically.
state = 'active (legacy name; renamed automatically on next command)';
} else {
state = 'corrupt — dir name does not match anchor';
}
const kindLabel = kind ? ` ${kind}` : '';
+32
View File
@@ -53,6 +53,38 @@ export async function writeFile(filePath: string, content: string): Promise<void
await fse.writeFile(expanded, content, 'utf-8');
}
/**
* Write a text file atomically (same-dir temp file + rename), preserving the
* target's existing permission bits (or defaulting to 0o600 for a new file).
*
* rename(2) within a filesystem is atomic, so a failed or interrupted write
* (ENOSPC, EFBIG, a crash mid-write) can NEVER truncate or corrupt an existing
* target — the original file is untouched until the fully-written temp file
* replaces it in one step, and on any error the temp file is removed and the
* original left in place. Use this for a single-copy, must-not-be-lost file
* such as a partition's config.yaml; `writeFile` (a plain overwrite) is fine
* for regenerable files.
*/
export async function writeFileAtomic(filePath: string, content: string): Promise<void> {
const expanded = expandHome(filePath);
await fse.ensureDir(path.dirname(expanded));
let mode = 0o600;
try {
mode = (await fse.stat(expanded)).mode & 0o777;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const tmp = `${expanded}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
try {
await fse.writeFile(tmp, content, 'utf-8');
await fse.chmod(tmp, mode);
await fse.rename(tmp, expanded);
} catch (error) {
await fse.remove(tmp).catch(() => undefined);
throw error;
}
}
/**
* Read JSON file, return null if not found
*/
+174 -6
View File
@@ -1,7 +1,9 @@
import path from 'node:path';
import fs from 'node:fs';
import { createHash } from 'node:crypto';
import YAML from 'yaml';
import { getUserHome } from './home.js';
import { readFileSafe, writeFileAtomic, expandHome } from './fs.js';
/**
* Per-project data partition identity (issue #374 P1).
@@ -22,6 +24,11 @@ import { getUserHome } from './home.js';
* such collision. The prefix is length-bounded (the hash still disambiguates
* when two long paths share a truncated head), and the authoritative reverse
* lookup remains the `anchor` file, not the (lossy, one-way) directory name.
*
* Partitions written before #546 used the `<safe-basename>-<hash>` format; the
* hash is unchanged, so those are adopted in place (renamed to the current
* name) by `resolvePartitionDir` and still recognized by `status --all` via
* `legacyProjectSlug` — no data is stranded by the widening.
*/
let caseInsensitiveCache = new Map<string, boolean>();
@@ -111,7 +118,7 @@ function safePathPrefix(anchor: string): string {
}
/**
* `<safe-path>-<sha256(normalized anchor)[:16]>` — stable per projectAnchor.
* sha256 of the normalized anchor, first 16 hex — the slug's uniqueness suffix.
*
* 16 hex = 64 bits of the digest. An 8-hex (32-bit) suffix is NOT collision-safe
* — a second-preimage against a target slug is constructible in well under a
@@ -119,24 +126,185 @@ function safePathPrefix(anchor: string): string {
* into one partition. 64 bits pushes a deliberate collision search past ~2^32
* hashes, out of casual reach, while keeping the directory name reasonable.
*/
function anchorHash(norm: string): string {
return createHash('sha256').update(norm).digest('hex').slice(0, 16);
}
/**
* `<safe-path>-<sha256(normalized anchor)[:16]>` — stable per projectAnchor.
*/
export function projectSlug(anchor: string): string {
// Normalize once so BOTH the path prefix and the hash are derived from the
// same canonical spelling — on a case-insensitive volume this makes the whole
// slug string identical for any spelling of one directory.
const norm = normalizeAnchor(anchor);
const hash = createHash('sha256').update(norm).digest('hex').slice(0, 16);
return `${safePathPrefix(norm)}-${hash}`;
return `${safePathPrefix(norm)}-${anchorHash(norm)}`;
}
/**
* Absolute path of a project's machine-data partition:
* `~/.teamai/projects/<slug(anchor)>`. `anchor` MUST be the shared projectAnchor
* (the main checkout), so all worktrees of one repo share the partition.
* The partition name format used BEFORE the prefix was widened from the anchor's
* basename to its whole path: `<safe-basename>-<hash>` (basename cleaned,
* bounded to 40 chars). The hash derivation is unchanged, so the legacy and
* current slugs of one anchor share the same suffix — which is what makes
* `resolvePartitionDir`'s rename-based adoption exact. Kept for its two
* consumers: adopting a pre-widening partition under its new name, and letting
* `status --all` report a not-yet-adopted legacy partition as active instead of
* corrupt.
*/
export function legacyProjectSlug(anchor: string): string {
const norm = normalizeAnchor(anchor);
const raw = path.basename(norm) || 'project';
const cleaned = raw.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return `${(cleaned || 'project').slice(0, 40)}-${anchorHash(norm)}`;
}
/**
* Absolute path of a project's machine-data partition in the CURRENT naming
* format: `~/.teamai/projects/<slug(anchor)>`. `anchor` MUST be the shared
* projectAnchor (the main checkout), so all worktrees of one repo share the
* partition. Pure path math — it does not look at the disk. Callers that need
* "the partition that actually holds this project's data" (detection, init,
* migration) must use `resolvePartitionDir` instead, which adopts a partition
* still named in the pre-#546 legacy format.
*/
export function projectDataHome(anchor: string): string {
return path.join(getUserHome(), '.teamai', 'projects', projectSlug(anchor));
}
/**
* Resolve the partition directory that actually holds `anchor`'s machine data,
* transparently adopting a partition written by a pre-#546 teamai under the
* legacy `<safe-basename>-<hash>` name (#546 widened the prefix to the whole
* path without migrating existing installs).
*
* Both names share the same sha256 suffix, so an anchor's legacy name is
* computable exactly — no directory scanning. When the canonical
* (current-format) directory does not exist yet and a legacy-named one does,
* the legacy partition is RENAMED into place: an atomic, same-parent metadata
* move, so no data is copied and an interrupted adoption leaves either name
* intact, never a half-moved directory. Every seam that resolves "this
* project's partition" (detection, init, migration) goes through here, so an
* upgraded CLI converges on the new name on the first command that touches the
* project. `status --all` deliberately does NOT rename (it must stay
* read-only); it recognizes the legacy name via `legacyProjectSlug` instead.
*
* Edge cases:
* - canonical already exists with data → it is authoritative; a leftover
* legacy dir is left untouched for `status --all` / manual cleanup (same
* rule as migration: never overwrite an authoritative partition).
* - canonical exists but is EMPTY (a crashed init's bare mkdir) while the
* legacy partition holds the data → the empty dir is replaced. POSIX
* rename already does this in one call; Windows (which refuses to rename
* onto an existing dir) takes the explicit rmdir-then-rename path.
* - rename genuinely impossible (e.g. read-only home) and legacy exists →
* the legacy directory keeps serving as the partition, so no data is
* stranded and nothing pretends to be fresh.
*
* The microscopic race (two processes adopting at once) is benign: the loser
* observes the legacy name gone, finds the canonical name in place, and lands
* on the same directory.
*
* Whenever the resolution lands on `canonical`, the stored `repo.localPath` is
* rebased off the legacy directory (see `rebaseLocalPathAfterAdoption`): the
* team-repo clone lived at `<legacyDir>/team-repo` as an ABSOLUTE path in
* config.yaml, so a bare directory rename would leave the config pointing at a
* now-gone path and every later `pull` would silently skip the sync. The
* rewrite is idempotent, so it also finishes an adoption that crashed between
* the rename and the config rewrite.
*/
export async function resolvePartitionDir(anchor: string): Promise<string> {
const canonical = projectDataHome(anchor);
const legacyDir = path.join(projectsRootDir(), legacyProjectSlug(anchor));
if (legacyDir === canonical) return canonical; // whole-path prefix == basename (root-level anchor)
const dir = await adoptLegacyPartition(canonical, legacyDir);
if (dir === canonical) await rebaseLocalPathAfterAdoption(canonical, legacyDir);
return dir;
}
/** Perform the rename-based adoption, returning the directory that holds the data. */
async function adoptLegacyPartition(canonical: string, legacyDir: string): Promise<string> {
try {
await fs.promises.rename(legacyDir, canonical);
return canonical;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
// Nothing to adopt — the common fresh-install case (or a concurrent
// process just adopted it, in which case canonical now exists).
return canonical;
}
const legacyExists = await dirExists(legacyDir);
// null → canonical does not exist; [] → exists but is empty.
const canonicalEntries = await fs.promises.readdir(canonical).catch(() => null);
if (canonicalEntries && canonicalEntries.length > 0) {
return canonical; // authoritative data — never clobber it
}
if (legacyExists && canonicalEntries) {
// Canonical is an empty leftover and the FS refused the rename onto it.
await fs.promises.rmdir(canonical).catch(() => {});
try {
await fs.promises.rename(legacyDir, canonical);
} catch { /* fall through to whichever dir actually exists */ }
return canonical;
}
// Rename failed for a real reason (e.g. permissions) with no canonical
// dir: keep serving the legacy partition so the data stays reachable.
return legacyExists ? legacyDir : canonical;
}
}
/**
* After a legacy partition is adopted (renamed) into `canonical`, its
* config.yaml still stores `repo.localPath` as an absolute path inside the old
* `legacyDir` — the team-repo clone was at `<legacyDir>/team-repo`. That path is
* gone, so `pull` would read the team config from a dead directory and skip the
* sync (exit 0, "Team config not found"). Rewrite the stored path into the new
* partition.
*
* Idempotent and safe to run on every resolve that lands on canonical:
* - a modern install's localPath is already inside canonical (not legacyDir),
* so the `path.relative` containment check leaves it untouched;
* - an adoption that crashed after the rename but before this rewrite is
* finished by the next command (the stale localPath is detected and fixed).
*
* `repo.localPath` is the only absolute path persisted in config.yaml (mirrors
* migrate.ts's `rebaseConfigPaths`); an external clone whose localPath sits
* outside legacyDir is left alone. The legacy path is gone at this point, so we
* compare on the expanded (not realpath'd) spelling — both `legacyDir` and the
* persisted path are built from the same `getUserHome()` root.
*/
async function rebaseLocalPathAfterAdoption(canonical: string, legacyDir: string): Promise<void> {
const configPath = path.join(canonical, 'config.yaml');
const content = await readFileSafe(configPath);
if (!content) return;
let doc: Record<string, unknown>;
try {
doc = YAML.parse(content) as Record<string, unknown>;
} catch {
return; // malformed config — leave it for status/doctor to surface
}
const repo = doc?.repo as { localPath?: string } | undefined;
if (!repo?.localPath) return;
const rel = path.relative(legacyDir, expandHome(repo.localPath));
if (rel === '' ? false : rel.startsWith('..') || path.isAbsolute(rel)) return; // not inside legacyDir
const rebased = rel === '' ? canonical : path.join(canonical, rel);
if (rebased === repo.localPath) return;
repo.localPath = rebased;
// Atomic write (same-dir temp + rename): config.yaml is the partition's only
// copy and the legacy source has already been renamed away, so a partial
// overwrite (ENOSPC/EFBIG/crash mid-write) would truncate it with no way back.
// A failed rename leaves the original config.yaml untouched.
await writeFileAtomic(configPath, YAML.stringify(doc));
}
async function dirExists(p: string): Promise<boolean> {
try {
await fs.promises.access(p);
return true;
} catch {
return false;
}
}
/** Root dir holding every project partition: `~/.teamai/projects`. */
export function projectsRootDir(): string {
return path.join(getUserHome(), '.teamai', 'projects');