diff --git a/packages/agent-memory-sync/docs/machine-setup.md b/packages/agent-memory-sync/docs/machine-setup.md index 3947814..75beb18 100644 --- a/packages/agent-memory-sync/docs/machine-setup.md +++ b/packages/agent-memory-sync/docs/machine-setup.md @@ -227,7 +227,10 @@ empty/stale local workspace as if it were authoritative. Claude Code slug for `rootDir` — do not guess it from the other profiles' slugs, since it encodes this machine's own OS username and checkout path (see the namespace-divergence note inside any existing - profile file). + profile file). The template's `syncPaths` already includes the + `machine-state` entry (see (e) below) alongside `memory` and + `frictions`; fill in its `` placeholder like the rest + of the file rather than adding the entry by hand. 4. First pull so the machine starts from the mini's current state: ```bash agent-memory-sync run --config profiles/linux.json --mode pull @@ -333,12 +336,13 @@ bullet at the top of this document. ## e) machine-state payload (toolchain snapshots) Every committed machine profile (`profiles/mac-mini.json`, -`profiles/macbook.json`, `profiles/linux.json`) -carries a **second, independent** `syncPaths` entry alongside the `memory` -one described at the top of this document: +`profiles/macbook.json`, `profiles/linux.json`), and the template, +`profiles/linux.example.json`, carries a **second, independent** +`syncPaths` entry alongside the `memory` one described at the top of this +document: ```json -{ "source": "/Users//.harness/machine-state", "destination": "machine-state", "kind": "directory" } +{ "source": "/Users//.harness/machine-state", "destination": "machine-state", "kind": "directory", "ownerScoped": true } ``` Unlike the `memory` entry, `source` here is an **absolute path outside @@ -350,12 +354,21 @@ treats an absolute `source` as-is instead of resolving it against `rootDir`, so this entry syncs on its own schedule independent of the memory tree; both entries still land under the same shared `pandora/` remote tree (`repositorySubdir`), just under different top-level destinations -(`pandora/memory/...` vs. `pandora/machine-state/...`). +(`pandora/memory/...` vs. `pandora/machine-state/...`). In +`profiles/linux.example.json` the entry's `source` uses the same +`` placeholder as `rootDir`/`stateDir` (see (c) above); +copying the template and filling in that one placeholder is all a new +machine needs to do here, rather than adding this entry from scratch. **Payload convention — one file per machine, owner-writes-only.** Each machine writes exactly one JSON file under its own `machine-state/`, named after its own profile (`machine-state/mac-mini.json`, -`machine-state/macbook.json`, ...). A machine never writes to another +`machine-state/macbook.json`, ...). This is enforced, not just documented: +the syncPaths entry above sets `"ownerScoped": true`, which is the +mechanism that makes push only ever offer this machine's own +`.json`, never a peer's file this machine merely pulled (see +`collectLocalSyncFiles`'s `ownerFilter` option in +`src/memory-sync/config.ts`). A machine never writes to another machine's file — this makes *content* conflicts on this path structurally impossible (`inline-markers` conflict resolution is never invoked here in practice, unlike the `memory` tree where concurrent edits are expected). @@ -403,12 +416,12 @@ exist ahead of any sync. Every committed profile (`profiles/mac-mini.json`, `profiles/macbook.json`, `profiles/linux.json`, `profiles/linux.example.json`) also carries an **independent** `syncPaths` entry pointing at `~/.harness/frictions` — the -third entry in `mac-mini.json`/`macbook.json`/`linux.json` (after `memory` -and `machine-state`), and the second in `linux.example.json` (that template -does not yet include a `machine-state` entry): +third entry in every one of them, after `memory` and `machine-state` (see +(e) above; the template carries a placeholder `machine-state` entry too, so +its `syncPaths` array has the same three-entry shape as the real profiles): ```json -{ "source": "/Users//.harness/frictions", "destination": "frictions", "kind": "directory" } +{ "source": "/Users//.harness/frictions", "destination": "frictions", "kind": "directory", "ownerScoped": true } ``` Same convention as the machine-state payload in section e) above: one file diff --git a/packages/agent-memory-sync/profiles/linux.example.json b/packages/agent-memory-sync/profiles/linux.example.json index 22b85b1..f1ae633 100644 --- a/packages/agent-memory-sync/profiles/linux.example.json +++ b/packages/agent-memory-sync/profiles/linux.example.json @@ -39,7 +39,7 @@ "recorded as a cosmetic label inside this machine's state.json. Fill it", "in anyway, for a self-documenting invocation and a correct label.", "", - "syncPaths is a single directory entry covering the ENTIRE rootDir", + "syncPaths' FIRST entry is a directory entry covering the ENTIRE rootDir", "(source '.'), so every file agent-memory-sync finds there is synced,", "including future subdirectories, without listing individual", "files/folders by name. destination is the non-'.' literal 'memory', not", @@ -51,6 +51,19 @@ "see isHiddenEntryName in src/memory-sync/config.ts and", "src/memory-sync/git-client.ts.", "", + "syncPaths' SECOND entry is machine-state (absolute source", + "/home//.harness/machine-state, destination", + "'machine-state'), carrying this machine's toolchain snapshot for the", + "cross-machine parity check run by the harness companion 'session-start", + "toolchain-parity'; see docs/machine-setup.md section e) for the full", + "payload convention (one file per machine, owner-writes-only) and", + "section f) for the THIRD entry, frictions. Both entries set", + "\"ownerScoped\": true, matching the real linux.json/macbook.json/", + "mac-mini.json profiles, so push only ever offers this machine's own", + ".json under machine-state/ or frictions/ and never a peer's", + "file this machine merely pulled; see collectLocalSyncFiles'", + "ownerFilter option in src/memory-sync/config.ts.", + "", "stateDir is set OUTSIDE rootDir (a separate ~/.agent-memory-sync tree),", "which matters now that syncPaths covers the whole rootDir: if stateDir", "stayed nested inside rootDir, the recursive walk would also pick up", @@ -82,6 +95,7 @@ "reachabilityTimeoutMs": 5000, "syncPaths": [ { "source": ".", "destination": "memory", "kind": "directory" }, - { "source": "/home//.harness/frictions", "destination": "frictions", "kind": "directory" } + { "source": "/home//.harness/machine-state", "destination": "machine-state", "kind": "directory", "ownerScoped": true }, + { "source": "/home//.harness/frictions", "destination": "frictions", "kind": "directory", "ownerScoped": true } ] } diff --git a/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts b/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts index 05d6542..285e785 100644 --- a/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts +++ b/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts @@ -23,6 +23,39 @@ const { cloneRemote, createSandbox, fileExists, initBareRemote, readText, runCli const PROFILES_DIR = path.resolve(process.cwd(), "profiles"); +// Derives the committed profile file list structurally (readdirSync over +// profiles/*.json) instead of a hand-maintained array, so a future profile +// (including a new machine or a template edit) is picked up by every test +// below that iterates "all committed profiles" without a matching edit +// here. Deliberately includes linux.example.json: the template is a +// committed *.json file under profiles/ like any other. +// +// A purely structural derivation loses deletion/rename detection though: +// an empty or partially-emptied profiles/ directory would silently make +// every test below iterate over fewer (or zero) files and report green +// instead of catching the loss. The non-vacuity guard below restores that +// detection while still auto-picking-up any future profile: it requires +// at least the 4 known files to be present, by name, on every call. +function listProfileFiles(): string[] { + const files = readdirSync(PROFILES_DIR) + .filter((name: string) => name.endsWith(".json")) + .sort(); + + const knownProfiles = ["macbook.json", "mac-mini.json", "linux.json", "linux.example.json"]; + assert.ok( + files.length >= 4, + `profiles/ must contain at least the ${knownProfiles.length} known machine profiles, found ${files.length}: ${JSON.stringify(files)} (PROFILES_DIR: ${PROFILES_DIR})` + ); + const missing = knownProfiles.filter((name) => !files.includes(name)); + assert.equal( + missing.length, + 0, + `profiles/ is missing known machine profile(s): ${JSON.stringify(missing)}, found: ${JSON.stringify(files)} (PROFILES_DIR: ${PROFILES_DIR})` + ); + + return files; +} + function machineArgs( profileName: string, configPath: string, @@ -129,13 +162,14 @@ test("macbook and mac-mini profiles share one remote tree and see each other's p test("all committed profiles (macbook, mac-mini, linux, linux.example) declare the same repositorySubdir", () => { // A narrower, faster companion to the end-to-end test above: pins the // specific config field that caused the divergence directly against the - // committed files, independent of any CLI/git plumbing. Includes - // linux.example.json — the copy-paste source for any new machine — so a - // future template edit that reintroduces a per-machine placeholder (as - // this template originally had, mirroring the pre-fix macbook/mac-mini - // profiles) is caught here too, not just on the profiles already in - // active use. - const profileFiles = ["macbook.json", "mac-mini.json", "linux.json", "linux.example.json"]; + // committed files, independent of any CLI/git plumbing. profileFiles is + // derived structurally (readdirSync, see listProfileFiles above), so it + // always includes linux.example.json (the copy-paste source for any new + // machine) without listing filenames by hand: a future template edit + // that reintroduces a per-machine placeholder (as this template + // originally had, mirroring the pre-fix macbook/mac-mini profiles) is + // caught here too, not just on the profiles already in active use. + const profileFiles = listProfileFiles(); const settingsByFile = Object.fromEntries( profileFiles.map((file) => [file, JSON.parse(readText(path.join(PROFILES_DIR, file)))]) ); @@ -195,11 +229,16 @@ test("all committed profiles (macbook, mac-mini, linux, linux.example) declare t ); } - // Same pin for the pre-existing machine-state entry, closing the identical - // #64 coverage gap — every committed machine profile, but not - // linux.example.json, since the template has never carried a machine-state - // entry (linux.json adds it deliberately; see machine-setup.md section f). - for (const file of ["macbook.json", "mac-mini.json", "linux.json"]) { + // Same pin for the machine-state entry, closing the identical #64 + // coverage gap for every committed profile, including linux.example.json: + // the template now carries the entry too (placeholder + // source), precisely so a future third machine copied from it starts with + // the entry present instead of repeating the hand-patch divergence + // linux.json/macbook.json/mac-mini.json needed before this fix (agent-tasks + // 10df0d9d; see machine-setup.md section e)/f)). The endsWith check below + // tolerates the template's placeholder segment (the path is still + // absolute-shaped and still ends with the literal suffix). + for (const file of profileFiles) { const machineStateEntries = findEntriesByDestination(settingsByFile[file].syncPaths, "machine-state"); assert.equal( machineStateEntries.length, @@ -219,18 +258,74 @@ test("all committed profiles (macbook, mac-mini, linux, linux.example) declare t } }); +// Pins the three path invariants documented in every profile's "//" field +// (real profiles' "rootDir/stateDir use a resolved absolute path, not '~'" +// paragraph; the template's identical paragraph): rootDir/stateDir must be +// absolute, must not start with '~' (agent-memory-sync's config loader +// never expands it; see resolveRunConfig() in src/config/loader.ts, which +// treats a leading '~' as a literal path segment, not the home directory), +// and stateDir must sit OUTSIDE rootDir (otherwise the recursive '.' +// syncPaths walk would also pick up this tool's own state (queue/base/tmp) +// and try to sync it as memory content). profileFiles is the same +// structurally-derived list used above, so linux.example.json is included: +// its placeholder segments (e.g. '', '') +// keep both paths absolute-shaped (still start with '/') and un-prefixed by +// '~', and its stateDir/rootDir still resolve to different subtrees, so all +// three checks hold for the template's placeholder values with no +// special-casing: these are pure string checks, never filesystem lookups, +// so an unresolved placeholder segment does not make them fail or need to +// be skipped. +test("all committed profiles keep rootDir/stateDir absolute, un-expanded (no leading '~'), and stateDir outside rootDir", () => { + const profileFiles = listProfileFiles(); + const settingsByFile = Object.fromEntries( + profileFiles.map((file) => [file, JSON.parse(readText(path.join(PROFILES_DIR, file)))]) + ); + + for (const file of profileFiles) { + const { rootDir, stateDir } = settingsByFile[file]; + + assert.ok( + typeof rootDir === "string" && path.isAbsolute(rootDir), + `profiles/${file} rootDir must be an absolute path, got: ${JSON.stringify(rootDir)}` + ); + assert.ok( + typeof stateDir === "string" && path.isAbsolute(stateDir), + `profiles/${file} stateDir must be an absolute path, got: ${JSON.stringify(stateDir)}` + ); + assert.ok( + !(rootDir as string).startsWith("~"), + `profiles/${file} rootDir must not start with '~' (the config loader never expands it), got: ${rootDir}` + ); + assert.ok( + !(stateDir as string).startsWith("~"), + `profiles/${file} stateDir must not start with '~' (the config loader never expands it), got: ${stateDir}` + ); + + const rel = path.posix.relative(path.posix.normalize(rootDir as string), path.posix.normalize(stateDir as string)); + assert.ok( + rel !== "" && (rel.startsWith("../") || path.posix.isAbsolute(rel)), + `profiles/${file} stateDir must sit OUTSIDE rootDir, got rootDir: ${rootDir}, stateDir: ${stateDir}` + ); + } +}); + // Pins Defect B's fix (agent-tasks 06d09cde / .ai/runs/2026-08-03-sync-conflict-markers-echo, // D-002/D-003): machine-state and frictions are one-owner-file-per-machine // destinations, so push must never re-offer a peer's file it only pulled — -// ownerScoped: true on both entries in every real machine profile is what +// ownerScoped: true on both entries in every committed profile is what // makes collectLocalSyncFiles' ownerFilter (src/memory-sync/config.ts) -// actually engage. Deliberately scoped to the same 3 real profiles as the -// machine-state pin above, not linux.example.json — the template documents -// the convention but was never a live sync target, so it carries no -// machine-state entry at all and this task's brief only requires the flag on -// "die 3 committeten Profilen". -test("macbook, mac-mini, and linux profiles set ownerScoped: true on both their machine-state and frictions entries", () => { - const profileFiles = ["macbook.json", "mac-mini.json", "linux.json"]; +// actually engage. profileFiles is the same structurally-derived list used +// by the tests above (see listProfileFiles), so it now includes +// linux.example.json too: an earlier revision of this test hand-scoped the +// list to the 3 real profiles and excluded the template, because the +// template's machine-state entry set ownerScoped: true (mirroring the real +// profiles, agent-tasks 10df0d9d) while its frictions entry still did not; +// including the template would have failed on that entry alone. The +// template's frictions entry now sets ownerScoped: true too, closing that +// gap, so the exclusion is no longer needed and a future regression in the +// template is caught here like any real profile. +test("all committed profiles set ownerScoped: true on both their machine-state and frictions entries", () => { + const profileFiles = listProfileFiles(); const settingsByFile = Object.fromEntries( profileFiles.map((file) => [file, JSON.parse(readText(path.join(PROFILES_DIR, file)))]) );