diff --git a/packages/agent-memory-sync/docs/machine-setup.md b/packages/agent-memory-sync/docs/machine-setup.md index e8cf049..a3970b5 100644 --- a/packages/agent-memory-sync/docs/machine-setup.md +++ b/packages/agent-memory-sync/docs/machine-setup.md @@ -19,14 +19,17 @@ This document wires together the pieces already documented individually per-machine value (mirroring the profile name) — that made every machine write to its own top-level tree in the bare repo and none of them ever saw each other's pushes, caught by a live cross-machine E2E test - (`pull` reporting `applied=0`). `syncPaths` in every profile is a single - `source: "."` directory entry with `destination: "memory"`, so the whole - `rootDir` (flat `.md` files, no fixed subdirectory layout) syncs to - `pandora/memory/...` in the bare repo, including any file added later. The - directory walk skips hidden files/dot-directories (`.DS_Store`, `._*` - AppleDouble shadows, `.git`, ...) by design — macOS cruft never becomes a - synced file or a recurring inline-conflict-marker diff between machines — - and it never follows symlinks under `rootDir` either (silently skipped, + (`pull` reporting `applied=0`). The `memory` entry in every profile's + `syncPaths` — a `source: "."` directory entry with `destination: + "memory"` — covers the whole `rootDir` (flat `.md` files, no fixed + subdirectory layout), so it syncs to `pandora/memory/...` in the bare + repo, including any file added later (profiles carry further, + independent `syncPaths` entries beyond this one — see (e) and (f) + below). The directory walk skips hidden files/dot-directories + (`.DS_Store`, `._*` AppleDouble shadows, `.git`, ...) by design — macOS + cruft never becomes a synced file or a recurring inline-conflict-marker + diff between machines — and it never follows symlinks under `rootDir` + either (silently skipped, not descended into; intentional containment). See `isHiddenEntryName` in `src/memory-sync/config.ts` / `src/memory-sync/git-client.ts`. - Conflict strategy is `inline-markers` everywhere — concurrent edits that @@ -298,10 +301,12 @@ is `agent-memory-sync restore`: # of guessing why a short one did not resolve. agent-memory-sync restore --config profiles/.json --dry-run -# Roll back a single file — --path is relative to repositorySubdir, and -# every profile's syncPaths destination is "memory" (see the shared remote -# tree bullet at the top of this document), so the path is memory/, -# not just the bare filename: +# Roll back a single file — --path is relative to repositorySubdir. Only +# the memory tree's syncPaths destination is "memory" (see the shared +# remote tree bullet at the top of this document); the other payloads +# (machine-state, frictions) use their own destinations instead — see +# (e)/(f) below. So a memory file's path is memory/, not just the +# bare filename: agent-memory-sync restore --config profiles/.json --path memory/MEMORY.md # Roll back the entire synced tree (requires --yes) @@ -337,7 +342,7 @@ one described at the top of this document: Unlike the `memory` entry, `source` here is an **absolute path outside `rootDir`** — `~/.harness/machine-state`, not anywhere under the Claude Code memory directory — pointing at a per-machine toolchain-snapshot directory -maintained by the (upcoming) harness companion `session-start +maintained by the (shipped) harness companion `session-start toolchain-parity`. `resolveWorkspacePath` in `src/memory-sync/config.ts` 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 @@ -390,3 +395,52 @@ peer's snapshot is pulled down, so no manual `mkdir` step is required during machine setup either — though the harness companion consuming these files may still create the directory itself on first run if it expects it to exist ahead of any sync. + +## f) frictions payload (friction-log exports) + +Every committed profile (`profiles/mac-mini.json`, `profiles/macbook.json`, +`profiles/linux.example.json`) also carries an **independent** `syncPaths` +entry pointing at `~/.harness/frictions` — the third entry in +`mac-mini.json`/`macbook.json` (after `memory` and `machine-state`), and +the second in `linux.example.json` (that template does not yet include a +`machine-state` entry): + +```json +{ "source": "/Users//.harness/frictions", "destination": "frictions", "kind": "directory" } +``` + +Same convention as the machine-state payload in section e) above: one file +per machine, named after its own profile (`frictions/mac-mini.json`, +`frictions/macbook.json`, ...), owner-writes-only, and never any secret — +the directory is synced into the shared, committed remote, so anything +dropped there ends up in every peer's git history. + +**Producer convention.** Each machine configures `friction-log`'s +`sync_export.path` setting to `~/.harness/frictions/.json` +(e.g. `~/.harness/frictions/mac-mini.json`) — `friction-log` writes exactly +that configured path and never derives a filename of its own, so the +owner-writes-only convention above is carried entirely by that per-machine +config, not by anything in this package. The file is also kept current via +write-through from `friction-log`'s six mutating commands, not only by the +dedicated `sync-export` verb, so `frictions/.json` can reflect a +mutation made through any of those commands, not just an explicit export +step. This producer (`friction-log`, config-gated) is shipped in +`agent-dx`, not in this repo; this `syncPaths` entry only carries the +resulting file, it does not generate it. + +The same missing-source tolerance and `mkdir -p`-on-pull behavior described +above for `machine-state` apply here unchanged, since both are plain +non-required, absolute-source, directory-kind `syncPaths` entries handled +by the same code path. The same `watch` caveats from section e) apply too: +`watch` only pushes and never pulls, so a peer's `frictions/.json` +only shows up locally after the next `pull`/`sync`; and `watch` only +detects this machine's own very first local write into `frictions/` if +`~/.harness` already existed when `watch` started — on a truly fresh +machine the first export travels via the periodic sync job or after a +`watch` restart instead. + +**Exposure note.** Friction-log records are free text. Like every other +payload in this document, anything written into +`~/.harness/frictions/.json` is synced into the shared, committed +remote and therefore counts as published into every peer's git history — +redact anything sensitive before logging it, not after. diff --git a/packages/agent-memory-sync/profiles/linux.example.json b/packages/agent-memory-sync/profiles/linux.example.json index 89362d1..22b85b1 100644 --- a/packages/agent-memory-sync/profiles/linux.example.json +++ b/packages/agent-memory-sync/profiles/linux.example.json @@ -81,6 +81,7 @@ "conflictStrategy": "inline-markers", "reachabilityTimeoutMs": 5000, "syncPaths": [ - { "source": ".", "destination": "memory", "kind": "directory" } + { "source": ".", "destination": "memory", "kind": "directory" }, + { "source": "/home//.harness/frictions", "destination": "frictions", "kind": "directory" } ] } diff --git a/packages/agent-memory-sync/profiles/mac-mini.json b/packages/agent-memory-sync/profiles/mac-mini.json index de12022..891a90f 100644 --- a/packages/agent-memory-sync/profiles/mac-mini.json +++ b/packages/agent-memory-sync/profiles/mac-mini.json @@ -117,6 +117,7 @@ "reachabilityTimeoutMs": 5000, "syncPaths": [ { "source": ".", "destination": "memory", "kind": "directory" }, - { "source": "/Users/lannguyensi/.harness/machine-state", "destination": "machine-state", "kind": "directory" } + { "source": "/Users/lannguyensi/.harness/machine-state", "destination": "machine-state", "kind": "directory" }, + { "source": "/Users/lannguyensi/.harness/frictions", "destination": "frictions", "kind": "directory" } ] } diff --git a/packages/agent-memory-sync/profiles/macbook.json b/packages/agent-memory-sync/profiles/macbook.json index a8e450f..36d8959 100644 --- a/packages/agent-memory-sync/profiles/macbook.json +++ b/packages/agent-memory-sync/profiles/macbook.json @@ -110,6 +110,11 @@ "source": "/Users/lan/.harness/machine-state", "destination": "machine-state", "kind": "directory" + }, + { + "source": "/Users/lan/.harness/frictions", + "destination": "frictions", + "kind": "directory" } ] } \ No newline at end of file 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 17da935..c06bfc9 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 @@ -155,4 +155,65 @@ test("profiles/macbook.json, profiles/mac-mini.json, and profiles/linux.example. "in src/memory-sync/config.ts); the 'profile' field is local-only and may differ" ); } + + // Pin the frictions syncPaths entry (agent-tasks 343d5a8f) directly + // against each committed profile file — deliberately placed in THIS test, + // not the live push/pull E2E test above, because this one parses the + // profile JSON via settingsByFile and never touches a real machine's home + // directory, so it stays green even where the E2E test's EACCES failure + // (mac-mini.json's hardcoded /Users/lannguyensi paths on a machine that + // isn't the mini) applies. Without this block, reverting the three + // profiles' new syncPaths entry left the suite fully green — nothing else + // reads the committed profile files for this entry. + function findEntriesByDestination( + syncPaths: Array> | undefined, + destination: string + ): Array> { + return (syncPaths || []).filter((entry) => entry.destination === destination); + } + + for (const file of profileFiles) { + const frictionsEntries = findEntriesByDestination(settingsByFile[file].syncPaths, "frictions"); + assert.equal( + frictionsEntries.length, + 1, + `profiles/${file} must declare exactly one syncPaths entry with destination 'frictions', found ${frictionsEntries.length}` + ); + const [frictionsEntry] = frictionsEntries; + assert.equal(frictionsEntry.kind, "directory", `profiles/${file} frictions entry must be kind 'directory'`); + assert.ok( + typeof frictionsEntry.source === "string" && path.isAbsolute(frictionsEntry.source as string), + `profiles/${file} frictions entry source must be an absolute path, got: ${JSON.stringify(frictionsEntry.source)}` + ); + assert.ok( + (frictionsEntry.source as string).endsWith("/.harness/frictions"), + `profiles/${file} frictions entry source must end with '/.harness/frictions', got: ${frictionsEntry.source}` + ); + assert.ok( + !frictionsEntry.required, + `profiles/${file} frictions entry must not be required (missing-source tolerance is the point)` + ); + } + + // Same pin for the pre-existing machine-state entry, closing the identical + // #64 coverage gap — macbook.json and mac-mini.json only, since + // linux.example.json's template has never carried a machine-state entry. + for (const file of ["macbook.json", "mac-mini.json"]) { + const machineStateEntries = findEntriesByDestination(settingsByFile[file].syncPaths, "machine-state"); + assert.equal( + machineStateEntries.length, + 1, + `profiles/${file} must declare exactly one syncPaths entry with destination 'machine-state', found ${machineStateEntries.length}` + ); + const [machineStateEntry] = machineStateEntries; + assert.equal(machineStateEntry.kind, "directory", `profiles/${file} machine-state entry must be kind 'directory'`); + assert.ok( + typeof machineStateEntry.source === "string" && path.isAbsolute(machineStateEntry.source as string), + `profiles/${file} machine-state entry source must be an absolute path, got: ${JSON.stringify(machineStateEntry.source)}` + ); + assert.ok( + (machineStateEntry.source as string).endsWith("/.harness/machine-state"), + `profiles/${file} machine-state entry source must end with '/.harness/machine-state', got: ${machineStateEntry.source}` + ); + } }); diff --git a/packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts b/packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts new file mode 100644 index 0000000..3f3b2c0 --- /dev/null +++ b/packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts @@ -0,0 +1,140 @@ +// Pins the frictions syncPaths convention (agent-tasks 343d5a8f: "agent-memory-sync: +// frictions syncPaths-Eintrag in allen Maschinen-Profilen"): a third, +// independent directory-kind syncPaths entry whose `source` is an ABSOLUTE +// path OUTSIDE rootDir — mirroring profiles/mac-mini.json, profiles/macbook.json, +// and profiles/linux.example.json's real frictions entry, which points at +// ~/.harness/frictions rather than anywhere under the Claude Code memory +// rootDir. This is the exact PR-#64 machine-state-syncpath pattern (see +// tests/integration/machine-state-syncpath.test.ts) applied to the frictions +// payload — same two guarantees, both already true of +// collectLocalSyncFiles/mapRemotePathToLocalAbsolute in +// src/memory-sync/config.ts and src/memory-sync/pull.ts WITHOUT any code +// change: +// 1. A directory-kind entry with an absolute `source` syncs correctly +// end-to-end through push+pull alongside sibling entries — +// resolveWorkspacePath (src/memory-sync/config.ts) treats an absolute +// candidate as-is instead of resolving it against rootDir. +// 2. Neither `push` nor `pull` breaks when that absolute source directory +// does not exist locally yet: config.ts's collectLocalSyncFiles skips a +// non-required syncPaths entry whose source is missing (existsSync +// check, `continue`), and pull's writer +// (mkdirSync(path.dirname(...), { recursive: true }) in +// src/memory-sync/pull.ts) creates that absolute directory the first +// time a peer's snapshot is pulled — so a fresh machine's frictions +// directory need not pre-exist before its first pull. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { + cloneRemote, + createSandbox, + fileExists, + initBareRemote, + readText, + runCli, + writeProjectConfig, + writeText +} = require("../helpers/cli.ts"); + +function twoEntryConfig( + workspaceRoot: string, + remoteDir: string, + stateDir: string, + frictionsSource: string +) { + return { + rootDir: workspaceRoot, + remoteUrl: remoteDir, + branch: "main", + repositorySubdir: "shared", + stateDir, + conflictStrategy: "inline-markers", + syncPaths: [ + { source: "MEMORY.md", destination: "MEMORY.md", kind: "file" }, + { source: frictionsSource, destination: "frictions", kind: "directory" } + ] + }; +} + +test("push tolerates a second syncPaths directory entry whose absolute source does not exist locally yet", () => { + const root = createSandbox("frictions-missing-source"); + const remoteDir = initBareRemote(root); + const workspaceRoot = path.join(root, "workspace"); + const stateDir = path.join(root, "state"); + const configPath = path.join(root, "config.json"); + // Absolute path outside rootDir/workspaceRoot, deliberately never + // created — mirrors a fresh machine that has not written a friction-log + // export to ~/.harness/frictions yet. + const frictionsSource = path.join(root, "harness-frictions"); + + writeText(path.join(workspaceRoot, "MEMORY.md"), "seed\n"); + writeProjectConfig(configPath, twoEntryConfig(workspaceRoot, remoteDir, stateDir, frictionsSource)); + assert.equal(fileExists(frictionsSource), false); + + const result = runCli(["run", "default", "--config", configPath, "--mode", "push", "--output", "json"]); + const payload = JSON.parse(result.stdout); + + assert.equal(payload.runs[0].status, "applied"); + assert.ok( + payload.runs[0].appliedFiles.some((f: string) => f.endsWith("MEMORY.md")), + `expected MEMORY.md in appliedFiles: ${JSON.stringify(payload.runs[0].appliedFiles)}` + ); + assert.ok( + !payload.runs[0].appliedFiles.some((f: string) => f.startsWith("frictions/")), + `missing source should not have synced anything: ${JSON.stringify(payload.runs[0].appliedFiles)}` + ); + + const inspection = cloneRemote(remoteDir, root, "inspect-missing-source"); + assert.equal(fileExists(path.join(inspection, "shared", "frictions")), false); +}); + +test( + "push and pull round-trip a directory syncPaths entry whose source is absolute and outside rootDir, " + + "and pull creates that directory locally if missing (mkdir -p semantics)", + () => { + const root = createSandbox("frictions-roundtrip"); + const remoteDir = initBareRemote(root); + + // Machine A already has a friction-log export on disk and pushes it. + const workspaceA = path.join(root, "workspace-a"); + const stateDirA = path.join(root, "state-a"); + const configPathA = path.join(root, "config-a.json"); + const frictionsSourceA = path.join(root, "machine-a-harness-frictions"); + + writeText(path.join(workspaceA, "MEMORY.md"), "machine a memory\n"); + writeText(path.join(frictionsSourceA, "mac-mini.json"), '{"frictions":[]}\n'); + writeProjectConfig(configPathA, twoEntryConfig(workspaceA, remoteDir, stateDirA, frictionsSourceA)); + + const pushResult = runCli(["run", "default", "--config", configPathA, "--mode", "push", "--output", "json"]); + const pushPayload = JSON.parse(pushResult.stdout); + assert.equal(pushPayload.runs[0].status, "applied"); + assert.ok( + pushPayload.runs[0].appliedFiles.includes("frictions/mac-mini.json"), + `expected frictions/mac-mini.json in appliedFiles: ${JSON.stringify(pushPayload.runs[0].appliedFiles)}` + ); + + // Machine B has never written to its frictions source directory — it + // (and its rootDir) do not exist on disk at all before the pull below, + // matching a brand-new machine's first `run --mode pull`. + const workspaceB = path.join(root, "workspace-b"); + const stateDirB = path.join(root, "state-b"); + const configPathB = path.join(root, "config-b.json"); + const frictionsSourceB = path.join(root, "machine-b-harness-frictions"); + + writeProjectConfig(configPathB, twoEntryConfig(workspaceB, remoteDir, stateDirB, frictionsSourceB)); + assert.equal(fileExists(frictionsSourceB), false); + assert.equal(fileExists(workspaceB), false); + + const pullResult = runCli(["run", "default", "--config", configPathB, "--mode", "pull", "--output", "json"]); + const pullPayload = JSON.parse(pullResult.stdout); + assert.equal(pullPayload.runs[0].status, "applied"); + assert.ok( + pullPayload.runs[0].appliedFiles.includes("frictions/mac-mini.json"), + `expected frictions/mac-mini.json in pull appliedFiles: ${JSON.stringify(pullPayload.runs[0].appliedFiles)}` + ); + + assert.equal(fileExists(frictionsSourceB), true); + assert.equal(readText(path.join(frictionsSourceB, "mac-mini.json")), '{"frictions":[]}\n'); + assert.equal(readText(path.join(workspaceB, "MEMORY.md")), "machine a memory\n"); + } +);