|
root / src / hermes.reef
hermes.reef Reef 159 lines 5.6 KB
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
/******************************************************************************
                __               ____                __   
               / /   ___  ____ _/ __/_____________ _/ /__ 
              / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
             / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
            /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ 

    (C)opyright 2026, Leafscale, LLC -  https://www.leafscale.com

    Project: repoman
   Filename: src/hermes.reef
    Authors: Chris Tusa <chris.tusa@leafscale.com>
    License: <see LICENSE file included with this source code>
Description: Hermes classify/paths helpers (library only; not wired to a CLI subcommand)
     
******************************************************************************/

module hermes

import core.str
import core.result_generic as rg
import core.convert as convert
import io.dir as iodir
import sys.process as p
import paths

export
    fn state_dir_for(home_dir: string, container_name: string): string
    fn default_seed_list(): [string]
    fn classify_seed_entry(name: string): int
    fn SEED_KIND_COPY(): int
    fn SEED_KIND_SYMLINK(): int
    fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string]
    fn purge_data_dir(dest: string): rg.Result[bool, string]
end export

// Layout under ~/.local/share/repoman/hermes/<container-name>/
fn state_dir_for(home_dir: string, container_name: string): string
    let base: string = paths.join(home_dir, ".local/share/repoman/hermes")
    return paths.join(base, container_name)
end state_dir_for

// Default selective-seed list: what to copy/symlink from the host's
// ~/.hermes/ into a per-container data dir. Per-instance state
// (sessions/, memories/, state.db, etc.) is *not* in this list.
fn default_seed_list(): [string]
    return [
        ".env",
        "config.yaml",
        "SOUL.md",
        "skills/",
        "hooks/"
    ]
end default_seed_list

// Constants exposed to the applier so it knows whether to copy or symlink
// each seed entry.
fn SEED_KIND_COPY(): int
    return 1
end SEED_KIND_COPY

fn SEED_KIND_SYMLINK(): int
    return 2
end SEED_KIND_SYMLINK

// Classify a seed entry. v0.3 always returns COPY: an earlier design used
// SYMLINK for runtime dirs (hermes-agent/, node/, bin/) so host upgrades
// would auto-apply, but the symlinks can't resolve through the bind-mount
// boundary inside the container (FilesystemLoop). Spec O-3 anticipated
// this and named copy as the fallback. Cost: container recreation needed
// after host hermes upgrades.
fn classify_seed_entry(name: string): int
    return SEED_KIND_COPY()
end classify_seed_entry

// Strip a trailing slash from a string ("hermes-agent/" → "hermes-agent").
// Used to normalize seed-list entries before paths.join.
fn strip_trailing_slash(s: string): string
    let n: int = str.length(s)
    if n > 0 and s[n - 1] == '/'
        return str.substring(s, 0, n - 1)
    end if
    return s
end strip_trailing_slash

// Recursively copy `src` to `dst`. We don't have a built-in recursive copy
// in the reef stdlib, so we shell out to `cp -a`. argv-list spawn — never
// shell — so user-supplied paths can't escape.
fn cp_recursive(src: string, dst: string): bool
    let pid: int = p.process_run("cp", ["-a", src, dst])
    if pid < 0
        return false
    end if
    return p.process_wait(pid) == 0
end cp_recursive

// Create a symlink at `link` pointing to `target` (absolute path).
fn make_symlink(target: string, link: string): bool
    let pid: int = p.process_run("ln", ["-sfn", target, link])
    if pid < 0
        return false
    end if
    return p.process_wait(pid) == 0
end make_symlink

// Selectively seed `source` into `dest` per the seed list.
// Idempotent for COPY entries (cp -a overwrites); idempotent for
// SYMLINK entries (ln -sfn replaces existing links).
// Returns Err on the first failure with a message naming the offending entry.
fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string]
    if not iodir.dir_exists(source)
        return @Result[bool, string].Err("hermes source dir not found: " + source)
    end if
    if not iodir.create_dir_all(dest)
        return @Result[bool, string].Err("cannot create dest dir: " + dest)
    end if

    let n: int = seed.length()
    mut i: int = 0
    while i < n
        let entry: string = seed[i]
        let normalized: string = strip_trailing_slash(entry)
        let src_path: string = paths.join(source, normalized)
        let dst_path: string = paths.join(dest, normalized)
        let kind: int = classify_seed_entry(entry)

        if kind == SEED_KIND_COPY()
            if not cp_recursive(src_path, dst_path)
                return @Result[bool, string].Err("copy failed: " + entry)
            end if
        else
            if not make_symlink(src_path, dst_path)
                return @Result[bool, string].Err("symlink failed: " + entry)
            end if
        end if
        i = i + 1
    end while
    return @Result[bool, string].Ok(true)
end seed_data_dir

// Recursively delete the per-container hermes data dir. Loud; only called
// from `repoman remove --purge-hermes`.
fn purge_data_dir(dest: string): rg.Result[bool, string]
    if not iodir.dir_exists(dest)
        return @Result[bool, string].Ok(true)  // nothing to do
    end if
    let pid: int = p.process_run("rm", ["-rf", dest])
    if pid < 0
        return @Result[bool, string].Err("failed to spawn rm")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Err("rm -rf exited " + convert.to_string(exit))
end purge_data_dir

end module