From 440009f64bcbf933b3d6c8c581e078b9802526da Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 9 Aug 2026 09:59:59 -0400 Subject: [PATCH 1/2] feat: vault ecosystem auto-setup on first activate (personal + public vault, mounts.toml) (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vault_setup.ts: ensureVaultEcosystem() — idempotent, never throws: ensureArmoniaDataDirs, ensurePersonalVault, ensurePublicVault (shallow clone vault-public, 10s cap, offline placeholder), ensureMountsToml (absent-only, personal rw first, public ro second) - extension.ts: call ensureVaultEcosystem() before first prepareOpencodeProject so mount stack is correct on boot; remove now-redundant ensureDefaultPersonalVault - trees.ts: update Armonia placeholder to reflect auto-provision - tools/bootstrap-armonia.sh: mirror same three vault steps as idempotent block (CLI/extension order agnostic), curl-able - tools/migrate-to-armonia.sh: three-bucket repos routing (packages/, demos/, flat), bucket-converging on re-run - skills/amico-vault/SKILL.md: vault-* naming in kinds table, new First-run lifecycle subsection for vault-public (kind=public, ro) --- .../extension/skills/amico-vault/SKILL.md | 24 +- packages/extension/src/extension.ts | 43 ++- .../extension/src/substrate/vault_setup.ts | 216 ++++++++++++++ packages/extension/src/trees.ts | 9 +- tools/bootstrap-armonia.sh | 265 ++++++++++++++++++ tools/migrate-to-armonia.sh | 237 ++++++++++++++++ 6 files changed, 761 insertions(+), 33 deletions(-) create mode 100755 tools/bootstrap-armonia.sh create mode 100755 tools/migrate-to-armonia.sh diff --git a/packages/extension/skills/amico-vault/SKILL.md b/packages/extension/skills/amico-vault/SKILL.md index 351fc02f..b11c2b7c 100644 --- a/packages/extension/skills/amico-vault/SKILL.md +++ b/packages/extension/skills/amico-vault/SKILL.md @@ -65,14 +65,24 @@ The vault is no longer a single directory. A user's **Armonia** is the set of va | Kind | Marker | Repo naming | Holds | Writable | |------|--------|-------------|-------|----------| -| **personal** | `kind = "personal"` | `armonia-` | Own research notes, hopper, solo specs/plans, session distillates, experiments-in-progress | single-writer (you) | -| **engagement** | `kind = "engagement"` | `armonia-` | Engagement-scoped notes; lab state (`lab.toml`, device/model-of-lab notes, calibration, per-lab catalog) once hardware deploys | engagement staff | -| **project** | `kind = "project"` | `armonia-` | Proprietary-package knowledge (your private package internals, hopper, insights) | per-person grant | -| **team** | `kind = "team"` | `armonia-` | Your team knowledge tier: hardware/control context, methods + patterns, strategy/specs/plans, experiments + insights, papers, people/orgs, central pulse catalog (git-lfs) | PR-gated promotion | -| **public** | `kind = "public"` | `/armonia` | Best-practice usage patterns for public packages, hazard notes, platform cards, recipes | world read-only | +| **personal** | `kind = "personal"` | `vault-` (e.g. `vault-aaron`) | Own research notes, hopper, solo specs/plans, session distillates, experiments-in-progress | single-writer (you) | +| **engagement** | `kind = "engagement"` | `vault-` | Engagement-scoped notes; lab state (`lab.toml`, device/model-of-lab notes, calibration, per-lab catalog) once hardware deploys | engagement staff | +| **project** | `kind = "project"` | `vault-` | Proprietary-package knowledge (your private package internals, hopper, insights) | per-person grant | +| **team** | `kind = "team"` | `vault-` (e.g. `vault-team` / `armonissima`) | Your team knowledge tier: hardware/control context, methods + patterns, strategy/specs/plans, experiments + insights, papers, people/orgs, central pulse catalog (git-lfs) | PR-gated promotion | +| **public** | `kind = "public"` | `harmoniqs/vault-public` | Best-practice usage patterns for public packages, hazard notes, platform cards, recipes | world read-only | **Read precedence: personal → engagement → project(s) → team → public.** Queries search the **union** of all mounts; on a path collision the higher-precedence mount wins (first hit). `mounts.toml` (in `~/.amico/`) overrides order and writability; absent, kind-order applies. A dir with no marker, a duplicate id, or a manifest `path` that doesn't exist is dropped from the mount set with a warning in the hook summary — never guessed at, never fatal. +### First-run lifecycle (auto-provision) + +A fresh Marketplace install gets a working vault ecosystem with zero commands: + +1. **Personal vault** — `ensureVaultEcosystem()` creates `~/armonia/data/vaults//` (`kind="personal"`, local `git init`, no remote) if no personal mount resolves. Offline-tolerant, never throws; activation continues unpersonalized on failure. +2. **Public vault** — shallow-clones `harmoniqs/vault-public` to `~/armonia/data/vaults/vault-public/` (`kind="public"`, `writable=false`, 10s timeout, anonymous https). On offline / no-git / timeout it creates a placeholder dir with a `kind="public"` marker so the mount stack still resolves. +3. **`mounts.toml`** — written *only if absent* (personal rw first, public ro second). Presence means user-managed — never overwritten. The same three steps are mirrored in `tools/bootstrap-armonia.sh` so CLI-first or extension-first order is safe (second run is a no-op). + +The canonical on-disk root is `~/armonia/` (`repos/` = versioned source, `data/` = managed state); `~/.amico/vaults` is a symlink into `~/armonia/data/vaults` for backward compat. + ### Write routing (Claude is the resolver pre-Amicode) Route every note-write by intent: @@ -81,7 +91,7 @@ Route every note-write by intent: |---|---|---| | spec / plan for **shared** work | **team** (your team vault) | PR flow | | lab state, calibration, device params, engagement notes | **engagement** vault | direct commit | -| proprietary-package knowledge, solver hopper items | **project** vault (e.g. `armonia-`) | direct commit | +| proprietary-package knowledge, solver hopper items | **project** vault (e.g. `vault-`) | direct commit | | personal research, sessions, scratch, solo specs | **personal** vault | direct commit (auto-synced) | | **ambiguous** | ask the user once → default personal | — | @@ -102,7 +112,7 @@ Never put the mechanism in a `team`/`public` note expecting a later scrub — au Crystallization to the company vault is double-gated: **gate 1** = author tags `visibility: team`; **gate 2** = a human merges the dream-promote PR. On promotion: -- The **copy** lands in the **team** vault carrying `promoted_from: armonia-` + `promoted_date: YYYY-MM-DD`. +- The **copy** lands in the **team** vault carrying `promoted_from: vault-` + `promoted_date: YYYY-MM-DD`. - The **original stays put** in its source vault and gains `promoted_to: "[[]]"` (a frontmatter-only stamp written back *only after* the PR merges). It is never re-proposed. - Move is wrong — copy preserves the source vault's local graph and the provenance backlink. `promoted_from`/`promoted_date`/`promoted_to` are the charter/12 provenance fields, carried over unchanged. diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 95760c3e..f412708f 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -41,7 +41,12 @@ import { runSetCloudKeyCommand } from "./cloud_key"; import { amicodeOpsDir } from "./substrate/vault_store"; import { stagePasqalConnector } from "./pasqal_assets"; import { needsProvision, pasqalVenvDir, provisionPasqalPython } from "./pasqal_python"; -import { createLocalPersonalVault, sanitizeVaultName, suggestVaultName } from "./substrate/vault_setup"; +import { + createLocalPersonalVault, + sanitizeVaultName, + suggestVaultName, + ensureVaultEcosystem, +} from "./substrate/vault_setup"; import { pinnedJuliaMinor, hasJuliaup, @@ -442,6 +447,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const parsed = parseLibraryRootSpecs(raw); return parsed.length ? parsed : undefined; }; + // Vault ecosystem — first-run auto-provision (personal + public + mounts.toml). + // Idempotent and never throws; runs before the first project prep so the + // mount stack is correct on boot. The named `amicode.setupVault` command + // remains for manual re-entry. + try { + const eco = ensureVaultEcosystem(); + if (eco.personal) opencodeChannel.appendLine(`[vault] auto-provisioned local personal vault: ${eco.personal.path} (git=${eco.personal.gitInit})`); + if (eco.publicCloned) opencodeChannel.appendLine(`[vault] public vault cloned: vault-public`); + else if (eco.publicPlaceholder) opencodeChannel.appendLine(`[vault] public vault placeholder (offline or no git)`); + if (eco.mountsWritten) opencodeChannel.appendLine(`[vault] mounts.toml written`); + } catch (e) { + opencodeChannel.appendLine(`[vault] ecosystem ensure failed: ${(e as Error).message}`); + } + const opencodeProject = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), // MODE-SELECTED vetted template: HP sessions get the Piccolissimo variant @@ -973,28 +992,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { void vscode.window.showInformationMessage(`Amicode: personal vault "${created.name}" created and active.`); }; ctx.subscriptions.push(vscode.commands.registerCommand("amicode.setupVault", () => void runVaultSetup(true))); - // Personal vault by default. The onboarding wizard (opencode-side) writes the - // profile but NOT a vault, and a genuine first-timer has none — so Amico would - // have nowhere to remember them (distiller disabled, session unpersonalized). - // Silently provision a LOCAL personal vault on first run when none resolves — - // no modal, like the Julia project. The `amicode.setupVault` command remains - // for naming / re-creating; the wizard finale offers attaching other vaults. - // Failure-tolerant: a creation error just leaves the session unpersonalized. - const ensureDefaultPersonalVault = async (): Promise => { - if (personalMount(resolveMountStack())) return; - let created; - try { - created = createLocalPersonalVault(defaultVaultsRoot(), suggestVaultName()); - } catch (e) { - opencodeChannel.appendLine(`[vault] default personal vault not created: ${(e as Error).message}`); - return; - } - opencodeChannel.appendLine( - `[vault] auto-provisioned local personal vault: ${created.path} (git=${created.gitInit})`, - ); - await respawnForVault(); - }; - void ensureDefaultPersonalVault(); // Julia setup (#8): amicode manages the Julia toolchain via juliaup — install // juliaup if absent, add the channel pinned to the Manifest's MINOR, and diff --git a/packages/extension/src/substrate/vault_setup.ts b/packages/extension/src/substrate/vault_setup.ts index f1093316..1d3a3cba 100644 --- a/packages/extension/src/substrate/vault_setup.ts +++ b/packages/extension/src/substrate/vault_setup.ts @@ -40,12 +40,33 @@ function safeUsername(): string { } } +export const PUBLIC_VAULT_DIR = "vault-public"; +export const PUBLIC_VAULT_REPO = "https://github.com/harmoniqs/vault-public.git"; +export const PUBLIC_VAULT_KIND = "public"; + +function defaultVaultsRoot(): string { + return path.join(os.homedir(), ".amico", "vaults"); +} +function defaultMountsTomlPath(): string { + return path.join(os.homedir(), ".amico", "mounts.toml"); +} +function armoniaDataRoot(): string { + return path.join(os.homedir(), "armonia", "data"); +} + export interface CreatedVault { path: string; name: string; gitInit: boolean; } +export interface VaultEcosystemResult { + personal?: CreatedVault; + publicCloned: boolean; + publicPlaceholder: boolean; + mountsWritten: boolean; +} + /** * Create a local personal vault. Refuses to clobber an existing directory. * `git init` is best-effort (the vault is fully functional without git). @@ -73,3 +94,198 @@ export function createLocalPersonalVault( } return { path: dir, name, gitInit }; } + +/** Ensure ~/armonia/data/{env,problems,runs,vaults} and ~/.amico symlinks. Never throws. */ +export function ensureArmoniaDataDirs(): void { + try { + const dataRoot = armoniaDataRoot(); + for (const sub of ["env", "problems", "runs", "vaults"]) { + try { + fs.mkdirSync(path.join(dataRoot, sub), { recursive: true }); + } catch {} + } + // Wire ~/.amico/ -> ~/armonia/data/ when safe. + const links: Array<[string, string]> = [ + ["julia", "env"], + ["problems", "problems"], + ["runs", "runs"], + ["vaults", "vaults"], + ]; + const amico = path.join(os.homedir(), ".amico"); + try { + fs.mkdirSync(amico, { recursive: true }); + } catch {} + for (const [srcName, targetName] of links) { + const src = path.join(amico, srcName); + const dest = path.join(dataRoot, targetName); + try { + if (fs.lstatSync(src).isSymbolicLink()) continue; + } catch {} + try { + if (fs.existsSync(src)) { + // Real dir with content → migration case, don't clobber. + const entries = fs.readdirSync(src); + if (entries.length > 0) continue; + fs.rmdirSync(src); + } + fs.symlinkSync(dest, src); + } catch {} + } + } catch {} +} + +/** Ensure the public vault (vault-public, kind=public, ro). Never throws. */ +export function ensurePublicVault(opts: { + vaultsRoot?: string; + repo?: string; + timeoutMs?: number; +} = {}): { cloned: boolean; placeholder: boolean } { + const vaultsRoot = opts.vaultsRoot ?? defaultVaultsRoot(); + const repo = opts.repo ?? PUBLIC_VAULT_REPO; + const timeoutMs = opts.timeoutMs ?? 10_000; + const dir = path.join(vaultsRoot, PUBLIC_VAULT_DIR); + const marker = path.join(dir, ".amico-vault.toml"); + + // Already present (any kind) → no-op. + try { + if (fs.existsSync(marker)) return { cloned: false, placeholder: false }; + } catch {} + if (fs.existsSync(dir)) { + // Dir exists without marker → seed marker as public. + try { + fs.writeFileSync(marker, `kind = "${PUBLIC_VAULT_KIND}"\nname = "${PUBLIC_VAULT_DIR}"\n`); + return { cloned: false, placeholder: true }; + } catch { + return { cloned: false, placeholder: false }; + } + } + + // Try shallow clone. + try { + execFileSync("git", ["clone", "--depth", "1", "--single-branch", repo, dir], { + stdio: "ignore", + timeout: timeoutMs, + }); + // Ensure marker is public (repo should already carry it). + try { + const text = fs.readFileSync(marker, "utf8"); + if (!text.includes(`kind = "${PUBLIC_VAULT_KIND}"`)) { + fs.writeFileSync(marker, `kind = "${PUBLIC_VAULT_KIND}"\nname = "${PUBLIC_VAULT_DIR}"\n`); + } + } catch { + try { + fs.writeFileSync(marker, `kind = "${PUBLIC_VAULT_KIND}"\nname = "${PUBLIC_VAULT_DIR}"\n`); + } catch {} + } + return { cloned: true, placeholder: false }; + } catch {} + + // Offline / no-git / timeout → placeholder. + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(marker, `kind = "${PUBLIC_VAULT_KIND}"\nname = "${PUBLIC_VAULT_DIR}"\n`); + try { + fs.writeFileSync(path.join(dir, "README.md"), "# vault-public (offline placeholder)\n\nCloned on next online activate.\n"); + } catch {} + return { cloned: false, placeholder: true }; + } catch { + return { cloned: false, placeholder: false }; + } +} + +/** Ensure ~/.amico/mounts.toml exists with personal + public precedence. Absent-only. */ +export function ensureMountsToml(opts: { + mountsTomlPath?: string; + vaultsRoot?: string; +} = {}): boolean { + const mountsTomlPath = opts.mountsTomlPath ?? defaultMountsTomlPath(); + const vaultsRoot = opts.vaultsRoot ?? defaultVaultsRoot(); + try { + if (fs.existsSync(mountsTomlPath)) return false; + } catch {} + // Resolve personal name from actual dirs after ensure steps. + let personalId: string | undefined; + try { + const entries = fs.readdirSync(vaultsRoot); + for (const base of entries) { + const marker = path.join(vaultsRoot, base, ".amico-vault.toml"); + try { + const text = fs.readFileSync(marker, "utf8"); + if (text.includes('kind = "personal"')) { + const m = text.match(/name\s*=\s*"([^"]+)"/); + personalId = m ? m[1] : base; + break; + } + } catch {} + } + } catch {} + + const lines: string[] = []; + if (personalId) { + lines.push("[[mount]]"); + lines.push(`id = "${personalId}"`); + lines.push(`kind = "personal"`); + lines.push(`writable = true`); + lines.push(""); + } + // Public entry — always emitted if vault-public dir exists (even placeholder). + const publicDir = path.join(vaultsRoot, PUBLIC_VAULT_DIR); + let hasPublic = false; + try { + hasPublic = fs.existsSync(path.join(publicDir, ".amico-vault.toml")); + } catch {} + if (hasPublic) { + lines.push("[[mount]]"); + lines.push(`id = "${PUBLIC_VAULT_DIR}"`); + lines.push(`kind = "${PUBLIC_VAULT_KIND}"`); + lines.push(`writable = false`); + lines.push(""); + } + if (lines.length === 0) return false; + try { + fs.mkdirSync(path.dirname(mountsTomlPath), { recursive: true }); + fs.writeFileSync(mountsTomlPath, lines.join("\n")); + return true; + } catch { + return false; + } +} + +/** Full first-run ecosystem: data dirs + personal + public + mounts.toml. Never throws. */ +export function ensureVaultEcosystem(opts: { + vaultsRoot?: string; + mountsTomlPath?: string; + publicRepo?: string; + hint?: string; +} = {}): VaultEcosystemResult { + const vaultsRoot = opts.vaultsRoot ?? defaultVaultsRoot(); + const mountsTomlPath = opts.mountsTomlPath ?? defaultMountsTomlPath(); + const result: VaultEcosystemResult = { publicCloned: false, publicPlaceholder: false, mountsWritten: false }; + + ensureArmoniaDataDirs(); + + // Personal — silent auto-provision if none resolves. + let hasPersonal = false; + try { + const entries = fs.readdirSync(vaultsRoot); + for (const base of entries) { + try { + const text = fs.readFileSync(path.join(vaultsRoot, base, ".amico-vault.toml"), "utf8"); + if (text.includes('kind = "personal"')) { hasPersonal = true; break; } + } catch {} + } + } catch {} + if (!hasPersonal) { + try { + const created = createLocalPersonalVault(vaultsRoot, suggestVaultName(opts.hint)); + result.personal = created; + } catch {} + } + + const pub = ensurePublicVault({ vaultsRoot, repo: opts.publicRepo }); + result.publicCloned = pub.cloned; + result.publicPlaceholder = pub.placeholder; + + result.mountsWritten = ensureMountsToml({ mountsTomlPath, vaultsRoot }); + return result; +} diff --git a/packages/extension/src/trees.ts b/packages/extension/src/trees.ts index 51576122..e7835585 100644 --- a/packages/extension/src/trees.ts +++ b/packages/extension/src/trees.ts @@ -107,9 +107,12 @@ export function registerTrees(ctx: vscode.ExtensionContext): { armonia: PlaceholderTree; } { const catalog = new SessionCatalogTree(ctx); - // amicode#204: the single Armonia panel. Its roots are the mounted Vaults; - // until ArmoniaService lands, a product empty state names what collects here. - const armonia = new PlaceholderTree("Your vaults collect here — run Amicode: Set up a vault"); + // amicode#204: single Armonia panel. Until ArmoniaService lands, a product + // empty state names the canonical location. First-run auto-provisions + // personal + vault-public (offline → placeholder) and writes mounts.toml. + const armonia = new PlaceholderTree( + "Your vaults live in ~/armonia/data/vaults — personal + vault-public after first run", + ); ctx.subscriptions.push( vscode.window.registerTreeDataProvider("amicode.catalog", catalog), diff --git a/tools/bootstrap-armonia.sh b/tools/bootstrap-armonia.sh new file mode 100755 index 00000000..ff106596 --- /dev/null +++ b/tools/bootstrap-armonia.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +# bootstrap-armonia.sh +# Fresh-machine setup for the canonical Amicode workspace layout. +# Any user — Harmoniqs dev or external contributor — gets the same tree: +# +# ~/armonia/repos/packages/ Julia libraries (Piccolo.jl, …) +# ~/armonia/repos/demos/ demo galleries (atoms-demo, …) +# ~/armonia/repos/ apps, forks, projects (amicode, …) +# ~/armonia/data/{env,problems,runs,vaults} +# +# Usage: +# bootstrap-armonia.sh [--minimal|--standard|--full] +# +# --minimal layout + data dirs only (extension users who never touch source) +# --standard + public packages and demos via plain git clone (default) +# --full + private Harmoniqs repos via `gh` (requires gh auth with access) +# +# Curl-able for users who have not cloned anything: +# bash <(curl -fsSL https://raw.githubusercontent.com/harmoniqs/amicode/main/tools/bootstrap-armonia.sh) +# +# Idempotent: existing clones are skipped (git pull --ff-only is attempted), +# existing symlinks are left alone. + +ARMONIA="${HOME}/armonia" +AMICO="${HOME}/.amico" +TIER="standard" + +for arg in "$@"; do + case "$arg" in + --minimal|--standard|--full) TIER="${arg#--}" ;; + -h|--help) + sed -n '2,22p' "$0"; exit 0 ;; + *) echo "unknown arg: $arg" >&2; exit 64 ;; + esac +done + +# Public Julia libraries (registered packages; plain git clone works). +PUBLIC_PACKAGES=( + Piccolo.jl + NamedTrajectories.jl + DirectTrajOpt.jl +) + +# Public demo galleries. +PUBLIC_DEMOS=( + atoms-demo + fluxonium-demo + ions +) + +# Private Harmoniqs repos (cloned with gh; requires org access). +PRIVATE_PACKAGES=( + Piccolissimo.jl +) +PRIVATE_APPS=( + amicode +) + +# =================================================================== +main() { + echo "==> bootstrap-armonia tier=${TIER}" + echo + + make_layout + wire_amico_links + ensure_vault_ecosystem + + case "$TIER" in + minimal) echo "tier=minimal — no repos cloned" ;; + standard) clone_public ;; + full) clone_public; clone_private ;; + esac + + echo + echo "==> Done." + echo " Layout: ~/armonia/{repos/{packages,demos,...}, data/{env,problems,runs,vaults}}" + echo " Open it: open ~/armonia/ (or add ~/armonia to your VS Code workspace)" +} + +# ------------------------------------------------------------------- +make_layout() { + echo "--- layout ---" + mkdir -p "${ARMONIA}/repos/packages" "${ARMONIA}/repos/demos" \ + "${ARMONIA}/data/env" "${ARMONIA}/data/problems" \ + "${ARMONIA}/data/runs" "${ARMONIA}/data/vaults" + echo " ~/armonia/{repos/{packages,demos}, data/{env,problems,runs,vaults}} ready" +} + +# ------------------------------------------------------------------- +# ~/.amico/ → ~/armonia/data/, created only when safe. +# If ~/.amico/ already exists as a REAL directory with content, that is +# the migration case — point the user at migrate-to-armonia.sh instead of +# clobbering it. +wire_amico_links() { + echo "--- ~/.amico links ---" + local pairs=("julia:env" "problems:problems" "runs:runs" "vaults:vaults") + for pair in "${pairs[@]}"; do + local name="${pair%%:*}" target="${pair##*:}" + local src="${AMICO}/${name}" dest="${ARMONIA}/data/${target}" + mkdir -p "$AMICO" + if [[ -L "$src" ]]; then + echo " (symlink) ~/.amico/${name}" + elif [[ -d "$src" && -n "$(ls -A "$src" 2>/dev/null)" ]]; then + echo " (real dir, not empty) ~/.amico/${name} — run tools/migrate-to-armonia.sh first" + elif [[ -d "$src" ]]; then + rmdir "$src" && ln -s "$dest" "$src" + echo " linked ~/.amico/${name} → data/${target}" + else + ln -s "$dest" "$src" + echo " linked ~/.amico/${name} → data/${target}" + fi + done +} + +# ------------------------------------------------------------------- +# Vault ecosystem — mirrors extension's ensureVaultEcosystem (idempotent, +# offline-tolerant, never throws). Either order (CLI first or extension first) +# is safe — second run is a no-op. +ensure_vault_ecosystem() { + echo "--- vaults ---" + local vaults_root="${AMICO}/vaults" + # Resolve symlink if present (~/armonia/data/vaults) + if [ -L "$vaults_root" ]; then vaults_root=$(readlink "$vaults_root"); fi + # Fallback if symlink broken or relative + if [[ "$vaults_root" != /* ]]; then vaults_root="${AMICO}/vaults"; fi + # Canonical: ~/armonia/data/vaults if symlink else ~/.amico/vaults + if [ -L "${AMICO}/vaults" ]; then + vaults_root=$(readlink "${AMICO}/vaults") + [[ "$vaults_root" != /* ]] && vaults_root="${AMICO}/vaults" + else + vaults_root="${AMICO}/vaults" + fi + # Ensure data/vaults exists (make_layout already did) + mkdir -p "$vaults_root" 2>/dev/null || true + + # Personal — create if none with kind=personal exists. + local has_personal=0 + for d in "$vaults_root"/*/.amico-vault.toml 2>/dev/null; do + [ -f "$d" ] && grep -q 'kind = "personal"' "$d" 2>/dev/null && has_personal=1 && break + done + if [ $has_personal -eq 0 ]; then + local raw="${USER:-personal}" + local name=$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[-.]+|[-.]+$//g') + [ -z "$name" ] && name="personal" + local dir="$vaults_root/$name" + if [ ! -e "$dir" ]; then + echo " create personal vault: $name" + mkdir -p "$dir" + printf 'kind = "personal"\nname = "%s"\n' "$name" > "$dir/.amico-vault.toml" + git -C "$dir" init -q 2>/dev/null || true + else + echo " (exists) personal vault: $name" + fi + else + echo " (exists) personal vault" + fi + + # Public — shallow clone vault-public if absent, else placeholder if offline. + local pub_dir="$vaults_root/vault-public" + local pub_marker="$pub_dir/.amico-vault.toml" + if [ -f "$pub_marker" ]; then + echo " (exists) public vault: vault-public" + elif [ -d "$pub_dir" ]; then + echo " (exists dir without marker) vault-public — seeding marker" + printf 'kind = "public"\nname = "vault-public"\n' > "$pub_marker" 2>/dev/null || true + else + echo " clone public vault: vault-public" + if git clone --depth 1 --single-branch https://github.com/harmoniqs/vault-public.git "$pub_dir" 2>/dev/null; then + # Ensure marker is public + if ! grep -q 'kind = "public"' "$pub_marker" 2>/dev/null; then + printf 'kind = "public"\nname = "vault-public"\n' > "$pub_marker" 2>/dev/null || true + fi + echo " cloned vault-public" + else + echo " (offline or no git) placeholder for vault-public" + mkdir -p "$pub_dir" + printf 'kind = "public"\nname = "vault-public"\n' > "$pub_marker" 2>/dev/null || true + printf '# vault-public (offline placeholder)\n\nCloned on next online run.\n' > "$pub_dir/README.md" 2>/dev/null || true + fi + fi + + # mounts.toml — absent-only. + local mounts_toml="${AMICO}/mounts.toml" + if [ -f "$mounts_toml" ]; then + echo " (exists) mounts.toml" + else + local personal_id="" + for d in "$vaults_root"/*/.amico-vault.toml 2>/dev/null; do + [ -f "$d" ] && grep -q 'kind = "personal"' "$d" 2>/dev/null && personal_id=$(grep -E '^name = ' "$d" 2>/dev/null | sed -E 's/.*"(.*)".*/\1/' | head -1) && [ -n "$personal_id" ] && break + done + # Fallback to dir basename if marker name parse failed + if [ -z "$personal_id" ]; then + for d in "$vaults_root"/*/; do + [ -f "$d/.amico-vault.toml" ] && grep -q 'kind = "personal"' "$d/.amico-vault.toml" 2>/dev/null && personal_id=$(basename "$d") && break + done + fi + { + if [ -n "$personal_id" ]; then + printf '[[mount]]\nid = "%s"\nkind = "personal"\nwritable = true\n\n' "$personal_id" + fi + if [ -f "$pub_marker" ]; then + printf '[[mount]]\nid = "vault-public"\nkind = "public"\nwritable = false\n\n' + fi + } > "$mounts_toml" 2>/dev/null && echo " wrote mounts.toml" || echo " (skip) mounts.toml not written" + fi +} + +# ------------------------------------------------------------------- +clone_public() { + echo "--- clone (public) ---" + for repo in "${PUBLIC_PACKAGES[@]}"; do + clone_or_update "https://github.com/harmoniqs/${repo}.git" "${ARMONIA}/repos/packages/${repo}" + done + for repo in "${PUBLIC_DEMOS[@]}"; do + clone_or_update "https://github.com/harmoniqs/${repo}.git" "${ARMONIA}/repos/demos/${repo}" + done +} + +# ------------------------------------------------------------------- +clone_private() { + echo "--- clone (private, via gh) ---" + if ! command -v gh >/dev/null 2>&1; then + echo " gh not installed — skipping private tier"; return 0 + fi + if ! gh auth status >/dev/null 2>&1; then + echo " gh not authenticated — skipping private tier (run: gh auth login)"; return 0 + fi + for repo in "${PRIVATE_PACKAGES[@]}"; do + gh_clone_or_update "harmoniqs/${repo}" "${ARMONIA}/repos/packages/${repo}" + done + for repo in "${PRIVATE_APPS[@]}"; do + gh_clone_or_update "harmoniqs/${repo}" "${ARMONIA}/repos/${repo}" + done +} + +# ------------------------------------------------------------------- +clone_or_update() { + local url="$1" dest="$2" + if [[ -d "${dest}/.git" ]]; then + echo " (exists) $(basename "$dest") — pulling" + git -C "$dest" pull --ff-only 2>/dev/null || echo " (pull skipped: not fast-forwardable)" + elif [[ -e "$dest" ]]; then + echo " (exists, not a git repo — left alone) $(basename "$dest")" + else + echo " clone $(basename "$dest")" + git clone "$url" "$dest" + fi +} + +gh_clone_or_update() { + local repo="$1" dest="$2" + if [[ -d "${dest}/.git" ]]; then + echo " (exists) $(basename "$dest") — pulling" + git -C "$dest" pull --ff-only 2>/dev/null || echo " (pull skipped: not fast-forwardable)" + elif [[ -e "$dest" ]]; then + echo " (exists, not a git repo — left alone) $(basename "$dest")" + else + echo " clone $repo" + gh repo clone "$repo" "$dest" + fi +} + +main diff --git a/tools/migrate-to-armonia.sh b/tools/migrate-to-armonia.sh new file mode 100755 index 00000000..029c5636 --- /dev/null +++ b/tools/migrate-to-armonia.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +# migrate-to-armonia.sh +# Idempotent migration: consolidate repos + Amico data into ~/armonia/. +# Safe to run multiple times — skips what is already in place, and converges +# the repos/ buckets on re-run (flat .jl packages → packages/, demo dirs → demos/). +# +# Canonical layout: +# ~/armonia/repos/packages/ Julia libraries (Piccolo.jl, …) +# ~/armonia/repos/demos/ demo galleries (atoms-demo, …) +# ~/armonia/repos/ apps, forks, research projects (amicode, passaggio, …) +# ~/armonia/data/{env,problems,runs,vaults} + +ARMONIA="${HOME}/armonia" +AMICO="${HOME}/.amico" + +# ---- discover source repos ---- +# Directories that might hold git checkouts to move. +REPO_SOURCES=( + "${HOME}/_dev/harmoniqs" + "${HOME}/harmoniqs" + "${HOME}/AmicodeProjects" + "${HOME}/_dev" +) + +# ---- discover data dirs to migrate ---- +# Each entry: "amico_dir armonia_target" +DATA_DIRS=( + "julia env" + "problems problems" + "runs runs" + "vaults vaults" +) + +# Known demo repo names → routed to repos/demos/. A source dir literally named +# "demos" is moved as-is (its contents are already grouped). +is_demo() { [[ "$1" == *demo* || "$1" == "atoms" || "$1" == "fluxonium" || "$1" == "ions" ]]; } + +# Julia packages route to repos/packages/ by the .jl suffix convention. +is_package() { [[ "$1" == *.jl || "$1" == *.jl-* ]]; } + +# =================================================================== +main() { + echo "==> migrate-to-armonia (idempotent)" + echo + + mkdir -p "${ARMONIA}/repos/packages" "${ARMONIA}/repos/demos" \ + "${ARMONIA}/data/env" "${ARMONIA}/data/problems" \ + "${ARMONIA}/data/runs" "${ARMONIA}/data/vaults" + + converge_buckets + migrate_repos + migrate_data + cleanup_empty_parents + + echo + echo "==> Done." + echo " Run: open ~/armonia/" +} + +# ------------------------------------------------------------------- +# Re-run convergence: repos/ that already migrated flat get bucketed. +converge_buckets() { + local moved=0 + for child in "${ARMONIA}/repos"/*/; do + [[ -d "$child" ]] || continue + local name; name=$(basename "$child") + case "$name" in packages|demos) continue ;; esac + if is_package "$name"; then + echo " bucket: repos/$name → repos/packages/$name" + mv "$child" "${ARMONIA}/repos/packages/$name" + moved=1 + elif is_demo "$name"; then + echo " bucket: repos/$name → repos/demos/$name" + mv "$child" "${ARMONIA}/repos/demos/$name" + moved=1 + fi + done + # A shared Julia dev env (Project.toml/Manifest.toml) stranded at repos/ + # belongs with the packages it references. + for f in Project.toml Manifest.toml; do + if [[ -f "${ARMONIA}/repos/$f" && ! -f "${ARMONIA}/repos/packages/$f" ]]; then + echo " bucket: repos/$f → repos/packages/$f" + mv "${ARMONIA}/repos/$f" "${ARMONIA}/repos/packages/$f" + fi + done + [[ $moved -eq 1 ]] && echo + return 0 +} + +# ------------------------------------------------------------------- +migrate_repos() { + echo "--- repos ---" + + for src_dir in "${REPO_SOURCES[@]}"; do + if [[ ! -d "$src_dir" ]]; then + echo " (skip) not found: $src_dir" + continue + fi + local src_children + src_children=$(find "$src_dir" -mindepth 1 -maxdepth 1 ! -name 'node_modules' 2>/dev/null || true) + if [[ -z "$src_children" ]]; then + echo " (skip) empty: $src_dir" + continue + fi + echo " source: $src_dir" + while IFS= read -r child; do + [[ -z "$child" ]] && continue + local name + name=$(basename "$child") + + # Shell scripts and loose files stay; only directories move. A root-level + # Project.toml/Manifest.toml accompanies the packages. + if [[ ! -d "$child" ]]; then + case "$name" in + Project.toml|Manifest.toml) + if [[ ! -f "${ARMONIA}/repos/packages/$name" ]]; then + echo " mv $name → packages/" + mv "$child" "${ARMONIA}/repos/packages/$name" + fi + ;; + *) echo " (skip file) $name" ;; + esac + continue + fi + + # Route to the right bucket. + local bucket="${ARMONIA}/repos" + if is_package "$name"; then + bucket="${ARMONIA}/repos/packages" + elif is_demo "$name" || [[ "$name" == "demos" ]]; then + bucket="${ARMONIA}/repos/demos" + fi + # A "demos" source dir lands AS repos/demos (contents grouped inside); + # merging into it rather than nesting demos/demos. + local dest + if [[ "$name" == "demos" ]]; then + dest="$bucket" + else + dest="${bucket}/${name}" + fi + + if [[ -e "$dest" && "$name" != "demos" ]]; then + echo " (exists) $name" + continue + fi + if [[ "$name" == "demos" && -d "$dest" ]]; then + # merge contents into the existing demos bucket + local demo_children + demo_children=$(find "$child" -mindepth 1 -maxdepth 1 2>/dev/null || true) + while IFS= read -r d; do + [[ -z "$d" ]] && continue + local dname; dname=$(basename "$d") + if [[ -e "${dest}/${dname}" ]]; then + echo " (exists) demos/$dname" + else + echo " mv demos/$dname" + mv "$d" "${dest}/${dname}" + fi + done <<< "$demo_children" + continue + fi + + echo " mv $name → ${bucket#"$ARMONIA"/}" + mv "$child" "$dest" + done <<< "$src_children" + done +} + +# ------------------------------------------------------------------- +migrate_data() { + echo "--- data ---" + + for entry in "${DATA_DIRS[@]}"; do + read -r amico_name armonia_name <<< "$entry" + local src="${AMICO}/${amico_name}" + local dest="${ARMONIA}/data/${armonia_name}" + + # already a symlink → done + if [[ -L "$src" ]]; then + echo " (symlink) ~/.amico/${amico_name}" + continue + fi + + # dest already populated → assume already migrated + if [[ -d "$dest" && -n "$(ls -A "$dest" 2>/dev/null)" ]]; then + # source still a real dir → just symlink it + if [[ -d "$src" && ! -L "$src" ]]; then + echo " (dest exists) ~/.amico/${amico_name} → symlink" + rm -rf "$src" + ln -s "$dest" "$src" + else + echo " (ok) ~/.amico/${amico_name}" + fi + continue + fi + + # source is a real dir, dest does not exist → move + symlink + if [[ -d "$src" && ! -L "$src" ]]; then + echo " mv ~/.amico/${amico_name} → data/${armonia_name}" + mv "$src" "$dest" + ln -s "$dest" "$src" + else + echo " (skip) ~/.amico/${amico_name} does not exist" + fi + done +} + +# ------------------------------------------------------------------- +cleanup_empty_parents() { + echo "--- cleanup ---" + for src_dir in "${REPO_SOURCES[@]}"; do + # never remove HOME or root-level dirs + case "$src_dir" in + "$HOME"|"$HOME/Desktop"|"$HOME/Documents"|"$HOME/Downloads") continue ;; + esac + if [[ -d "$src_dir" ]]; then + local remaining + remaining=$(find "$src_dir" -mindepth 1 -maxdepth 1 2>/dev/null || true) + if [[ -z "$remaining" ]]; then + echo " rmdir $src_dir" + rmdir "$src_dir" + # try to remove the parent if it is now empty + local parent + parent=$(dirname "$src_dir") + local parent_remaining + parent_remaining=$(find "$parent" -mindepth 1 -maxdepth 1 2>/dev/null || true) + if [[ -z "$parent_remaining" && "$parent" != "$HOME" ]]; then + rmdir "$parent" 2>/dev/null || true + fi + fi + fi + done +} + +main From 8c149b411068860e47d397a62d6f3d00ee9c8e5b Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 9 Aug 2026 10:39:55 -0400 Subject: [PATCH 2/2] feat(sync): opt-in workspace sync (extension-host, WIP-aware) Single toggle amicode.sync.enabled (off by default, first-run nudge). Extension-host missed-nightly check (>20h) + Amicode: Sync now command. Generic scope: any git repo under workspace folders. WIP-aware: clean WIP auto-rebases onto freshly fast-forwarded main, dirty skips rebase and only updates main ref, conflicts abort cleanly. Subtle UX: Output channel + notifications only on attention-needed. Pure helpers are tested with injected git runner. Closes #314 --- packages/extension/package.json | 31 ++- packages/extension/src/extension.ts | 97 +++++++ packages/extension/src/substrate/sync.ts | 335 +++++++++++++++++++++++ packages/extension/test/sync.test.ts | 227 +++++++++++++++ 4 files changed, 679 insertions(+), 11 deletions(-) create mode 100644 packages/extension/src/substrate/sync.ts create mode 100644 packages/extension/test/sync.test.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index 97a1642e..ae4b0a82 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -134,10 +134,14 @@ "command": "amicode.openRunDir", "title": "Amicode: Open current run directory" }, - { - "command": "amicode.distillNow", - "title": "Amicode: Distill now (update my memory)" - }, + { + "command": "amicode.distillNow", + "title": "Amicode: Distill now (update my memory)" + }, + { + "command": "amicode.sync.now", + "title": "Amicode: Sync now (update workspace repos)" + }, { "command": "amicode.catalog.remove", "title": "Remove from Catalog" @@ -297,13 +301,18 @@ "default": false, "description": "Capture agent runs to Harmoniqs' internal run corpus (OpenTelemetry). Runs include your prompts and tool output, which may contain source code. Off by default; enable explicitly after confirming the configured ingest endpoint is reachable from your network. Nothing is transmitted until you answer the first-run consent prompt, an endpoint is configured (amicode.telemetry.endpoint), and your Amico cloud connection is set up (\"Amico: Connect Cloud\"). Capture authenticates with your per-user cloud token." }, - "amicode.telemetry.endpoint": { - "type": "string", - "default": "https://bld42qbgsn7gu6y44v4kd6a32e0hprmy.lambda-url.us-east-1.on.aws", - "description": "Base URL of the run-corpus ingest endpoint (no trailing slash; opencode appends /v1/traces and /v1/logs). Defaults to the PRODUCTION corpus. Empty = capture stays dormant even with consent given. Auth uses your per-user Amico cloud token (~/.amico/cloud.json, set up via \"Amico: Connect Cloud\") — never a separate ingest key. The token MUST be minted in the same AWS account as this endpoint: each account's credentials table is independent, so a token from the other account is rejected on every batch (401) while capture still looks enabled. This default is therefore account-coupled with DEFAULT_CLOUD_URL in cloud_key.ts — change both together or neither." - } - } - }, + "amicode.telemetry.endpoint": { + "type": "string", + "default": "https://bld42qbgsn7gu6y44v4kd6a32e0hprmy.lambda-url.us-east-1.on.aws", + "description": "Base URL of the run-corpus ingest endpoint (no trailing slash; opencode appends /v1/traces and /v1/logs). Defaults to the PRODUCTION corpus. Empty = capture stays dormant even with consent given. Auth uses your per-user Amico cloud token (~/.amico/cloud.json, set up via \"Amico: Connect Cloud\") — never a separate ingest key. The token MUST be minted in the same AWS account as this endpoint: each account's credentials table is independent, so a token from the other account is rejected on every batch (401) while capture still looks enabled. This default is therefore account-coupled with DEFAULT_CLOUD_URL in cloud_key.ts — change both together or neither." + }, + "amicode.sync.enabled": { + "type": "boolean", + "default": false, + "description": "Keep workspace git repos in sync (opt-in). When enabled, Amicode checks on startup if it missed a nightly sync (>20h) and, if so, fast-forwards clean repos and keeps WIP current with main. Dirty work is never touched. Also offered as 'Amicode: Sync now' in the Command Palette. Off by default — enable in Settings or via the first-run prompt." + } + } + }, "menus": { "view/title": [ { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index f412708f..a1be6f8d 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -63,6 +63,14 @@ import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLega import { registerAmicodeTerminal } from "./terminal"; import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; +import { + findWorkspaceRepos, + syncOneRepo, + isSyncDue, + SYNC_INTERVAL_MS, + LAST_SYNC_KEY, + SYNC_DISMISSED_KEY, +} from "./substrate/sync"; import { registerBugReport, unregisterBugReport, @@ -1077,6 +1085,95 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { void runJuliaSetup(false); } + // ── Workspace sync (opt-in, extension-host) ────────────────────────── + // Single toggle amicode.sync.enabled (off by default). First-run nudge, + // missed-nightly check on activation (if >20h since last sync, run within + // 2 min), and manual Amicode: Sync now. Keeps any git repo in the open + // workspace current: clean WIP auto-rebases onto freshly fast-forwarded main, + // dirty skips rebase, conflicts abort cleanly. Output: channel log + notifications + // only on attention-needed (failed/conflicted). Julia env: check+nudge only (stub). + const syncChannel = vscode.window.createOutputChannel("Amicode — sync"); + ctx.subscriptions.push(syncChannel); + + const isSyncEnabled = (): boolean => + vscode.workspace.getConfiguration("amicode").get("sync.enabled", false); + + const runWorkspaceSync = async (source: "startup" | "manual"): Promise => { + const folders = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + const repos = findWorkspaceRepos(folders); + if (repos.length === 0) { + syncChannel.appendLine(`[sync:${source}] no git repos in workspace — nothing to do`); + return; + } + syncChannel.appendLine(`[sync:${source}] ${repos.length} repo(s): ${repos.map((r) => path.basename(r)).join(", ")}`); + let ok = 0, + skipped = 0, + failed = 0; + const failures: string[] = []; + for (const repoPath of repos) { + const res = syncOneRepo(repoPath); + syncChannel.appendLine(`[sync] ${res.repo} (${res.branch}) — ${res.status}: ${res.detail}`); + if (res.status === "ok") ok++; + else if (res.status === "skipped") skipped++; + else { + failed++; + failures.push(`${res.repo} (${res.branch}): ${res.detail}`); + } + } + syncChannel.appendLine(`[sync:${source}] done — ${ok} ok, ${skipped} skipped, ${failed} failed`); + await ctx.globalState.update(LAST_SYNC_KEY, new Date().toISOString()); + // Subtle UX: silent success, noisy failure (grilled). + if (failed > 0) { + const detail = failures.slice(0, 3).join("; ") + (failures.length > 3 ? ` (+${failures.length - 3} more)` : ""); + const pick = await vscode.window.showWarningMessage( + `Amicode sync: ${failed} repo(s) need attention — ${detail}`, + "Show log", + ); + if (pick === "Show log") syncChannel.show(); + } + }; + + const maybePromptSyncOptIn = async (): Promise => { + if (isSyncEnabled()) return; + if (ctx.globalState.get(SYNC_DISMISSED_KEY) === true) return; + // Only prompt if there's actually a workspace with git repos to sync. + const folders = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (findWorkspaceRepos(folders).length === 0) return; + const choice = await vscode.window.showInformationMessage( + "Keep your workspace in sync? Amicode can keep your git repos current overnight — fast-forwarding clean branches and keeping WIP up to date with main. Off by default, change anytime in Settings.", + "Enable sync", + "Not now", + "Don't ask again", + ); + if (choice === "Enable sync") { + await vscode.workspace.getConfiguration("amicode").update("sync.enabled", true, vscode.ConfigurationTarget.Global); + void vscode.window.showInformationMessage("Amicode sync enabled — will run overnight and keep WIP current with main."); + // Run once now so the user sees it work. + void runWorkspaceSync("manual"); + } else if (choice === "Don't ask again") { + await ctx.globalState.update(SYNC_DISMISSED_KEY, true); + } + }; + + // Register manual command. + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.sync.now", () => void runWorkspaceSync("manual"))); + + // First-run nudge (fire-and-forget, like vault/julia prompts). + void maybePromptSyncOptIn(); + + // Missed-nightly check: if enabled and >20h since last sync, run within 2 min. + if (isSyncEnabled()) { + const last = ctx.globalState.get(LAST_SYNC_KEY); + if (isSyncDue(last)) { + const delayMs = 30_000 + Math.floor(Math.random() * 90_000); // 30-120s jitter + syncChannel.appendLine(`[sync:startup] last sync ${last ?? "never"} — scheduling run in ${Math.round(delayMs / 1000)}s`); + const timer = setTimeout(() => void runWorkspaceSync("startup"), delayMs); + ctx.subscriptions.push({ dispose: () => clearTimeout(timer) }); + } else { + syncChannel.appendLine(`[sync:startup] last sync ${last} — not due (interval ${Math.round(SYNC_INTERVAL_MS / 3600000)}h)`); + } + } + // Healthcheck (the real `amicode.healthcheck`): verify the managed Julia // toolchain (Piccolo loads), the opencode server, and LLM creds. This is what // the Julia-setup notification points users at to confirm setup worked. diff --git a/packages/extension/src/substrate/sync.ts b/packages/extension/src/substrate/sync.ts new file mode 100644 index 00000000..21dc3d28 --- /dev/null +++ b/packages/extension/src/substrate/sync.ts @@ -0,0 +1,335 @@ +// Workspace sync — opt-in, extension-host, user-focused. +// Keeps any git repo in the open workspace current with its remote. +// Pure helpers are unit-tested; VS Code wiring (prompt + channel) is thin. +// +// Design (grilled 2026-08-09, issue #314): +// — single toggle amicode.sync.enabled (off by default, onboarding nudge) +// — generic scope: any git repo found under workspace folders +// — clean WIP auto-rebases onto freshly fast-forwarded main; dirty skips rebase +// — conflicts abort cleanly (branch untouched); notifications only on attention-needed +// — missed-nightly: if >20h since lastSyncAt, run within 2 min of activation +// — manual: Amicode: Sync now (Command Palette) +// — Julia env: check + nudge only (no auto-mutate) — stubbed for v1, wired later +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; + +// ── constants ────────────────────────────────────────────────────────────── +export const SYNC_ENABLED_KEY = "amicode.sync.enabled"; // VS Code setting +export const LAST_SYNC_KEY = "amicode.sync.lastSyncAt"; // globalState ISO string +export const SYNC_DISMISSED_KEY = "amicode.sync.dismissed"; // globalState boolean +export const SYNC_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h — "nightly" with startup grace +export const SYNC_DEBOUNCE_MS = 2 * 60 * 1000; // run within 2 min of activation if due + +// ── injectable runner (healthcheck / julia_setup pattern) ──────────────── +export type GitRunner = (args: string[], opts: { cwd: string }) => string; +export const defaultGitRunner: GitRunner = (args, opts) => + execFileSync("git", args, { encoding: "utf8", cwd: opts.cwd, timeout: 30_000 }).trim(); + +// ── pure helpers ─────────────────────────────────────────────────────────── + +/** Is `dir` a git repo (has .git)? Never throws. */ +export function isGitRepo(dir: string): boolean { + try { + return fs.existsSync(path.join(dir, ".git")); + } catch { + return false; + } +} + +/** Find git repos under workspace folders: + * — if the folder itself is a repo, return it + * — otherwise scan immediate children for repos + * Never throws; skips unreadable entries. */ +export function findWorkspaceRepos(workspaceFolders: string[]): string[] { + const out: string[] = []; + for (const root of workspaceFolders) { + if (isGitRepo(root)) { + out.push(root); + continue; + } + let entries: string[] = []; + try { + entries = fs.readdirSync(root); + } catch { + continue; + } + for (const name of entries) { + // skip hidden + common non-repo dirs + if (name.startsWith(".")) continue; + if (name === "node_modules" || name === "__pycache__" || name === "target") continue; + const full = path.join(root, name); + try { + if (fs.statSync(full).isDirectory() && isGitRepo(full)) out.push(full); + } catch { + /* unreadable — skip */ + } + } + } + return out; +} + +/** Current branch name, or null if detached / error. */ +export function currentBranch(repoPath: string, run: GitRunner = defaultGitRunner): string | null { + try { + const out = run(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath }); + if (!out || out === "HEAD") return null; + return out; + } catch { + return null; + } +} + +/** True if `git status --porcelain` has any output. */ +export function isDirty(repoPath: string, run: GitRunner = defaultGitRunner): boolean { + try { + const out = run(["status", "--porcelain"], { cwd: repoPath }); + return out.trim().length > 0; + } catch { + return false; + } +} + +/** Does origin/ exist? */ +export function hasOriginBranch( + repoPath: string, + branch: string, + run: GitRunner = defaultGitRunner, +): boolean { + try { + run(["rev-parse", "--verify", `origin/${branch}`], { cwd: repoPath }); + return true; + } catch { + return false; + } +} + +/** Fetch origin quietly; true on success. */ +export function fetchOrigin(repoPath: string, run: GitRunner = defaultGitRunner): boolean { + try { + run(["fetch", "origin", "--quiet"], { cwd: repoPath }); + return true; + } catch { + return false; + } +} + +// ── per-repo sync (the WIP-aware logic) ─────────────────────────────────── + +export type RepoSyncResult = + | { repo: string; branch: string; status: "ok"; detail: string } + | { repo: string; branch: string; status: "skipped"; detail: string } + | { repo: string; branch: string; status: "failed"; detail: string }; + +/** Sync one repo. Never throws; always returns a result. */ +export function syncOneRepo( + repoPath: string, + run: GitRunner = defaultGitRunner, +): RepoSyncResult { + const repo = path.basename(repoPath); + const branch = currentBranch(repoPath, run); + if (!branch) return { repo, branch: "detached", status: "skipped", detail: "detached HEAD" }; + + const dirty = isDirty(repoPath, run); + + // Dirty on main/master: skip entirely. + if (dirty && (branch === "main" || branch === "master")) { + return { repo, branch, status: "skipped", detail: "dirty on main" }; + } + + // Dirty on feature branch: fetch + force-update local main ref, skip rebase. + if (dirty) { + if (!fetchOrigin(repoPath, run)) { + return { repo, branch, status: "failed", detail: "fetch failed" }; + } + // Find which mainline to update (main preferred, then master). + let mainBranch: string | null = null; + if (hasOriginBranch(repoPath, "main", run)) mainBranch = "main"; + else if (hasOriginBranch(repoPath, "master", run)) mainBranch = "master"; + if (!mainBranch) { + return { repo, branch, status: "skipped", detail: "dirty, no origin/main or origin/master" }; + } + let hasLocalMain = false; + try { + run(["rev-parse", "--verify", mainBranch], { cwd: repoPath }); + hasLocalMain = true; + } catch { + hasLocalMain = false; + } + if (hasLocalMain) { + // Only fast-forward if origin is ahead (ancestor check). + let isAncestor = false; + try { + run(["merge-base", "--is-ancestor", mainBranch, `origin/${mainBranch}`], { cwd: repoPath }); + isAncestor = true; + } catch { + isAncestor = false; + } + if (!isAncestor) { + return { repo, branch, status: "skipped", detail: "dirty, main diverged" }; + } + let behind = 0; + try { + const out = run(["rev-list", "--count", `${mainBranch}..origin/${mainBranch}`], { cwd: repoPath }); + behind = parseInt(out, 10) || 0; + } catch { + behind = 0; + } + if (behind > 0) { + try { + run(["branch", "-f", mainBranch, `origin/${mainBranch}`], { cwd: repoPath }); + return { repo, branch, status: "skipped", detail: `dirty, main ff'd ${behind}` }; + } catch { + return { repo, branch, status: "failed", detail: "dirty, main update failed" }; + } + } + return { repo, branch, status: "skipped", detail: "dirty, main up to date" }; + } else { + try { + run(["branch", mainBranch, `origin/${mainBranch}`], { cwd: repoPath }); + return { repo, branch, status: "skipped", detail: `dirty, main created at origin/${mainBranch}` }; + } catch { + return { repo, branch, status: "failed", detail: "dirty, main create failed" }; + } + } + } + + // Clean repo: fetch + ff or rebase. + if (!fetchOrigin(repoPath, run)) { + return { repo, branch, status: "failed", detail: "fetch failed" }; + } + + // Main/master: fast-forward only. + if (branch === "main" || branch === "master") { + if (!hasOriginBranch(repoPath, branch, run)) { + return { repo, branch, status: "skipped", detail: `no origin/${branch}` }; + } + let isAncestor = false; + try { + run(["merge-base", "--is-ancestor", "HEAD", `origin/${branch}`], { cwd: repoPath }); + isAncestor = true; + } catch { + isAncestor = false; + } + if (!isAncestor) { + return { repo, branch, status: "skipped", detail: "diverged — needs human" }; + } + try { + run(["merge", "--ff-only", `origin/${branch}`], { cwd: repoPath }); + return { repo, branch, status: "ok", detail: "fast-forwarded" }; + } catch { + return { repo, branch, status: "skipped", detail: "ff-only failed" }; + } + } + + // Feature branch, clean: rebase onto origin/ if behind, then keep main fresh + rebase WIP onto main if needed. + const hasTracked = hasOriginBranch(repoPath, branch, run); + if (hasTracked) { + let behind = 0; + try { + const out = run(["rev-list", "--count", `HEAD..origin/${branch}`], { cwd: repoPath }); + behind = parseInt(out, 10) || 0; + } catch { + behind = 0; + } + if (behind > 0) { + try { + run(["rebase", `origin/${branch}`], { cwd: repoPath }); + // Fall through to main sync below — rebase succeeded. + } catch { + try { + run(["rebase", "--abort"], { cwd: repoPath }); + } catch { + /* ignore */ + } + return { repo, branch, status: "failed", detail: "rebase conflict (tracked branch)" }; + } + } + } + + // Keep local main fresh (even for clean feature branches) and auto-rebase WIP onto main if clean. + let mainBranch: string | null = null; + if (hasOriginBranch(repoPath, "main", run)) mainBranch = "main"; + else if (hasOriginBranch(repoPath, "master", run)) mainBranch = "master"; + + if (mainBranch) { + // Update local main ref if needed (fast-forward check). + let hasLocalMain = false; + try { + run(["rev-parse", "--verify", mainBranch], { cwd: repoPath }); + hasLocalMain = true; + } catch { + hasLocalMain = false; + } + if (hasLocalMain) { + let behindMain = 0; + try { + const out = run(["rev-list", "--count", `${mainBranch}..origin/${mainBranch}`], { cwd: repoPath }); + behindMain = parseInt(out, 10) || 0; + } catch { + behindMain = 0; + } + if (behindMain > 0) { + let isAncestor = false; + try { + run(["merge-base", "--is-ancestor", mainBranch, `origin/${mainBranch}`], { cwd: repoPath }); + isAncestor = true; + } catch { + isAncestor = false; + } + if (isAncestor) { + try { + run(["branch", "-f", mainBranch, `origin/${mainBranch}`], { cwd: repoPath }); + } catch { + // non-fatal — main update failed but feature rebase still attempted + } + // Clean WIP: auto-rebase feature branch onto updated main. + // Only if the feature branch has commits ahead of main (i.e., real WIP). + let aheadOfMain = 0; + try { + const out = run(["rev-list", "--count", `${mainBranch}..${branch}`], { cwd: repoPath }); + aheadOfMain = parseInt(out, 10) || 0; + } catch { + aheadOfMain = 0; + } + if (aheadOfMain > 0) { + try { + run(["rebase", mainBranch], { cwd: repoPath }); + return { repo, branch, status: "ok", detail: `main ff'd ${behindMain}, WIP rebased onto ${mainBranch}` }; + } catch { + try { + run(["rebase", "--abort"], { cwd: repoPath }); + } catch { + /* ignore */ + } + return { repo, branch, status: "failed", detail: `main ff'd ${behindMain}, WIP rebase conflict` }; + } + } + return { repo, branch, status: "ok", detail: `main ff'd ${behindMain}` }; + } + } + } else { + // No local main — create it, then consider WIP rebase (rare). + try { + run(["branch", mainBranch, `origin/${mainBranch}`], { cwd: repoPath }); + } catch { + /* ignore */ + } + } + } + + // No main update needed; report tracked rebase or up-to-date. + if (hasTracked) { + return { repo, branch, status: "ok", detail: "up to date" }; + } + return { repo, branch, status: "skipped", detail: `no origin/${branch}` }; +} + +// ── timing helpers (pure) ────────────────────────────────────────────────── + +export function isSyncDue(lastSyncAt: string | undefined, nowMs: number = Date.now()): boolean { + if (!lastSyncAt) return true; // never synced — due + const last = Date.parse(lastSyncAt); + if (Number.isNaN(last)) return true; + return nowMs - last > SYNC_INTERVAL_MS; +} diff --git a/packages/extension/test/sync.test.ts b/packages/extension/test/sync.test.ts new file mode 100644 index 00000000..af2f1246 --- /dev/null +++ b/packages/extension/test/sync.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + findWorkspaceRepos, + isSyncDue, + syncOneRepo, + isGitRepo, + SYNC_INTERVAL_MS, + type GitRunner, +} from "../src/substrate/sync"; + +// ── isSyncDue ────────────────────────────────────────────────────────────── +describe("isSyncDue", () => { + it("due when never synced", () => { + expect(isSyncDue(undefined)).toBe(true); + expect(isSyncDue("")).toBe(true); + expect(isSyncDue("not-a-date")).toBe(true); + }); + it("not due within interval", () => { + const now = Date.now(); + const recent = new Date(now - 60_000).toISOString(); + expect(isSyncDue(recent, now)).toBe(false); + }); + it("due after interval", () => { + const now = Date.now(); + const old = new Date(now - SYNC_INTERVAL_MS - 1000).toISOString(); + expect(isSyncDue(old, now)).toBe(true); + }); + it("due exactly at interval boundary", () => { + const now = Date.now(); + const at = new Date(now - SYNC_INTERVAL_MS - 1).toISOString(); + expect(isSyncDue(at, now)).toBe(true); + }); +}); + +// ── findWorkspaceRepos ──────────────────────────────────────────────────── +describe("findWorkspaceRepos", () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-sync-test-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("finds direct repo folder", () => { + fs.mkdirSync(path.join(tmp, ".git")); + expect(findWorkspaceRepos([tmp])).toEqual([tmp]); + }); + it("finds child repos when root is not a repo", () => { + const a = path.join(tmp, "proj-a"); + const b = path.join(tmp, "proj-b"); + fs.mkdirSync(a); + fs.mkdirSync(path.join(a, ".git")); + fs.mkdirSync(b); + // no .git in b + const repos = findWorkspaceRepos([tmp]); + expect(repos).toEqual([a]); + }); + it("skips hidden and node_modules", () => { + const hidden = path.join(tmp, ".hidden"); + const nm = path.join(tmp, "node_modules"); + fs.mkdirSync(hidden); + fs.mkdirSync(path.join(hidden, ".git")); + fs.mkdirSync(nm); + fs.mkdirSync(path.join(nm, ".git")); + expect(findWorkspaceRepos([tmp])).toEqual([]); + }); + it("handles multiple roots", () => { + const r1 = path.join(tmp, "r1"); + const r2 = path.join(tmp, "r2"); + fs.mkdirSync(r1); + fs.mkdirSync(path.join(r1, ".git")); + fs.mkdirSync(r2); + fs.mkdirSync(path.join(r2, ".git")); + expect(findWorkspaceRepos([r1, r2]).sort()).toEqual([r1, r2].sort()); + }); + it("empty roots returns empty", () => { + expect(findWorkspaceRepos([])).toEqual([]); + }); +}); + +// ── syncOneRepo with mocked git ────────────────────────────────────────── +function makeRunner(map: Record): GitRunner { + return (args, _opts) => { + const key = args.join(" "); + const v = map[key]; + if (v instanceof Error) throw v; + if (v !== undefined) return v; + // default: echo empty (success) + return ""; + }; +} + +describe("syncOneRepo", () => { + it("dirty on main → skipped", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "main"; + if (k === "status --porcelain") return " M foo.ts\n"; + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("skipped"); + expect(r.detail).toMatch(/dirty on main/); + }); + + it("dirty on feature branch → updates main ref", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "my-feature"; + if (k === "status --porcelain") return " M foo.ts\n"; + if (k === "fetch origin --quiet") return ""; + if (k === "rev-parse --verify origin/main") return "abc"; + if (k === "rev-parse --verify main") return "abc"; + if (k === "merge-base --is-ancestor main origin/main") return ""; + if (k === "rev-list --count main..origin/main") return "3"; + if (k === "branch -f main origin/main") return ""; + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake-proj", run); + expect(r.status).toBe("skipped"); + expect(r.detail).toMatch(/main ff'd 3/); + }); + + it("clean on main → fast-forwards", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "main"; + if (k === "status --porcelain") return ""; + if (k === "fetch origin --quiet") return ""; + if (k === "rev-parse --verify origin/main") return "abc"; + if (k === "merge-base --is-ancestor HEAD origin/main") return ""; + if (k === "merge --ff-only origin/main") return ""; + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("ok"); + expect(r.detail).toMatch(/fast-forward/); + }); + + it("clean main diverged → skipped", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "main"; + if (k === "status --porcelain") return ""; + if (k === "fetch origin --quiet") return ""; + if (k === "rev-parse --verify origin/main") return "abc"; + if (k === "merge-base --is-ancestor HEAD origin/main") throw new Error("not ancestor"); + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("skipped"); + expect(r.detail).toMatch(/diverged/); + }); + + it("fetch failure → failed", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "main"; + if (k === "status --porcelain") return ""; + if (k === "fetch origin --quiet") throw new Error("network down"); + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("failed"); + expect(r.detail).toMatch(/fetch failed/); + }); + + it("detached HEAD → skipped", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "HEAD"; + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("skipped"); + expect(r.detail).toMatch(/detached/); + }); + + it("clean feature branch rebase conflict → failed and aborted", () => { + let aborted = false; + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "feat"; + if (k === "status --porcelain") return ""; + if (k === "fetch origin --quiet") return ""; + if (k === "rev-parse --verify origin/feat") return "abc"; + if (k === "rev-list --count HEAD..origin/feat") return "2"; + if (k === "rebase origin/feat") throw new Error("conflict"); + if (k === "rebase --abort") { + aborted = true; + return ""; + } + if (k === "rev-parse --verify origin/main") throw new Error("no main"); + if (k === "rev-parse --verify origin/master") throw new Error("no master"); + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("failed"); + expect(aborted).toBe(true); + }); + + it("clean feature branch with WIP auto-rebases onto updated main", () => { + const run: GitRunner = (args) => { + const k = args.join(" "); + if (k === "rev-parse --abbrev-ref HEAD") return "feat"; + if (k === "status --porcelain") return ""; + if (k === "fetch origin --quiet") return ""; + if (k === "rev-parse --verify origin/feat") return "abc"; + if (k === "rev-list --count HEAD..origin/feat") return "0"; + if (k === "rev-parse --verify origin/main") return "abc"; + if (k === "rev-parse --verify main") return "abc"; + if (k === "rev-list --count main..origin/main") return "2"; + if (k === "merge-base --is-ancestor main origin/main") return ""; + if (k === "branch -f main origin/main") return ""; + if (k === "rev-list --count main..feat") return "1"; + if (k === "rebase main") return ""; + throw new Error(`unexpected ${k}`); + }; + const r = syncOneRepo("/tmp/fake", run); + expect(r.status).toBe("ok"); + expect(r.detail).toMatch(/WIP rebased/); + }); +});