From e6f22e2634a3f859dc605c2e9b7f87ac47ebc6df Mon Sep 17 00:00:00 2001 From: z2z23n0 Date: Wed, 15 Jul 2026 18:58:05 +0800 Subject: [PATCH 1/2] feat: add bundle-first lifecycle management --- .github/workflows/release.yml | 83 ++ README.md | 127 ++- cmd/release-manifest/main.go | 79 ++ install.sh | 70 ++ internal/buildinfo/info.go | 22 +- internal/bundle/discover.go | 885 ++++++++++++++++++ internal/bundle/discover_test.go | 162 ++++ internal/bundle/recipe.go | 282 ++++++ internal/bundle/recipes/agent-capsule.toml | 29 + internal/bundle/recipes/anysearch.toml | 15 + internal/bundle/recipes/citadel.toml | 28 + internal/bundle/recipes/codex-conductor.toml | 15 + .../recipes/idea-discovery-workflow.toml | 15 + internal/bundle/recipes/itu-context7.toml | 28 + internal/bundle/recipes/known-skills.toml | 16 + internal/bundle/recipes/mainline.toml | 45 + internal/bundle/recipes/sherlog.toml | 32 + internal/bundle/recipes/shuorenhua.toml | 15 + internal/bundle/recipes/tooltend.toml | 29 + internal/bundle/recipes/vibe-island.toml | 15 + internal/bundle/recipes/xsearch.toml | 15 + internal/bundle/recovery.go | 226 +++++ internal/bundle/service.go | 785 ++++++++++++++++ internal/bundle/service_test.go | 283 ++++++ internal/cli/app.go | 30 +- internal/cli/bundle_commands.go | 742 +++++++++++++++ internal/cli/cli_test.go | 214 ++++- internal/cli/init_scan.go | 90 +- internal/cli/maintenance_commands.go | 4 +- internal/cli/read_commands.go | 68 +- internal/cli/reset.go | 418 +++++++++ internal/cli/self_repair.go | 50 + internal/cli/worker_commands.go | 25 + internal/doctor/doctor.go | 16 +- internal/model/enums.go | 98 ++ internal/model/types.go | 150 ++- internal/reconcile/types.go | 12 + internal/reconcile/worker.go | 166 +++- internal/releasemanifest/manifest.go | 138 +++ internal/releasemanifest/manifest_test.go | 67 ++ internal/scheduler/scheduler.go | 23 + internal/selfupdate/manager.go | 34 +- internal/selfupdate/verify.go | 13 + internal/selfupdate/verify_test.go | 14 + internal/store/bundles.go | 748 +++++++++++++++ internal/store/bundles_test.go | 62 ++ internal/store/inventory.go | 20 + .../migrations/0005_bundle_lifecycle.sql | 157 ++++ internal/store/store.go | 2 +- 49 files changed, 6523 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 cmd/release-manifest/main.go create mode 100755 install.sh create mode 100644 internal/bundle/discover.go create mode 100644 internal/bundle/discover_test.go create mode 100644 internal/bundle/recipe.go create mode 100644 internal/bundle/recipes/agent-capsule.toml create mode 100644 internal/bundle/recipes/anysearch.toml create mode 100644 internal/bundle/recipes/citadel.toml create mode 100644 internal/bundle/recipes/codex-conductor.toml create mode 100644 internal/bundle/recipes/idea-discovery-workflow.toml create mode 100644 internal/bundle/recipes/itu-context7.toml create mode 100644 internal/bundle/recipes/known-skills.toml create mode 100644 internal/bundle/recipes/mainline.toml create mode 100644 internal/bundle/recipes/sherlog.toml create mode 100644 internal/bundle/recipes/shuorenhua.toml create mode 100644 internal/bundle/recipes/tooltend.toml create mode 100644 internal/bundle/recipes/vibe-island.toml create mode 100644 internal/bundle/recipes/xsearch.toml create mode 100644 internal/bundle/recovery.go create mode 100644 internal/bundle/service.go create mode 100644 internal/bundle/service_test.go create mode 100644 internal/cli/bundle_commands.go create mode 100644 internal/cli/reset.go create mode 100644 internal/cli/self_repair.go create mode 100644 internal/releasemanifest/manifest.go create mode 100644 internal/releasemanifest/manifest_test.go create mode 100644 internal/store/bundles.go create mode 100644 internal/store/bundles_test.go create mode 100644 internal/store/migrations/0005_bundle_lifecycle.sql diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..151d400 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + cache: true + - name: Validate stable tag + run: | + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + - name: Read release public key + id: release-key + env: + TOOLTEND_RELEASE_PRIVATE_KEY_B64: ${{ secrets.TOOLTEND_RELEASE_PRIVATE_KEY_B64 }} + run: | + public_key="$(go run ./cmd/release-manifest --public-key)" + echo "public_key=$public_key" >> "$GITHUB_OUTPUT" + - name: Build release assets + env: + PUBLIC_KEY: ${{ steps.release-key.outputs.public_key }} + run: | + mkdir -p dist + version="${GITHUB_REF_NAME#v}" + sequence="$(go run ./cmd/release-manifest --sequence --version "$GITHUB_REF_NAME")" + commit="$(git rev-parse HEAD)" + build_date="$(git show -s --format=%cI HEAD)" + ldflags="-s -w -X github.com/z2z23n0/tooltend/internal/buildinfo.Version=$version -X github.com/z2z23n0/tooltend/internal/buildinfo.Commit=$commit -X github.com/z2z23n0/tooltend/internal/buildinfo.Date=$build_date -X github.com/z2z23n0/tooltend/internal/buildinfo.Sequence=$sequence -X github.com/z2z23n0/tooltend/internal/selfupdate.ReleasePublicKeyHex=$PUBLIC_KEY" + for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64; do + os="${target%/*}" + arch="${target#*/}" + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" -o "dist/tooltend-$os-$arch" ./cmd/tooltend + done + - name: Sign release manifest + env: + TOOLTEND_RELEASE_PRIVATE_KEY_B64: ${{ secrets.TOOLTEND_RELEASE_PRIVATE_KEY_B64 }} + run: | + go run ./cmd/release-manifest \ + --version "$GITHUB_REF_NAME" \ + --repository "$GITHUB_REPOSITORY" \ + --assets-dir "$PWD/dist" \ + --published-at "$(git show -s --format=%cI HEAD)" \ + --output dist/tooltend-manifest.json \ + --checksums dist/checksums.txt + - name: Verify signed release payload + run: | + test "$(find dist -maxdepth 1 -type f -name 'tooltend-*' ! -name 'tooltend-manifest.json' | wc -l | tr -d ' ')" = "4" + test -s dist/tooltend-manifest.json + test -s dist/checksums.txt + version="${GITHUB_REF_NAME#v}" + sequence="$(go run ./cmd/release-manifest --sequence --version "$GITHUB_REF_NAME")" + TOOLTEND_HOME="$PWD/dist/self-test" ./dist/tooltend-linux-amd64 self status --json > dist/self-status.json + grep -q "\"version\":\"$version\"" dist/self-status.json + grep -q "\"release_sequence\":$sequence" dist/self-status.json + grep -q '"embedded":true' dist/self-status.json + grep -q '"valid":true' dist/self-status.json + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + dist/tooltend-darwin-arm64 \ + dist/tooltend-darwin-amd64 \ + dist/tooltend-linux-arm64 \ + dist/tooltend-linux-amd64 \ + dist/tooltend-manifest.json \ + dist/checksums.txt \ + --verify-tag \ + --title "$GITHUB_REF_NAME" \ + --generate-notes diff --git a/README.md b/README.md index a0a211f..bbd561c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ # ToolTend -**Lifecycle manager for coding-agent extensions.** +**Bundle lifecycle manager for coding-agent tooling.** *Keep your coding-agent tooling current.* -ToolTend 是面向 Codex 和 Claude Code 的本地生命周期管理器。它统一盘点 Skill、Plugin、Hook、stdio MCP Server,以及这些扩展明确依赖的专用 CLI;在可验证、可回滚的边界内检查更新、保留本地修改、原子切换版本,并在失败时继续使用旧版本。 +ToolTend 是面向 Codex 和 Claude Code 的本地生命周期管理器。它把同一个工具产品的 CLI、Skill、Hook、App、配置和内嵌二进制聚合成一个 Bundle,用同一次 Release、同一个策略和同一笔事务管理;同一份物理安装被多个 Agent 使用时只更新一次。 -ToolTend V1 不提供扩展市场、发布、搜索或卸载,也不接管通用 CLI、项目依赖、远程 HTTP MCP 的版本和整台机器。 +ToolTend v0.2 不提供扩展市场、在线 recipe、搜索或卸载,也不会在初始化时自动接管任何已有工具。Component/Binding 仍保留为底层发现证据和兼容接口,但不再是自动更新的调度单位。 ## 安装 -需要 Go 1.22 或更新版本。可以从源码安装到 `~/.local/bin`: +正式版本可以直接从 GitHub Release 安装到 `~/.local/bin`。安装器通过 GitHub HTTPS 获取匹配平台资产,并按 release manifest 校验字节数和 SHA-256: + +```bash +curl -fsSL https://raw.githubusercontent.com/z2z23n0/tooltend/main/install.sh | bash +``` + +从源码安装需要 Go 1.22 或更新版本: ```bash ./scripts/install.sh ``` -也可以直接安装 Go module: +也可以安装 Go module 开发构建;开发构建不含 release 公钥,不能使用签名自更新: ```bash go install github.com/z2z23n0/tooltend/cmd/tooltend@latest @@ -29,7 +35,40 @@ tooltend version tooltend init ``` -`tooltend init` 在最终确认前只读取本机状态。它只扫描 Codex、Claude Code 的官方配置层、已知扩展目录和用户选择的项目,不遍历整个 home。统一预览会列出状态目录、Host Hook、每日任务、runtime shim 和配置变更;交互确认一次后才写入,并为已确认的精确 npm/Python runtime 异步排队安全迁移。 +`tooltend init` 在最终确认前只读取本机状态。确认后会安装 ToolTend 自己的 Hook、shim 和 scheduler,扫描并聚合 Bundle,但不会迁移 runtime、执行外部安装器或排队接管任务。所有发现的 Bundle 初始状态都是 `unconfigured`。 + +已有 ToolTend 状态需要完全重建时,先预览再确认: + +```bash +tooltend init --reset-state --dry-run +tooltend init --reset-state --yes +``` + +重置前会获取全局锁、检查 managed 对象和未完成 journal,并把 config、state、database、data 与基础设施状态备份到相邻的 `tooltend-backups//`。任一步失败会恢复旧状态和 scheduler。 + +## Bundle 模型 + +```text +Bundle + ├─ BundleRelease:一次整体、精确解析的版本 + ├─ BundleArtifact:CLI / Skill / Hook / App / Config / Binary + ├─ Installation:唯一物理安装实例 + ├─ ConsumerBinding:Codex / Claude / 项目如何消费该实例 + └─ Policy / Transaction / Receipt / Health Check +``` + +生命周期所有者固定为: + +| 所有者 | 行为 | +|---|---| +| `tooltend` | ToolTend staging、切换、验证和回滚 | +| `delegated` | 编排 npm、mtskills 或官方安装器,并验证、记录结果 | +| `host-owned` | Codex/Claude 管理;ToolTend 只观察 | +| `app-owned` | App 自带更新器管理;ToolTend 只观察 | +| `workspace-linked` | 链接本地仓库;默认只观察 commit 和健康 | +| `unresolved` | 无法高置信识别;禁止自动更新 | + +内置 `bundle-recipe-v1` recipe 随二进制发布。本地扩展放在 `~/.config/tooltend/bundles.d/*.toml`,首次配置必须显式信任。所有命令只能声明静态 argv,不能使用 shell 字符串。 ## 工作方式 @@ -44,76 +83,70 @@ SessionStart / ToolUse / 每日任务 / 用户命令 │ ▼ tooltend reconcile --once - 排他锁 → 恢复 → 扫描 → 检查 → staging → 退出 + 排他锁 → 恢复 → 扫描/聚合 → Bundle 事务 → 退出 │ ▼ - generation 或 shim 原子切换 + Bundle Receipt 与健康状态 ``` - Hook 热路径不联网、不合并、不调用模型,SQLite 使用 `busy_timeout=0`;数据库繁忙或输入异常时 fail-open。 - `kick` 只启动一个脱离当前会话的一次性 worker。全局文件锁保证并发 Session 不会并行更新。 - macOS 使用 launchd,Linux 使用 systemd user timer;两者每天启动一次 `reconcile --once`,没有常驻 ToolTend 进程。 -- SQLite 和文件系统之间使用 activation intent journal。进程中断后,下一次 worker 会按真实 generation 指针完成提交或回滚。 -- 正常更新、回滚和 adopt 都生成 Receipt,可供 `history` 审计。 +- 未执行 `bundles configure` 的 Bundle 不检查更新、不下载,也不调用安装器。 +- Bundle 更新先完成所有 Artifact 的解析、校验和 staging,再按物理 Installation 激活;失败时按相反顺序补偿。 +- Bundle 事务使用步骤 journal。中断、失败、回滚和健康检查都有 Bundle 级 Receipt 可审计。 ## 策略 -每个 Binding 有独立策略: +每个 Bundle 有一个明确策略: ```toml -track_channel = "stable" # stable | latest | main | semver | exact -constraint = "^1.6" -apply_mode = "auto" # auto | manual | ignore -notify_mode = "failures" # all | failures | none +mode = "auto" # auto | manual | observe | ignore ``` -- `auto`:只有来源明确、Binding 已 adopt、验证通过且具有可靠回滚边界时,后台 staging 并激活。 -- `manual`:后台只 resolve 并记录可用更新;主动运行 `tooltend update` 后才下载和应用。 -- `ignore`:保留在 inventory 中,不联网检查,也不更新。 -- `exact` 是固定版本通道,不另设 `pin`。 -- 本地 policy 是权限上限。项目 manifest 可以声明版本目标,但不能授予来源信任,也不能把本机的 `manual`、`ignore` 或 `exact` 放宽。 +- `auto`:仅允许 recipe 同时具备精确解析、完整 staging、激活、健康检查和可靠补偿回滚。 +- `manual`:允许检查更新,但每次整包应用都需要用户确认。 +- `observe`:只记录版本、来源、漂移和健康,不执行替换命令。 +- `ignore`:保留发现证据,不检查更新。 -无 Baseline 的已有副本按 Fork 处理并保持 `manual`,不会伪造三方合并。Hook 内容、执行权限、Host 信任哈希或来源身份发生变化时,旧版本继续生效,候选进入 `needs_review`。 +`host-owned`、`app-owned`、`workspace-linked` 和 `unresolved` 只能选择 `observe` 或 `ignore`。交互配置中回车表示跳过,未选择的 Bundle 始终保持 `unconfigured`。 ## 更新与回滚 -文件型组件在 adopt 后进入 ToolTend generation 目录;原安装位置成为稳定指针。每次候选依次经过: +每次 Bundle 更新严格依次经过: ```text -resolve → staging → 来源/完整性验证 -→ Baseline + Binding Overlay + 新上游三方合并 -→ 确定性验证 → 必要的 candidate-bound review -→ intent journal → 原子指针切换 → 健康检查 → Receipt +解析整体 BundleRelease 和精确 Artifact 版本 +→ 全部下载、完整性校验和 staging +→ 兼容性、权限、Hook 和本地修改检查 +→ 每个物理 Installation 只更新一次 +→ 派生步骤和 Bundle/Artifact 健康检查 +→ 提交 Receipt;失败则反向补偿 ``` -本地修改以 Binding Overlay 保存,因此相同组件在不同 Agent 或项目中的定制互不覆盖。文本冲突、验证失败、审查结果为 `conflict`/`uncertain` 或 candidate hash 不匹配时,不切换当前版本。 - -runtime 组件采用隔离环境和稳定 shim。npm、pipx/uv 的原生全局安装在 adopt 前保持 `manual`;迁移先重建并验证精确版本,不自动卸载原安装。 +delegated driver 复用用户现有认证环境,但不会保存或输出 registry token、环境变量或完整命令。默认 resolve 超时 30 秒、安装 5 分钟、健康检查 30 秒,最多重试 3 次。 -## 适配器边界 +## 发现证据 -| 适配器 | 发现/检查 | 自动更新边界 | 回滚 | -|---|---|---|---| -| Git Skill / Plugin / Hook | 支持 | managed、来源和 Baseline 明确、确定性验证通过;Hook 信任变化除外 | generation 指针 | -| npm CLI / stdio MCP | 支持 | adopt 到隔离 prefix 和稳定 shim 后 | 精确版本 generation | -| npx package 引用 | 解析 package | adopt 为固定 runtime 或 wrapper 后 | 恢复旧 wrapper | -| pipx / uv tool | 支持 | adopt 到隔离环境和 shim 后 | 精确版本环境 | -| uvx package 引用 | 观察 | adopt 为持久 runtime 后 | 恢复旧 runtime | -| Homebrew | 发现和检查 | 默认 `manual`;只有降级路径预验证后才可能自动 | 原生精确降级 | -| 远程 HTTP MCP | 配置与可用性观察 | 不做版本更新 | 不适用 | -| 未识别安装器 | 观察 | 不猜测升级命令 | 不适用 | +发现优先读取 npm `package.json`、Git commit、GitHub Release、本机 `.agents/.skill-lock.json`、mtskills 来源记录和签名 manifest、App `Info.plist`/代码签名/Sparkle,以及仓库链接。Skill 文档里的 `latest`、示例命令和依赖约束只作为需求证据,绝不会当作已安装版本。 -`git`、`gh`、`node`、`npx`、`bash` 等载体不会因为普通命令调用被纳入 inventory。 +Codex 插件缓存由 Host 管理,ToolTend 聚合为 `host-owned` 观察对象,不参与下载或替换。无法高置信聚合的对象以 fallback Bundle 保留,`bundles list --all` 才展示。 ## CLI ```text tooltend init +tooltend init --reset-state --dry-run|--yes tooltend scan tooltend status +tooltend bundles list [--all] +tooltend bundles show +tooltend bundles configure [--set =auto|manual|observe|ignore] +tooltend bundles update [ | --all] [--stage-only] +tooltend bundles rollback [--to ] +tooltend bundles history [] +tooltend bundles doctor [] tooltend components list -tooltend components list --managed -tooltend components list --all tooltend components show tooltend policy set tooltend update [component | --all] @@ -128,7 +161,7 @@ tooltend doctor [--repair] 所有命令支持 `--json`;写操作支持 `--dry-run`。在非交互或 JSON 模式中,未提供 `--yes` 的写操作返回 `confirmation_required` 和完整预览,不会提示或偷偷写入。 -`components list` 默认只显示具有真实 Binding、且生命周期不由 Codex 等 Host 自身管理的组件;`--managed` 只显示已 adopt 的 Binding,`--all` 用于查看包含声明依赖和 Host-owned 缓存在内的完整发现结果。Codex 插件缓存仅作观察,不参与 ToolTend 更新检查,也不能 adopt。 +`components`、`policy`、`adopt`、单组件 `update/rollback/history/review` 是 v0.1 兼容入口,会输出弃用提示。它们不再决定用户看到的 Bundle 数量或 Bundle 更新状态。 JSON 输出使用稳定的 V1 envelope: @@ -164,16 +197,18 @@ tooltend project sync --yes | 内容 | 默认位置 | |---|---| | 配置 | `${XDG_CONFIG_HOME:-~/.config}/tooltend/config.toml` | -| SQLite 状态 | `${XDG_STATE_HOME:-~/.local/state}/tooltend/state.db` | +| SQLite schema v5 状态 | `${XDG_STATE_HOME:-~/.local/state}/tooltend/state.db` | | activation lock | `${XDG_STATE_HOME:-~/.local/state}/tooltend/activation.lock` | | objects / staging / generations | `${XDG_DATA_HOME:-~/.local/share}/tooltend/` | | stable shims | `~/.local/bin/` | +| 本地 Bundle recipe | `${XDG_CONFIG_HOME:-~/.config}/tooltend/bundles.d/*.toml` | +| reset 备份 | config/state/data 相邻的 `tooltend-backups//` | SQLite 使用 WAL,schema migration 前创建备份。数据库不保存完整 Prompt、transcript、未经解析的原始命令、环境变量、MCP secret 或 registry token;Hook 只记录标准化 package/version、事件类型和不可逆 correlation hash。 ## ToolTend 自更新 -正式 release 的自更新 manifest 使用嵌入式 Ed25519 公钥验证,平台 asset 同时校验签名 manifest 中的 SHA-256 和字节数。签名、序列号、平台或完整性任一不匹配都不会进入 staging。没有嵌入 release key 的开发构建拒绝自更新。 +正式 release 包含 darwin/linux 的 arm64/amd64 原始可执行文件、`checksums.txt` 和 Ed25519 签名 manifest。安装后的自更新使用二进制内嵌公钥验证签名,并同时检查 release sequence、平台、SHA-256 和字节数。任一不匹配都不会进入 staging;开发构建拒绝自更新。 通过 Homebrew 安装的版本只提示执行对应的 `brew upgrade tooltend`,不会绕过 Homebrew 替换自身。 diff --git a/cmd/release-manifest/main.go b/cmd/release-manifest/main.go new file mode 100644 index 0000000..6393df1 --- /dev/null +++ b/cmd/release-manifest/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "crypto/ed25519" + "encoding/hex" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + + "github.com/z2z23n0/tooltend/internal/releasemanifest" +) + +func main() { + var version, repository, assetsDir, output, checksums, published string + var publicOnly bool + var sequenceOnly bool + flag.StringVar(&version, "version", "", "stable semantic version") + flag.StringVar(&repository, "repository", "z2z23n0/tooltend", "GitHub owner/name") + flag.StringVar(&assetsDir, "assets-dir", "", "directory containing release binaries") + flag.StringVar(&output, "output", "tooltend-manifest.json", "signed envelope output") + flag.StringVar(&checksums, "checksums", "checksums.txt", "checksum output") + flag.StringVar(&published, "published-at", "", "RFC3339 publication timestamp") + flag.BoolVar(&publicOnly, "public-key", false, "print the release public key and exit") + flag.BoolVar(&sequenceOnly, "sequence", false, "print the deterministic release sequence and exit") + flag.Parse() + if sequenceOnly { + parsed, parseErr := semver.StrictNewVersion(strings.TrimPrefix(strings.TrimSpace(version), "v")) + if parseErr != nil || parsed.Prerelease() != "" || parsed.Metadata() != "" { + fatal(fmt.Errorf("--version must be a stable semantic version")) + } + if err := releasemanifest.ValidateSequenceVersion(parsed); err != nil { + fatal(err) + } + fmt.Println(releasemanifest.Sequence(parsed)) + return + } + privateKey, err := releasemanifest.DecodePrivateKey(os.Getenv("TOOLTEND_RELEASE_PRIVATE_KEY_B64")) + if err != nil { + fatal(err) + } + if publicOnly { + fmt.Println(hex.EncodeToString(privateKey.Public().(ed25519.PublicKey))) + return + } + when := time.Now().UTC() + if published != "" { + when, err = time.Parse(time.RFC3339, published) + if err != nil { + fatal(err) + } + } + if assetsDir == "" { + fatal(fmt.Errorf("--assets-dir is required")) + } + assetsDir, err = filepath.Abs(assetsDir) + if err != nil { + fatal(err) + } + result, err := releasemanifest.Generate(releasemanifest.Options{Version: version, Repository: repository, AssetsDir: assetsDir, PrivateKey: privateKey, PublishedAt: when}) + if err != nil { + fatal(err) + } + if err := os.WriteFile(output, result.Envelope, 0o600); err != nil { + fatal(err) + } + if err := os.WriteFile(checksums, result.Checksums, 0o600); err != nil { + fatal(err) + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "release-manifest:", err) + os.Exit(1) +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c7086b2 --- /dev/null +++ b/install.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPOSITORY="${TOOLTEND_REPOSITORY:-z2z23n0/tooltend}" +INSTALL_DIR="${TOOLTEND_INSTALL_DIR:-$HOME/.local/bin}" +MANIFEST_URL="${TOOLTEND_MANIFEST_URL:-https://github.com/$REPOSITORY/releases/latest/download/tooltend-manifest.json}" + +case "$(uname -s)" in + Darwin) os_name="darwin" ;; + Linux) os_name="linux" ;; + *) echo "ToolTend does not publish binaries for $(uname -s)." >&2; exit 1 ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch="arm64" ;; + x86_64|amd64) arch="amd64" ;; + *) echo "ToolTend does not publish binaries for $(uname -m)." >&2; exit 1 ;; +esac + +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/tooltend-install.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT + +manifest="$tmp_dir/tooltend-manifest.json" +curl -fsSL --proto '=https' --tlsv1.2 "$MANIFEST_URL" -o "$manifest" + +asset_record="$(tr '}' '\n' < "$manifest" | sed -n "s#.*\"os\":\"$os_name\",\"arch\":\"$arch\",\"url\":\"\([^\"]*\)\",\"sha256\":\"\([0-9a-fA-F]*\)\",\"size\":\([0-9]*\).*#\1 \2 \3#p" | head -n 1)" +if [[ -z "$asset_record" ]]; then + echo "The signed release manifest has no asset for $os_name/$arch." >&2 + exit 1 +fi + +read -r asset_url expected_sha expected_size <<< "$asset_record" +case "$asset_url" in + https://github.com/*) ;; + *) echo "The release manifest selected a non-GitHub HTTPS asset." >&2; exit 1 ;; +esac + +binary="$tmp_dir/tooltend" +curl -fsSL --proto '=https' --tlsv1.2 "$asset_url" -o "$binary" +actual_size="$(wc -c < "$binary" | tr -d '[:space:]')" +if [[ "$actual_size" != "$expected_size" ]]; then + echo "Downloaded ToolTend size does not match the release manifest." >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + actual_sha="$(sha256sum "$binary" | awk '{print $1}')" +else + actual_sha="$(shasum -a 256 "$binary" | awk '{print $1}')" +fi +actual_sha="$(printf '%s' "$actual_sha" | tr '[:upper:]' '[:lower:]')" +expected_sha="$(printf '%s' "$expected_sha" | tr '[:upper:]' '[:lower:]')" +if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "Downloaded ToolTend SHA-256 does not match the release manifest." >&2 + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +chmod 0755 "$binary" +target="$INSTALL_DIR/tooltend" +if [[ -f "$target" && ! -L "$target" ]]; then + cp -p "$target" "$target.previous" +fi +mv -f "$binary" "$target" + +echo "Installed tooltend to $target" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "Add $INSTALL_DIR to PATH before running tooltend." ;; +esac diff --git a/internal/buildinfo/info.go b/internal/buildinfo/info.go index 553db74..80b3cb3 100644 --- a/internal/buildinfo/info.go +++ b/internal/buildinfo/info.go @@ -1,12 +1,16 @@ package buildinfo -import "runtime" +import ( + "runtime" + "strconv" +) // These values are replaced with -ldflags for release builds. var ( - Version = "dev" - Commit = "unknown" - Date = "unknown" + Version = "dev" + Commit = "unknown" + Date = "unknown" + Sequence = "0" ) type Info struct { @@ -16,6 +20,7 @@ type Info struct { GoVersion string `json:"go_version"` OS string `json:"os"` Arch string `json:"arch"` + Sequence uint64 `json:"release_sequence"` } func Current() Info { @@ -26,5 +31,14 @@ func Current() Info { GoVersion: runtime.Version(), OS: runtime.GOOS, Arch: runtime.GOARCH, + Sequence: ReleaseSequence(), + } +} + +func ReleaseSequence() uint64 { + value, err := strconv.ParseUint(Sequence, 10, 64) + if err != nil { + return 0 } + return value } diff --git a/internal/bundle/discover.go b/internal/bundle/discover.go new file mode 100644 index 0000000..e7e75f0 --- /dev/null +++ b/internal/bundle/discover.go @@ -0,0 +1,885 @@ +package bundle + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/z2z23n0/tooltend/internal/model" + "github.com/z2z23n0/tooltend/internal/store" +) + +type DiscoverOptions struct { + HomeDir string + Executable string + BuildVersion string + LocalRecipeDir string + LookupPath func(string) (string, error) + Now func() time.Time +} + +type DiscoverResult struct { + Bundles int `json:"bundles"` + Installations int `json:"installations"` + ConsumerBindings int `json:"consumer_bindings"` + ByConfidence map[string]int `json:"by_confidence"` + Pruned int64 `json:"pruned"` +} + +type observedInstallation struct { + component model.LogicalComponent + source model.Source + binding model.Binding + dependencies []model.Dependency +} + +type matchedInstallation struct { + path string + packageIdentity string + sourceIdentity string + version string + hash string + consumers []observedInstallation + metadata map[string]any +} + +type skillSourceEvidence struct { + SourceIdentity string + ObservedHash string + Metadata map[string]any +} + +func Discover(ctx context.Context, database *store.Store, options DiscoverOptions) (DiscoverResult, error) { + if database == nil { + return DiscoverResult{}, errors.New("bundle discovery: store is required") + } + if options.HomeDir == "" { + return DiscoverResult{}, errors.New("bundle discovery: home directory is required") + } + now := time.Now().UTC() + if options.Now != nil { + now = options.Now().UTC() + } + lookup := options.LookupPath + if lookup == nil { + lookup = exec.LookPath + } + catalog, err := LoadCatalog(options.LocalRecipeDir) + if err != nil { + return DiscoverResult{}, err + } + observed, err := loadObservedInstallations(ctx, database) + if err != nil { + return DiscoverResult{}, err + } + result := DiscoverResult{ByConfidence: map[string]int{}} + skillEvidence := loadSkillSourceEvidence(options.HomeDir) + matchedBindings := map[string]struct{}{} + for _, recipe := range catalog.Recipes() { + matches := make(map[string][]matchedInstallation, len(recipe.Artifacts)) + for _, artifact := range recipe.Artifacts { + for index := range observed { + item := &observed[index] + if observedHostOwned(*item) && recipe.Owner != model.LifecycleHostOwned { + continue + } + if artifactMatches(artifact, *item) { + match := matchedFromObserved(*item, artifact, options.HomeDir) + enrichSkillMatch(&match, *item, skillEvidence) + enrichWorkspaceMatch(&match, *item) + matches[artifact.Key] = append(matches[artifact.Key], match) + matchedBindings[item.binding.ID] = struct{}{} + } + } + for _, probe := range artifact.Probes { + match, ok := resolveProbe(ctx, probe, recipe, artifact, options, lookup) + if ok { + matches[artifact.Key] = append(matches[artifact.Key], match) + } + } + matches[artifact.Key] = dedupeMatches(matches[artifact.Key]) + } + if countMatches(matches) == 0 { + continue + } + confidence := effectiveConfidence(recipe, matches) + if err := persistRecipeMatch(ctx, database, recipe, confidence, matches, now, &result); err != nil { + return result, err + } + } + if err := persistFallbacks(ctx, database, observed, matchedBindings, now, options.HomeDir, &result); err != nil { + return result, err + } + result.Pruned, err = database.PruneUnconfiguredBundles(ctx, now) + if err != nil { + return result, err + } + if err := refreshDiscoveryCounts(ctx, database, &result); err != nil { + return result, err + } + return result, nil +} + +func observedHostOwned(value observedInstallation) bool { + return value.binding.HostOwned() || strings.Contains(filepath.ToSlash(value.binding.InstallPath), "/.codex/plugins/cache/") +} + +func refreshDiscoveryCounts(ctx context.Context, database *store.Store, result *DiscoverResult) error { + for target, query := range map[*int]string{ + &result.Bundles: `SELECT COUNT(*) FROM bundles`, + &result.Installations: `SELECT COUNT(*) FROM installations`, + &result.ConsumerBindings: `SELECT COUNT(*) FROM consumer_bindings`, + } { + if err := database.DB().QueryRowContext(ctx, query).Scan(target); err != nil { + return err + } + } + result.ByConfidence = map[string]int{} + rows, err := database.DB().QueryContext(ctx, `SELECT confidence,COUNT(*) FROM bundles GROUP BY confidence`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var confidence string + var count int + if err := rows.Scan(&confidence, &count); err != nil { + return err + } + result.ByConfidence[confidence] = count + } + return rows.Err() +} + +func loadObservedInstallations(ctx context.Context, database *store.Store) ([]observedInstallation, error) { + components, err := database.ListComponents(ctx) + if err != nil { + return nil, err + } + dependencies, err := database.ListDependencies(ctx) + if err != nil { + return nil, err + } + depsByComponent := make(map[string][]model.Dependency) + for _, dependency := range dependencies { + depsByComponent[dependency.FromComponentID] = append(depsByComponent[dependency.FromComponentID], dependency) + } + var result []observedInstallation + for _, component := range components { + var source model.Source + if component.SourceID != "" { + source, err = database.GetSource(ctx, component.SourceID) + if err != nil { + return nil, err + } + } + bindings, err := database.ListBindings(ctx, component.ID) + if err != nil { + return nil, err + } + for _, binding := range bindings { + result = append(result, observedInstallation{component: component, source: source, binding: binding, dependencies: depsByComponent[component.ID]}) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].binding.ID < result[j].binding.ID }) + return result, nil +} + +func artifactMatches(recipe ArtifactRecipe, value observedInstallation) bool { + if len(recipe.Selectors) == 0 { + return false + } + for _, selector := range recipe.Selectors { + var candidates []string + switch selector.Field { + case "name": + candidates = []string{value.component.Name} + case "kind": + candidates = []string{string(value.component.Kind)} + case "path": + candidates = []string{value.binding.InstallPath, value.binding.ConfigPath} + case "package": + candidates = []string{value.source.PackageName} + case "source": + candidates = []string{value.source.Locator} + case "dependency": + for _, dependency := range value.dependencies { + candidates = append(candidates, dependency.PackageIdentity) + } + case "host": + candidates = []string{string(value.binding.Host)} + } + if !selectorMatches(selector, candidates) { + return false + } + } + return true +} + +func selectorMatches(selector Selector, candidates []string) bool { + for _, candidate := range candidates { + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if selector.Equals != "" { + for _, alternative := range strings.Split(strings.ToLower(selector.Equals), "|") { + if candidate == strings.TrimSpace(alternative) { + return true + } + } + } else if strings.Contains(candidate, strings.ToLower(selector.Contains)) { + return true + } + } + return false +} + +func matchedFromObserved(value observedInstallation, artifact ArtifactRecipe, home string) matchedInstallation { + path := normalizeInstallationPath(value.binding.InstallPath, home) + packageIdentity := value.source.PackageName + if packageIdentity == "" { + packageIdentity = value.component.Name + } + version, observedHash := actualInstalledVersion(path, packageIdentity, value.binding.ObservedVersion) + if observedHash == "" { + observedHash = value.binding.ObservedHash + } + return matchedInstallation{ + path: path, packageIdentity: packageIdentity, sourceIdentity: value.source.IdentityHash, + version: version, hash: observedHash, consumers: []observedInstallation{value}, + metadata: map[string]any{"legacy_component_id": value.component.ID, "legacy_binding_id": value.binding.ID, "artifact_driver": artifact.Driver}, + } +} + +func resolveProbe(ctx context.Context, probe string, recipe Recipe, artifact ArtifactRecipe, options DiscoverOptions, lookup func(string) (string, error)) (matchedInstallation, bool) { + kind, value, ok := strings.Cut(probe, ":") + if !ok { + return matchedInstallation{}, false + } + var path string + switch kind { + case "command": + path, _ = lookup(value) + if value == "tooltend" && options.Executable != "" { + path = options.Executable + } + case "path": + path = expandHome(value, options.HomeDir) + if info, err := os.Lstat(path); err != nil || (!info.Mode().IsRegular() && !info.IsDir() && info.Mode()&os.ModeSymlink == 0) { + path = "" + } + } + if path == "" { + return matchedInstallation{}, false + } + path = normalizeInstallationPath(path, options.HomeDir) + version := "" + metadata := map[string]any{"probe": probe} + if recipe.ID == "tooltend" && options.BuildVersion != "" && options.BuildVersion != "dev" { + version = strings.TrimPrefix(options.BuildVersion, "v") + } + if artifact.Kind == model.ArtifactApp { + if appVersion, appMetadata := inspectApp(path); appVersion != "" { + version = appVersion + for key, item := range appMetadata { + metadata[key] = item + } + } + metadata["code_signature_valid"] = verifyAppSignature(ctx, path) + } + return matchedInstallation{path: path, packageIdentity: recipe.ID, version: version, metadata: metadata}, true +} + +func persistRecipeMatch(ctx context.Context, database *store.Store, recipe Recipe, confidence model.BundleConfidence, matches map[string][]matchedInstallation, now time.Time, result *DiscoverResult) error { + bundleID := stableID("bun", recipe.ID) + metadata, _ := json.Marshal(map[string]any{"description": recipe.Description}) + value := model.Bundle{ + ID: bundleID, Slug: recipe.ID, Name: recipe.Name, RecipeID: recipe.ID, RecipeVersion: recipe.Version, + RecipeSource: recipe.Source, Owner: recipe.Owner, ConfigState: model.BundleUnconfigured, Confidence: confidence, + MetadataJSON: string(metadata), DiscoveredAt: now, LastSeenAt: now, + } + if err := database.UpsertBundle(ctx, value); err != nil { + return err + } + result.Bundles++ + result.ByConfidence[string(confidence)]++ + for ordinal, artifactRecipe := range recipe.Artifacts { + artifactID := stableID("art", bundleID+"\x00"+artifactRecipe.Key) + artifactMetadata, _ := json.Marshal(artifactRecipe) + artifact := model.BundleArtifact{ + ID: artifactID, BundleID: bundleID, RecipeKey: artifactRecipe.Key, Kind: artifactRecipe.Kind, + Name: artifactRecipe.Name, Ordinal: ordinal, Required: artifactRecipe.Required, Driver: artifactRecipe.Driver, + MetadataJSON: string(artifactMetadata), + } + if err := database.UpsertBundleArtifact(ctx, artifact); err != nil { + return err + } + for _, match := range matches[artifactRecipe.Key] { + if err := persistInstallation(ctx, database, value, artifact, match, now, result); err != nil { + return err + } + } + } + return persistObservedRelease(ctx, database, value, matches, now) +} + +func persistObservedRelease(ctx context.Context, database *store.Store, bundle model.Bundle, matches map[string][]matchedInstallation, now time.Time) error { + versions := map[string][]string{} + for key, values := range matches { + for _, value := range values { + if value.version != "" { + versions[key] = append(versions[key], value.version) + } + } + sort.Strings(versions[key]) + } + if len(versions) == 0 { + return nil + } + manifest, _ := json.Marshal(map[string]any{"artifacts": versions}) + version := "" + var flattened []string + for _, values := range versions { + flattened = append(flattened, values...) + } + sort.Strings(flattened) + if len(flattened) > 0 { + version = flattened[0] + for _, candidate := range flattened[1:] { + if candidate != version { + digest := sha256.Sum256(manifest) + version = "observed-" + hex.EncodeToString(digest[:6]) + break + } + } + } + releaseID := stableID("rel", bundle.ID+"\x00"+string(manifest)) + if err := database.UpsertBundleRelease(ctx, model.BundleRelease{ + ID: releaseID, BundleID: bundle.ID, Version: version, ManifestJSON: string(manifest), Status: "observed", CreatedAt: now, + }); err != nil { + return err + } + return database.SetBundleCurrentRelease(ctx, bundle.ID, releaseID) +} + +func persistInstallation(ctx context.Context, database *store.Store, bundle model.Bundle, artifact model.BundleArtifact, match matchedInstallation, now time.Time, result *DiscoverResult) error { + metadata, _ := json.Marshal(match.metadata) + material := strings.Join([]string{artifact.Driver, match.path, match.packageIdentity, match.sourceIdentity}, "\x00") + installationID := stableID("ins", material) + installation := model.Installation{ + ID: installationID, BundleID: bundle.ID, ArtifactID: artifact.ID, Driver: artifact.Driver, Path: match.path, + PackageIdentity: match.packageIdentity, SourceIdentity: match.sourceIdentity, ObservedVersion: match.version, + ObservedHash: match.hash, Owner: bundle.Owner, MetadataJSON: string(metadata), LastSeenAt: now, + } + if err := database.UpsertInstallation(ctx, installation); err != nil { + return err + } + result.Installations++ + for _, observed := range match.consumers { + legacy := observed.binding + consumer := model.ConsumerBinding{ + ID: stableID("con", installationID+"\x00"+legacy.ID), InstallationID: installationID, BindingID: legacy.ID, + Host: legacy.Host, ProjectID: legacy.ProjectID, Scope: legacy.Scope, ConfigPath: legacy.ConfigPath, + ConfigPointer: legacy.ConfigPointer, LastSeenAt: now, + } + if err := database.UpsertConsumerBinding(ctx, consumer); err != nil { + return err + } + result.ConsumerBindings++ + } + return nil +} + +func persistFallbacks(ctx context.Context, database *store.Store, observed []observedInstallation, matched map[string]struct{}, now time.Time, home string, result *DiscoverResult) error { + type fallbackGroup struct { + bundle model.Bundle + items []observedInstallation + } + groups := map[string]*fallbackGroup{} + for _, item := range observed { + if _, ok := matched[item.binding.ID]; ok { + continue + } + owner := model.LifecycleUnresolved + confidence := model.BundleConfidenceUnresolved + groupKey := "unresolved:" + item.component.LogicalKey + name := item.component.Name + if item.binding.HostOwned() || strings.Contains(item.binding.InstallPath, filepath.Join(".codex", "plugins", "cache")) { + owner, confidence = model.LifecycleHostOwned, model.BundleConfidenceMedium + plugin := pluginGroup(item.binding.InstallPath) + if plugin != "" { + groupKey, name = "host-plugin:"+plugin, plugin+" (host-owned)" + } + } else if linkedWorkspace(item.binding.InstallPath, item.source.Locator, home) { + owner, confidence = model.LifecycleWorkspaceLinked, model.BundleConfidenceMedium + groupKey = "workspace:" + item.component.LogicalKey + } + slug := fallbackSlug(name, groupKey) + group := groups[groupKey] + if group == nil { + group = &fallbackGroup{bundle: model.Bundle{ + ID: stableID("bun", groupKey), Slug: slug, Name: name, RecipeID: "fallback", RecipeVersion: "1", RecipeSource: "fallback", + Owner: owner, ConfigState: model.BundleUnconfigured, Confidence: confidence, MetadataJSON: "{}", DiscoveredAt: now, LastSeenAt: now, + }} + groups[groupKey] = group + } + group.items = append(group.items, item) + } + keys := make([]string, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + group := groups[key] + if err := database.UpsertBundle(ctx, group.bundle); err != nil { + return err + } + result.Bundles++ + result.ByConfidence[string(group.bundle.Confidence)]++ + artifactByComponent := map[string]model.BundleArtifact{} + for _, item := range group.items { + artifact := artifactByComponent[item.component.ID] + if artifact.ID == "" { + kind := artifactKind(item.component.Kind) + artifact = model.BundleArtifact{ + ID: stableID("art", group.bundle.ID+"\x00"+item.component.ID), BundleID: group.bundle.ID, + RecipeKey: item.component.ID, Kind: kind, Name: item.component.Name, Ordinal: len(artifactByComponent), + Required: true, Driver: "observe", MetadataJSON: "{}", + } + if err := database.UpsertBundleArtifact(ctx, artifact); err != nil { + return err + } + artifactByComponent[item.component.ID] = artifact + } + match := matchedFromObserved(item, ArtifactRecipe{Driver: "observe"}, home) + enrichWorkspaceMatch(&match, item) + if err := persistInstallation(ctx, database, group.bundle, artifact, match, now, result); err != nil { + return err + } + } + } + return nil +} + +func loadSkillSourceEvidence(home string) map[string]skillSourceEvidence { + result := map[string]skillSourceEvidence{} + loadSkillLock(filepath.Join(home, ".agents", ".skill-lock.json"), result) + for _, root := range []string{filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".codex", "skills"), filepath.Join(home, ".claude", "skills")} { + loadMTSkillsRecords(filepath.Join(root, ".mtskills-source.jsonl"), result) + } + return result +} + +func loadSkillLock(path string, destination map[string]skillSourceEvidence) { + data, err := os.ReadFile(path) + if err != nil { + return + } + var lock struct { + Version int `json:"version"` + Skills map[string]struct { + Source string `json:"source"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl"` + SkillPath string `json:"skillPath"` + SkillFolderHash string `json:"skillFolderHash"` + InstalledAt string `json:"installedAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"skills"` + } + if json.Unmarshal(data, &lock) != nil { + return + } + for name, item := range lock.Skills { + destination[strings.ToLower(name)] = skillSourceEvidence{SourceIdentity: strings.TrimSpace(item.SourceURL) + "#" + strings.TrimSpace(item.SkillPath), + ObservedHash: item.SkillFolderHash, Metadata: map[string]any{"lifecycle_manager": "npx-skills", "lock_version": lock.Version, + "source": item.Source, "source_type": item.SourceType, "source_url": item.SourceURL, "skill_path": item.SkillPath, + "installed_at": item.InstalledAt, "updated_at": item.UpdatedAt}} + } +} + +func loadMTSkillsRecords(path string, destination map[string]skillSourceEvidence) { + data, err := os.ReadFile(path) + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var record struct { + SkillName string `json:"skillName"` + SourceType string `json:"sourceType"` + SourceID string `json:"sourceId"` + Environment string `json:"env"` + Version string `json:"version"` + InstalledAt string `json:"installedAt"` + TargetDir string `json:"targetDir"` + } + if json.Unmarshal([]byte(line), &record) != nil || record.SkillName == "" { + continue + } + skillPath := filepath.Join(record.TargetDir, record.SkillName) + manifestID, integrity, signaturePresent := inspectSignedSkillManifest(skillPath) + metadata := map[string]any{"lifecycle_manager": "mtskills", "source_type": record.SourceType, "source_id": record.SourceID, + "environment": record.Environment, "installed_at": record.InstalledAt, "target_dir": record.TargetDir, + "manifest_integrity_valid": integrity, "signature_present": signaturePresent} + if exactVersion(record.Version) { + metadata["source_version"] = strings.TrimPrefix(record.Version, "v") + } + if manifestID != "" { + metadata["skill_id"] = manifestID + } + destination[strings.ToLower(record.SkillName)] = skillSourceEvidence{SourceIdentity: "mtskills:" + record.Environment + ":" + record.SourceID, + ObservedHash: manifestID, Metadata: metadata} + } +} + +func enrichSkillMatch(match *matchedInstallation, observed observedInstallation, evidence map[string]skillSourceEvidence) { + if observed.component.Kind != model.ComponentSkill { + return + } + value, ok := evidence[strings.ToLower(observed.component.Name)] + if !ok { + return + } + if value.SourceIdentity != "" { + match.sourceIdentity = value.SourceIdentity + } + if value.ObservedHash != "" { + match.hash = value.ObservedHash + } + for key, item := range value.Metadata { + match.metadata[key] = item + } +} + +func enrichWorkspaceMatch(match *matchedInstallation, observed observedInstallation) { + path := observed.binding.InstallPath + if path == "" { + path = observed.source.Locator + } + if commit := readGitCommit(path); commit != "" { + match.metadata["git_commit"] = commit + if match.version == "" { + match.hash = commit + } + } +} + +func inspectSignedSkillManifest(skillPath string) (string, bool, bool) { + manifestPath := filepath.Join(skillPath, "skill.manifest") + data, err := os.ReadFile(manifestPath) + if err != nil { + return "", false, false + } + manifestID := "" + integrity := true + manifestFiles := 0 + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "skill-id:") { + manifestID = strings.TrimSpace(strings.TrimPrefix(trimmed, "skill-id:")) + continue + } + fields := strings.Fields(trimmed) + if len(fields) != 2 || len(fields[0]) != sha256.Size*2 { + continue + } + manifestFiles++ + content, readErr := os.ReadFile(filepath.Join(skillPath, filepath.FromSlash(fields[1]))) + if readErr != nil { + integrity = false + continue + } + digest := sha256.Sum256(content) + if !strings.EqualFold(fields[0], hex.EncodeToString(digest[:])) { + integrity = false + } + } + signature, err := os.Stat(filepath.Join(skillPath, "skill.sig")) + return manifestID, integrity && manifestID != "" && manifestFiles > 0, err == nil && signature.Mode().IsRegular() && signature.Size() > 0 +} + +func readGitCommit(path string) string { + root := path + if info, err := os.Stat(root); err == nil && !info.IsDir() { + root = filepath.Dir(root) + } + for { + gitPath := filepath.Join(root, ".git") + if info, err := os.Stat(gitPath); err == nil { + gitDir := gitPath + if !info.IsDir() { + data, readErr := os.ReadFile(gitPath) + if readErr != nil || !strings.HasPrefix(strings.TrimSpace(string(data)), "gitdir:") { + return "" + } + gitDir = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(data)), "gitdir:")) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(root, gitDir) + } + } + head, readErr := os.ReadFile(filepath.Join(gitDir, "HEAD")) + if readErr != nil { + return "" + } + value := strings.TrimSpace(string(head)) + if !strings.HasPrefix(value, "ref:") { + return validGitHash(value) + } + ref := strings.TrimSpace(strings.TrimPrefix(value, "ref:")) + if data, readErr := os.ReadFile(filepath.Join(gitDir, filepath.FromSlash(ref))); readErr == nil { + return validGitHash(strings.TrimSpace(string(data))) + } + packed, _ := os.ReadFile(filepath.Join(gitDir, "packed-refs")) + for _, line := range strings.Split(string(packed), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == ref { + return validGitHash(fields[0]) + } + } + return "" + } + parent := filepath.Dir(root) + if parent == root { + return "" + } + root = parent + } +} + +func validGitHash(value string) string { + if len(value) != 40 && len(value) != 64 { + return "" + } + if _, err := hex.DecodeString(value); err != nil { + return "" + } + return value +} + +func verifyAppSignature(ctx context.Context, path string) bool { + commandCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + command := exec.CommandContext(commandCtx, "codesign", "--verify", "--deep", "--strict", path) + command.Stdout, command.Stderr = nil, nil + return command.Run() == nil +} + +func effectiveConfidence(recipe Recipe, matches map[string][]matchedInstallation) model.BundleConfidence { + for _, artifact := range recipe.Artifacts { + if artifact.Required && len(matches[artifact.Key]) == 0 { + if recipe.Confidence == model.BundleConfidenceHigh { + return model.BundleConfidenceMedium + } + return recipe.Confidence + } + } + return recipe.Confidence +} + +func countMatches(values map[string][]matchedInstallation) int { + total := 0 + for _, matches := range values { + total += len(matches) + } + return total +} + +func dedupeMatches(values []matchedInstallation) []matchedInstallation { + seen := map[string]int{} + result := make([]matchedInstallation, 0, len(values)) + for _, value := range values { + key := value.path + "\x00" + value.packageIdentity + "\x00" + value.sourceIdentity + if index, exists := seen[key]; exists { + result[index].consumers = append(result[index].consumers, value.consumers...) + continue + } + seen[key] = len(result) + result = append(result, value) + } + return result +} + +func normalizeInstallationPath(path, home string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + path = expandHome(path, home) + if strings.Contains(path, "#") { + return filepath.Clean(path) + } + path = filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = filepath.Clean(resolved) + } + return path +} + +func expandHome(path, home string) string { + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + return path +} + +func actualInstalledVersion(path, packageIdentity, observed string) (string, string) { + if packageIdentity != "" && path != "" { + cursor := path + if info, err := os.Stat(cursor); err == nil && !info.IsDir() { + cursor = filepath.Dir(cursor) + } + for depth := 0; depth < 8; depth++ { + data, err := os.ReadFile(filepath.Join(cursor, "package.json")) + if err == nil { + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + if json.Unmarshal(data, &pkg) == nil && pkg.Name == packageIdentity && exactVersion(pkg.Version) { + hash := sha256.Sum256(data) + return pkg.Version, hex.EncodeToString(hash[:]) + } + } + parent := filepath.Dir(cursor) + if parent == cursor { + break + } + cursor = parent + } + } + if exactVersion(observed) { + return strings.TrimPrefix(strings.TrimSpace(observed), "v"), "" + } + return "", "" +} + +var semverLike = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) + +func exactVersion(value string) bool { + value = strings.TrimSpace(value) + return semverLike.MatchString(value) +} + +func inspectApp(path string) (string, map[string]any) { + data, err := os.ReadFile(filepath.Join(path, "Contents", "Info.plist")) + if err != nil { + return "", nil + } + text := string(data) + value := plistString(text, "CFBundleShortVersionString") + metadata := map[string]any{ + "bundle_identifier": plistString(text, "CFBundleIdentifier"), + "sparkle_feed": plistString(text, "SUFeedURL"), + "sparkle_enabled": strings.Contains(text, "SUAutomaticallyUpdate") && strings.Contains(text, "") || plistString(text, "SUAutomaticallyUpdate") == "1", + } + return value, metadata +} + +func plistString(data, key string) string { + needle := "" + key + "" + index := strings.Index(data, needle) + if index < 0 { + return "" + } + rest := data[index+len(needle):] + start := strings.Index(rest, "") + end := strings.Index(rest, "") + if start < 0 || end < 0 || end < start { + return "" + } + return strings.TrimSpace(rest[start+len("") : end]) +} + +func pluginGroup(path string) string { + path = filepath.ToSlash(path) + marker := "/.codex/plugins/cache/" + index := strings.Index(path, marker) + if index < 0 { + return "" + } + parts := strings.Split(strings.TrimPrefix(path[index+len(marker):], "/"), "/") + if len(parts) < 2 { + return "" + } + for _, part := range parts[1:] { + if part == "skills" || semverLike.MatchString(part) || strings.Contains(part, ".") || len(part) >= 7 && isHex(part) { + continue + } + return part + } + return parts[1] +} + +func linkedWorkspace(path, locator, home string) bool { + for _, value := range []string{path, locator} { + if strings.Contains(filepath.Clean(value), filepath.Join(home, "workspace")) { + return true + } + } + return false +} + +func fallbackSlug(name, key string) string { + base := strings.ToLower(name) + var b strings.Builder + for _, char := range base { + if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' { + b.WriteRune(char) + } else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") { + b.WriteByte('-') + } + } + base = strings.Trim(b.String(), "-") + if base == "" { + base = "bundle" + } + digest := sha256.Sum256([]byte(key)) + return base + "-" + hex.EncodeToString(digest[:4]) +} + +func artifactKind(kind model.ComponentKind) model.ArtifactKind { + switch kind { + case model.ComponentCLI: + return model.ArtifactCLI + case model.ComponentSkill: + return model.ArtifactSkill + case model.ComponentHook: + return model.ArtifactHook + case model.ComponentPlugin: + return model.ArtifactPlugin + default: + return model.ArtifactMCP + } +} + +func stableID(prefix, material string) string { + digest := sha256.Sum256([]byte(material)) + return prefix + "_" + hex.EncodeToString(digest[:13]) +} + +func isHex(value string) bool { + _, err := hex.DecodeString(value) + return err == nil +} + +func formatError(err error) string { + if err == nil { + return "" + } + return fmt.Sprint(err) +} diff --git a/internal/bundle/discover_test.go b/internal/bundle/discover_test.go new file mode 100644 index 0000000..030b51b --- /dev/null +++ b/internal/bundle/discover_test.go @@ -0,0 +1,162 @@ +package bundle + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/z2z23n0/tooltend/internal/model" + "github.com/z2z23n0/tooltend/internal/store" +) + +func TestDiscoverDeduplicatesPhysicalInstallAndReadsPackageMetadata(t *testing.T) { + database, err := store.OpenRW(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + now := time.Now().UTC() + packageRoot := filepath.Join(t.TempDir(), "node_modules", "@it", "oa-skills") + if err := os.MkdirAll(filepath.Join(packageRoot, "bin"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), []byte(`{"name":"@it/oa-skills","version":"1.2.3"}`), 0o600); err != nil { + t.Fatal(err) + } + physical := filepath.Join(packageRoot, "bin", "oa-skills") + if err := os.WriteFile(physical, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + linkOne := filepath.Join(t.TempDir(), "oa-skills") + linkTwo := filepath.Join(t.TempDir(), "oa-skills") + if err := os.Symlink(physical, linkOne); err != nil { + t.Fatal(err) + } + if err := os.Symlink(physical, linkTwo); err != nil { + t.Fatal(err) + } + source := model.Source{ID: "source", Kind: model.SourceNPM, Locator: "https://registry.npmjs.org/@it/oa-skills", PackageName: "@it/oa-skills", IdentityHash: "source-hash", CreatedAt: now, UpdatedAt: now} + if err := database.UpsertSource(ctx, source); err != nil { + t.Fatal(err) + } + component := model.LogicalComponent{ID: "component", Kind: model.ComponentCLI, Name: "@it/oa-skills", SourceID: source.ID, LogicalKey: "oa-skills", CreatedAt: now, UpdatedAt: now} + if err := database.UpsertComponent(ctx, component); err != nil { + t.Fatal(err) + } + for _, value := range []model.Binding{ + {ID: "codex", ComponentID: component.ID, Host: model.HostCodex, Scope: model.ScopeGlobal, InstallPath: linkOne, ObservedVersion: "latest", Classification: model.ClassificationClean, LastSeenAt: now}, + {ID: "claude", ComponentID: component.ID, Host: model.HostClaude, Scope: model.ScopeGlobal, InstallPath: linkTwo, ObservedVersion: "latest", Classification: model.ClassificationClean, LastSeenAt: now}, + } { + if err := database.UpsertBinding(ctx, value); err != nil { + t.Fatal(err) + } + } + result, err := Discover(ctx, database, DiscoverOptions{HomeDir: t.TempDir(), Executable: "/missing/tooltend", LookupPath: func(string) (string, error) { return "", os.ErrNotExist }, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if result.Bundles == 0 { + t.Fatal("no bundle discovered") + } + bundleValue, err := database.GetBundleBySlug(ctx, "citadel") + if err != nil { + t.Fatal(err) + } + installations, err := database.ListInstallations(ctx, bundleValue.ID) + if err != nil { + t.Fatal(err) + } + if len(installations) != 1 || installations[0].ObservedVersion != "1.2.3" { + t.Fatalf("installations = %#v", installations) + } + consumers, err := database.ListConsumerBindings(ctx, installations[0].ID) + if err != nil { + t.Fatal(err) + } + if len(consumers) != 2 { + t.Fatalf("consumers = %#v", consumers) + } +} + +func TestRecipeRejectsShellStrings(t *testing.T) { + data := []byte(` +schema = "bundle-recipe-v1" +id = "unsafe" +version = "1" +name = "Unsafe" +owner = "delegated" +confidence = "high" +[[artifacts]] +key = "cli" +name = "CLI" +kind = "cli" +driver = "test" +required = true +activate_argv = ["sh", "-c", "echo ok | curl example.test"] +`) + if _, err := decodeRecipe(data, "local"); err == nil { + t.Fatal("unsafe shell recipe was accepted") + } +} + +func TestHostOwnedPluginEvidenceCannotBecomeDelegatedByName(t *testing.T) { + value := observedInstallation{component: model.LogicalComponent{Name: "mainline", Kind: model.ComponentSkill}, binding: model.Binding{ + Host: model.HostCodex, InstallMethod: model.HostOwnedInstallMethod(model.HostCodex), InstallPath: "/Users/test/.codex/plugins/cache/example/mainline", + }} + if !observedHostOwned(value) { + t.Fatal("host-owned plugin evidence was not protected from delegated recipe matching") + } +} + +func TestLoadSkillSourceEvidenceUsesPackageManagerRecordsWithoutInventingVersions(t *testing.T) { + home := t.TempDir() + agents := filepath.Join(home, ".agents") + if err := os.MkdirAll(agents, 0o700); err != nil { + t.Fatal(err) + } + lock := `{"version":3,"skills":{"mainline":{"source":"mainline-org/mainline","sourceType":"github","sourceUrl":"https://github.com/mainline-org/mainline.git","skillPath":"skills/mainline/SKILL.md","skillFolderHash":"abc123","installedAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z"}}}` + if err := os.WriteFile(filepath.Join(agents, ".skill-lock.json"), []byte(lock), 0o600); err != nil { + t.Fatal(err) + } + claudeSkills := filepath.Join(home, ".claude", "skills") + itu := filepath.Join(claudeSkills, "itu-context7") + if err := os.MkdirAll(itu, 0o700); err != nil { + t.Fatal(err) + } + content := []byte("skill body\n") + digest := sha256.Sum256(content) + if err := os.WriteFile(filepath.Join(itu, "SKILL.md"), content, 0o600); err != nil { + t.Fatal(err) + } + manifest := "skill-manifest-version: 1\nskill-id: signed-id\n\nfiles:\n " + hex.EncodeToString(digest[:]) + " SKILL.md\n" + if err := os.WriteFile(filepath.Join(itu, "skill.manifest"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(itu, "skill.sig"), []byte("signature"), 0o600); err != nil { + t.Fatal(err) + } + record := `{"skillName":"itu-context7","sourceType":"mt-name","sourceId":"itu-context7","env":"prod","installedAt":"2026-01-03T00:00:00Z","targetDir":"` + claudeSkills + `"}` + "\n" + if err := os.WriteFile(filepath.Join(claudeSkills, ".mtskills-source.jsonl"), []byte(record), 0o600); err != nil { + t.Fatal(err) + } + + evidence := loadSkillSourceEvidence(home) + if evidence["mainline"].SourceIdentity != "https://github.com/mainline-org/mainline.git#skills/mainline/SKILL.md" || evidence["mainline"].ObservedHash != "abc123" { + t.Fatalf("mainline evidence = %#v", evidence["mainline"]) + } + ituEvidence := evidence["itu-context7"] + if ituEvidence.SourceIdentity != "mtskills:prod:itu-context7" || ituEvidence.ObservedHash != "signed-id" { + t.Fatalf("itu evidence = %#v", ituEvidence) + } + if ituEvidence.Metadata["manifest_integrity_valid"] != true || ituEvidence.Metadata["signature_present"] != true { + t.Fatalf("itu metadata = %#v", ituEvidence.Metadata) + } + if _, exists := ituEvidence.Metadata["source_version"]; exists { + t.Fatalf("non-semver source evidence became an installed version: %#v", ituEvidence.Metadata) + } +} diff --git a/internal/bundle/recipe.go b/internal/bundle/recipe.go new file mode 100644 index 0000000..84b8916 --- /dev/null +++ b/internal/bundle/recipe.go @@ -0,0 +1,282 @@ +package bundle + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" + + "github.com/z2z23n0/tooltend/internal/model" +) + +const RecipeSchema = "bundle-recipe-v1" + +const ( + DefaultResolveTimeout = 30 * time.Second + DefaultInstallTimeout = 5 * time.Minute + DefaultHealthTimeout = 30 * time.Second + DefaultRetries = 3 +) + +//go:embed recipes/*.toml +var builtinRecipes embed.FS + +type Recipe struct { + Schema string `toml:"schema" json:"schema"` + ID string `toml:"id" json:"id"` + Version string `toml:"version" json:"version"` + Name string `toml:"name" json:"name"` + Owner model.LifecycleOwner `toml:"owner" json:"owner"` + Confidence model.BundleConfidence `toml:"confidence" json:"confidence"` + Description string `toml:"description" json:"description,omitempty"` + Artifacts []ArtifactRecipe `toml:"artifacts" json:"artifacts"` + Source string `toml:"-" json:"source"` +} + +type ArtifactRecipe struct { + Key string `toml:"key" json:"key"` + Name string `toml:"name" json:"name"` + Kind model.ArtifactKind `toml:"kind" json:"kind"` + Driver string `toml:"driver" json:"driver"` + Required bool `toml:"required" json:"required"` + Selectors []Selector `toml:"selectors" json:"selectors"` + Probes []string `toml:"probes" json:"probes,omitempty"` + ResolveArgv []string `toml:"resolve_argv" json:"resolve_argv,omitempty"` + StageArgv []string `toml:"stage_argv" json:"stage_argv,omitempty"` + ActivateArgv []string `toml:"activate_argv" json:"activate_argv,omitempty"` + RollbackArgv []string `toml:"rollback_argv" json:"rollback_argv,omitempty"` + HealthArgv []string `toml:"health_argv" json:"health_argv,omitempty"` +} + +type Selector struct { + Field string `toml:"field" json:"field"` + Equals string `toml:"equals" json:"equals,omitempty"` + Contains string `toml:"contains" json:"contains,omitempty"` +} + +type Catalog struct { + recipes map[string]Recipe +} + +func LoadCatalog(localDir string) (Catalog, error) { + result := Catalog{recipes: map[string]Recipe{}} + if err := loadRecipeFS(builtinRecipes, "recipes", "builtin", result.recipes); err != nil { + return Catalog{}, err + } + if localDir != "" { + entries, err := os.ReadDir(localDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return Catalog{}, fmt.Errorf("bundle recipes: read local directory: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".toml" { + continue + } + path := filepath.Join(localDir, entry.Name()) + info, err := os.Lstat(path) + if err != nil { + return Catalog{}, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o022 != 0 { + return Catalog{}, fmt.Errorf("bundle recipes: local recipe must be a regular file that is not group/world writable: %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return Catalog{}, err + } + recipe, err := decodeRecipe(data, "local") + if err != nil { + return Catalog{}, fmt.Errorf("bundle recipes: %s: %w", path, err) + } + // Local recipes may intentionally override a built-in recipe, but + // configuration remains trust-gated by recipe_source=local. + result.recipes[recipe.ID] = recipe + } + } + return result, nil +} + +func loadRecipeFS(files fs.FS, dir, source string, destination map[string]Recipe) error { + entries, err := fs.ReadDir(files, dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".toml" { + continue + } + data, err := fs.ReadFile(files, filepath.Join(dir, entry.Name())) + if err != nil { + return err + } + recipe, err := decodeRecipe(data, source) + if err != nil { + return fmt.Errorf("bundle recipes: %s: %w", entry.Name(), err) + } + if _, exists := destination[recipe.ID]; exists { + return fmt.Errorf("bundle recipes: duplicate recipe %q", recipe.ID) + } + destination[recipe.ID] = recipe + } + return nil +} + +func decodeRecipe(data []byte, source string) (Recipe, error) { + var value Recipe + decoder := toml.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return Recipe{}, err + } + value.Source = source + if err := value.Validate(); err != nil { + return Recipe{}, err + } + return value, nil +} + +func (c Catalog) Recipes() []Recipe { + result := make([]Recipe, 0, len(c.recipes)) + for _, recipe := range c.recipes { + result = append(result, recipe) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func (c Catalog) Get(id string) (Recipe, bool) { + value, ok := c.recipes[id] + return value, ok +} + +func (r Recipe) Validate() error { + if r.Schema != RecipeSchema { + return fmt.Errorf("unsupported schema %q", r.Schema) + } + if !safeIdentifier(r.ID) || r.Version == "" || strings.TrimSpace(r.Name) == "" { + return errors.New("recipe identity is incomplete") + } + if err := r.Owner.Validate(); err != nil { + return err + } + if err := r.Confidence.Validate(); err != nil { + return err + } + if len(r.Artifacts) == 0 { + return errors.New("recipe has no artifacts") + } + seen := map[string]struct{}{} + for index, artifact := range r.Artifacts { + if !safeIdentifier(artifact.Key) || strings.TrimSpace(artifact.Name) == "" || strings.TrimSpace(artifact.Driver) == "" { + return fmt.Errorf("artifact %d identity is incomplete", index) + } + if _, exists := seen[artifact.Key]; exists { + return fmt.Errorf("duplicate artifact key %q", artifact.Key) + } + seen[artifact.Key] = struct{}{} + if err := artifact.Kind.Validate(); err != nil { + return err + } + for _, selector := range artifact.Selectors { + if err := selector.Validate(); err != nil { + return fmt.Errorf("artifact %s: %w", artifact.Key, err) + } + } + for _, probe := range artifact.Probes { + if !strings.HasPrefix(probe, "path:") && !strings.HasPrefix(probe, "command:") { + return fmt.Errorf("artifact %s: invalid probe %q", artifact.Key, probe) + } + if strings.ContainsAny(probe, "\x00\r\n") { + return fmt.Errorf("artifact %s: invalid probe characters", artifact.Key) + } + } + for name, argv := range map[string][]string{ + "resolve": artifact.ResolveArgv, "stage": artifact.StageArgv, "activate": artifact.ActivateArgv, + "rollback": artifact.RollbackArgv, "health": artifact.HealthArgv, + } { + if err := validateStaticArgv(argv); err != nil { + return fmt.Errorf("artifact %s %s argv: %w", artifact.Key, name, err) + } + } + if r.Owner == model.LifecycleToolTend || r.Owner == model.LifecycleDelegated { + if len(artifact.ActivateArgv) > 0 && len(artifact.RollbackArgv) == 0 && r.Owner == model.LifecycleToolTend { + return fmt.Errorf("artifact %s: tooltend activation requires rollback argv", artifact.Key) + } + } else if len(artifact.ResolveArgv)+len(artifact.StageArgv)+len(artifact.ActivateArgv)+len(artifact.RollbackArgv) != 0 { + return fmt.Errorf("artifact %s: observation-only owner cannot declare mutation argv", artifact.Key) + } + } + return nil +} + +func (s Selector) Validate() error { + switch s.Field { + case "name", "kind", "path", "package", "source", "dependency", "host": + default: + return fmt.Errorf("invalid selector field %q", s.Field) + } + if (s.Equals == "") == (s.Contains == "") { + return errors.New("selector requires exactly one of equals or contains") + } + if strings.ContainsAny(s.Equals+s.Contains, "\x00\r\n") { + return errors.New("selector contains invalid characters") + } + return nil +} + +func validateStaticArgv(argv []string) error { + if len(argv) == 0 { + return nil + } + if strings.TrimSpace(argv[0]) == "" || strings.ContainsAny(argv[0], "/\\") { + return errors.New("command must be a program name resolved from PATH") + } + allowedVariables := []string{"${version}", "${resolved_ref}", "${stage}", "${path}", "${previous_version}", "${rollback_version}"} + for _, argument := range argv { + if argument == "" || strings.ContainsAny(argument, "\x00\r\n") { + return errors.New("argument is empty or contains control characters") + } + if strings.ContainsAny(argument, ";|&`><") || strings.Contains(argument, "$(") { + return errors.New("shell syntax is not allowed") + } + for start := strings.Index(argument, "${"); start >= 0; start = strings.Index(argument, "${") { + end := strings.Index(argument[start:], "}") + if end < 0 { + return errors.New("unterminated template variable") + } + variable := argument[start : start+end+1] + allowed := false + for _, candidate := range allowedVariables { + if variable == candidate { + allowed = true + break + } + } + if !allowed { + return fmt.Errorf("template variable %q is not allowed", variable) + } + argument = argument[start+end+1:] + } + } + return nil +} + +func safeIdentifier(value string) bool { + if value == "" || len(value) > 96 { + return false + } + for _, char := range value { + if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' || char == '_' { + continue + } + return false + } + return true +} diff --git a/internal/bundle/recipes/agent-capsule.toml b/internal/bundle/recipes/agent-capsule.toml new file mode 100644 index 0000000..3eb6bae --- /dev/null +++ b/internal/bundle/recipes/agent-capsule.toml @@ -0,0 +1,29 @@ +schema = "bundle-recipe-v1" +id = "agent-capsule" +version = "1" +name = "Agent Capsule" +owner = "delegated" +confidence = "high" +description = "Agent Capsule release binary and agent skill" + +[[artifacts]] +key = "cli" +name = "Agent Capsule CLI" +kind = "cli" +driver = "github-release" +required = true +probes = ["command:capsule"] +health_argv = ["capsule", "version"] +[[artifacts.selectors]] +field = "name" +equals = "capsule" + +[[artifacts]] +key = "skill" +name = "Agent Capsule Skill" +kind = "skill" +driver = "git-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "agent-capsule" diff --git a/internal/bundle/recipes/anysearch.toml b/internal/bundle/recipes/anysearch.toml new file mode 100644 index 0000000..f58fe34 --- /dev/null +++ b/internal/bundle/recipes/anysearch.toml @@ -0,0 +1,15 @@ +schema = "bundle-recipe-v1" +id = "anysearch" +version = "1" +name = "AnySearch" +owner = "delegated" +confidence = "high" +[[artifacts]] +key = "skill" +name = "AnySearch Skill" +kind = "skill" +driver = "git-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "anysearch" diff --git a/internal/bundle/recipes/citadel.toml b/internal/bundle/recipes/citadel.toml new file mode 100644 index 0000000..4df7aec --- /dev/null +++ b/internal/bundle/recipes/citadel.toml @@ -0,0 +1,28 @@ +schema = "bundle-recipe-v1" +id = "citadel" +version = "1" +name = "oa-skills / Citadel" +owner = "delegated" +confidence = "high" +description = "The private oa-skills npm CLI and Citadel skills" + +[[artifacts]] +key = "cli" +name = "oa-skills CLI" +kind = "cli" +driver = "npm" +required = true +probes = ["command:oa-skills"] +[[artifacts.selectors]] +field = "package" +equals = "@it/oa-skills" + +[[artifacts]] +key = "skill" +name = "Citadel Skill" +kind = "skill" +driver = "npm-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "citadel" diff --git a/internal/bundle/recipes/codex-conductor.toml b/internal/bundle/recipes/codex-conductor.toml new file mode 100644 index 0000000..71222ae --- /dev/null +++ b/internal/bundle/recipes/codex-conductor.toml @@ -0,0 +1,15 @@ +schema = "bundle-recipe-v1" +id = "codex-conductor" +version = "1" +name = "Codex Conductor" +owner = "delegated" +confidence = "high" +[[artifacts]] +key = "skill" +name = "Codex Conductor Skill" +kind = "skill" +driver = "git-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "codex-conductor" diff --git a/internal/bundle/recipes/idea-discovery-workflow.toml b/internal/bundle/recipes/idea-discovery-workflow.toml new file mode 100644 index 0000000..050066e --- /dev/null +++ b/internal/bundle/recipes/idea-discovery-workflow.toml @@ -0,0 +1,15 @@ +schema = "bundle-recipe-v1" +id = "idea-discovery-workflow" +version = "1" +name = "Idea Discovery Workflow" +owner = "workspace-linked" +confidence = "high" +[[artifacts]] +key = "skill" +name = "Idea Discovery Workflow Skill" +kind = "skill" +driver = "workspace-link" +required = true +[[artifacts.selectors]] +field = "name" +equals = "idea-discovery-workflow" diff --git a/internal/bundle/recipes/itu-context7.toml b/internal/bundle/recipes/itu-context7.toml new file mode 100644 index 0000000..4b70839 --- /dev/null +++ b/internal/bundle/recipes/itu-context7.toml @@ -0,0 +1,28 @@ +schema = "bundle-recipe-v1" +id = "itu-context7" +version = "1" +name = "ITU Context7" +owner = "delegated" +confidence = "high" +description = "ITU Context7 npm CLI and agent skills" + +[[artifacts]] +key = "cli" +name = "ITU Context7 CLI" +kind = "cli" +driver = "npm" +required = true +probes = ["command:itu-ctx7"] +[[artifacts.selectors]] +field = "package" +equals = "@middleware-ai/itu-context7-cli" + +[[artifacts]] +key = "skill" +name = "ITU Context7 Skill" +kind = "skill" +driver = "mtskills" +required = true +[[artifacts.selectors]] +field = "name" +equals = "itu-context7" diff --git a/internal/bundle/recipes/known-skills.toml b/internal/bundle/recipes/known-skills.toml new file mode 100644 index 0000000..3037dba --- /dev/null +++ b/internal/bundle/recipes/known-skills.toml @@ -0,0 +1,16 @@ +schema = "bundle-recipe-v1" +id = "0g-hk" +version = "1" +name = "0g-hk" +owner = "delegated" +confidence = "high" +description = "0g.hk sharing skill" +[[artifacts]] +key = "skill" +name = "0g-hk Skill" +kind = "skill" +driver = "git-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "0g-hk" diff --git a/internal/bundle/recipes/mainline.toml b/internal/bundle/recipes/mainline.toml new file mode 100644 index 0000000..19b062e --- /dev/null +++ b/internal/bundle/recipes/mainline.toml @@ -0,0 +1,45 @@ +schema = "bundle-recipe-v1" +id = "mainline" +version = "1" +name = "Mainline" +owner = "delegated" +confidence = "high" +description = "Mainline CLI, shared agent skill, and generated host hooks" + +[[artifacts]] +key = "cli" +name = "Mainline CLI" +kind = "cli" +driver = "github-release" +required = true +probes = ["command:mainline"] +health_argv = ["mainline", "version"] +[[artifacts.selectors]] +field = "name" +equals = "mainline" +[[artifacts.selectors]] +field = "kind" +equals = "cli" + +[[artifacts]] +key = "skill" +name = "Mainline Skill" +kind = "skill" +driver = "npx-skills" +required = true +[[artifacts.selectors]] +field = "name" +equals = "mainline" +[[artifacts.selectors]] +field = "kind" +equals = "skill" + +[[artifacts]] +key = "hooks" +name = "Mainline generated hooks" +kind = "hook" +driver = "mainline-hooks" +required = false +[[artifacts.selectors]] +field = "dependency" +equals = "cli:mainline" diff --git a/internal/bundle/recipes/sherlog.toml b/internal/bundle/recipes/sherlog.toml new file mode 100644 index 0000000..d6cc0ab --- /dev/null +++ b/internal/bundle/recipes/sherlog.toml @@ -0,0 +1,32 @@ +schema = "bundle-recipe-v1" +id = "sherlog" +version = "1" +name = "Sherlog" +owner = "delegated" +confidence = "high" +description = "Sherlog npm CLI and shared skill" + +[[artifacts]] +key = "cli" +name = "Sherlog CLI" +kind = "cli" +driver = "npm" +required = true +probes = ["command:sherlog", "command:shlog"] +health_argv = ["sherlog", "--version"] +[[artifacts.selectors]] +field = "name" +equals = "sherlog|shlog" + +[[artifacts]] +key = "skill" +name = "Sherlog Skill" +kind = "skill" +driver = "npx-skills" +required = true +[[artifacts.selectors]] +field = "name" +equals = "sherlog" +[[artifacts.selectors]] +field = "kind" +equals = "skill" diff --git a/internal/bundle/recipes/shuorenhua.toml b/internal/bundle/recipes/shuorenhua.toml new file mode 100644 index 0000000..5628b63 --- /dev/null +++ b/internal/bundle/recipes/shuorenhua.toml @@ -0,0 +1,15 @@ +schema = "bundle-recipe-v1" +id = "shuorenhua" +version = "1" +name = "shuorenhua" +owner = "delegated" +confidence = "high" +[[artifacts]] +key = "skill" +name = "shuorenhua Skill" +kind = "skill" +driver = "git-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "shuorenhua" diff --git a/internal/bundle/recipes/tooltend.toml b/internal/bundle/recipes/tooltend.toml new file mode 100644 index 0000000..4434e80 --- /dev/null +++ b/internal/bundle/recipes/tooltend.toml @@ -0,0 +1,29 @@ +schema = "bundle-recipe-v1" +id = "tooltend" +version = "1" +name = "ToolTend" +owner = "tooltend" +confidence = "high" +description = "ToolTend CLI and its owned hooks and scheduler" + +[[artifacts]] +key = "cli" +name = "ToolTend CLI" +kind = "cli" +driver = "self-update" +required = true +probes = ["command:tooltend"] +health_argv = ["tooltend", "version", "--json"] +[[artifacts.selectors]] +field = "name" +equals = "tooltend" + +[[artifacts]] +key = "integrations" +name = "ToolTend hooks and scheduler" +kind = "hook" +driver = "tooltend-integrations" +required = false +[[artifacts.selectors]] +field = "dependency" +equals = "cli:tooltend" diff --git a/internal/bundle/recipes/vibe-island.toml b/internal/bundle/recipes/vibe-island.toml new file mode 100644 index 0000000..404e656 --- /dev/null +++ b/internal/bundle/recipes/vibe-island.toml @@ -0,0 +1,15 @@ +schema = "bundle-recipe-v1" +id = "vibe-island" +version = "1" +name = "Vibe Island" +owner = "app-owned" +confidence = "high" +description = "Vibe Island macOS application with its Sparkle updater" + +[[artifacts]] +key = "app" +name = "Vibe Island.app" +kind = "app" +driver = "sparkle" +required = true +probes = ["path:/Applications/Vibe Island.app"] diff --git a/internal/bundle/recipes/xsearch.toml b/internal/bundle/recipes/xsearch.toml new file mode 100644 index 0000000..ddf05a6 --- /dev/null +++ b/internal/bundle/recipes/xsearch.toml @@ -0,0 +1,15 @@ +schema = "bundle-recipe-v1" +id = "xsearch" +version = "1" +name = "xsearch" +owner = "delegated" +confidence = "high" +[[artifacts]] +key = "skill" +name = "xsearch Skill" +kind = "skill" +driver = "git-skill" +required = true +[[artifacts.selectors]] +field = "name" +equals = "xsearch" diff --git a/internal/bundle/recovery.go b/internal/bundle/recovery.go new file mode 100644 index 0000000..1f5e788 --- /dev/null +++ b/internal/bundle/recovery.go @@ -0,0 +1,226 @@ +package bundle + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/z2z23n0/tooltend/internal/model" + "github.com/z2z23n0/tooltend/internal/store" +) + +type RecoveryResult struct { + FailedBeforeActivation int `json:"failed_before_activation"` + CompensatedUpdates int `json:"compensated_updates"` + CompletedRollbacks int `json:"completed_rollbacks"` +} + +func (r RecoveryResult) Total() int { + return r.FailedBeforeActivation + r.CompensatedUpdates + r.CompletedRollbacks +} + +// RecoverTransactions makes every unfinished Bundle journal terminal before a +// new inventory scan or update can start. Ambiguous in-flight commands are +// deliberately re-run toward a known exact version; eligible recipes must make +// their activation and rollback commands idempotent. +func (s Service) RecoverTransactions(ctx context.Context) (RecoveryResult, error) { + if err := s.validate(); err != nil { + return RecoveryResult{}, err + } + transactions, err := s.Database.ListUnfinishedBundleTransactions(ctx) + if err != nil { + return RecoveryResult{}, err + } + var result RecoveryResult + for _, transaction := range transactions { + switch transaction.Status { + case model.BundleTransactionPrepared, model.BundleTransactionStaging: + completed := s.now() + if err := s.Database.UpdateBundleTransaction(ctx, transaction.ID, model.BundleTransactionFailed, "recovered_before_activation", "", &completed); err != nil { + return result, err + } + _ = os.RemoveAll(filepath.Join(s.Paths.StagingDir, transaction.ID)) + result.FailedBeforeActivation++ + case model.BundleTransactionActivating: + steps, _, err := s.loadRecoverySteps(ctx, transaction) + if err != nil { + return result, err + } + var activated []executionStep + for _, step := range steps { + switch step.record.Status { + case model.BundleStepActivating, model.BundleStepActivated, model.BundleStepHealthy, model.BundleStepFailed, model.BundleStepCompensating: + activated = append(activated, step) + } + } + if err := s.compensate(ctx, transaction, activated); err != nil { + return result, fmt.Errorf("recover bundle transaction %s: %w", transaction.ID, err) + } + if err := s.putRecoveredRollbackReceipt(ctx, transaction); err != nil { + return result, err + } + _ = os.RemoveAll(filepath.Join(s.Paths.StagingDir, transaction.ID)) + result.CompensatedUpdates++ + case model.BundleTransactionRollingBack: + steps, _, err := s.loadRecoverySteps(ctx, transaction) + if err != nil { + return result, err + } + explicit := false + for _, step := range steps { + explicit = explicit || step.record.Kind == "rollback" + } + if explicit { + fromVersions, err := s.releaseVersionsByID(ctx, transaction.FromReleaseID) + if err != nil { + return result, err + } + if err := s.finishExplicitRollback(ctx, transaction, steps, fromVersions); err != nil { + return result, err + } + result.CompletedRollbacks++ + } else { + var activated []executionStep + for _, step := range steps { + switch step.record.Status { + case model.BundleStepActivating, model.BundleStepActivated, model.BundleStepHealthy, model.BundleStepFailed, model.BundleStepCompensating: + activated = append(activated, step) + } + } + if err := s.compensate(ctx, transaction, activated); err != nil { + return result, fmt.Errorf("recover bundle rollback %s: %w", transaction.ID, err) + } + if err := s.putRecoveredRollbackReceipt(ctx, transaction); err != nil { + return result, err + } + result.CompensatedUpdates++ + } + _ = os.RemoveAll(filepath.Join(s.Paths.StagingDir, transaction.ID)) + } + } + return result, nil +} + +func (s Service) releaseVersionsByID(ctx context.Context, releaseID string) (map[string]string, error) { + if releaseID == "" { + return map[string]string{}, nil + } + release, err := s.Database.GetBundleRelease(ctx, releaseID) + if err != nil { + return nil, err + } + return parseReleaseVersions(release.ManifestJSON) +} + +func (s Service) loadRecoverySteps(ctx context.Context, transaction model.BundleTransaction) ([]executionStep, map[string]string, error) { + records, err := s.Database.ListBundleTransactionSteps(ctx, transaction.ID) + if err != nil { + return nil, nil, err + } + artifacts, err := s.Database.ListBundleArtifacts(ctx, transaction.BundleID) + if err != nil { + return nil, nil, err + } + installations, err := s.Database.ListInstallations(ctx, transaction.BundleID) + if err != nil { + return nil, nil, err + } + artifactByID := make(map[string]model.BundleArtifact, len(artifacts)) + for _, artifact := range artifacts { + artifactByID[artifact.ID] = artifact + } + installationByID := make(map[string]model.Installation, len(installations)) + for _, installation := range installations { + installationByID[installation.ID] = installation + } + versions := map[string]string{} + if transaction.ToReleaseID != "" { + release, err := s.Database.GetBundleRelease(ctx, transaction.ToReleaseID) + if err != nil { + return nil, nil, err + } + versions, err = parseReleaseVersions(release.ManifestJSON) + if err != nil { + return nil, nil, err + } + } + steps := make([]executionStep, 0, len(records)) + for _, record := range records { + artifact, ok := artifactByID[record.ArtifactID] + if !ok { + return nil, nil, fmt.Errorf("recover bundle transaction %s: artifact %s is missing", transaction.ID, record.ArtifactID) + } + installation, ok := installationByID[record.InstallationID] + if !ok { + return nil, nil, fmt.Errorf("recover bundle transaction %s: installation %s is missing", transaction.ID, record.InstallationID) + } + recipe, err := decodeArtifactMetadata(artifact) + if err != nil { + return nil, nil, err + } + rollbackVersion := installation.ObservedVersion + if record.Kind == "rollback" { + rollbackVersion = versions[artifact.RecipeKey] + } + steps = append(steps, executionStep{record: record, artifact: artifact, installation: installation, recipe: recipe, + version: versions[artifact.RecipeKey], rollbackVersion: rollbackVersion, + stagePath: filepath.Join(s.Paths.StagingDir, transaction.ID, fmt.Sprintf("%03d", record.Ordinal))}) + } + return steps, versions, nil +} + +func (s Service) putRecoveredRollbackReceipt(ctx context.Context, transaction model.BundleTransaction) error { + completed := s.now() + receiptID, _ := model.NewID("brc") + receipt := model.BundleReceipt{ID: receiptID, BundleID: transaction.BundleID, TransactionID: transaction.ID, + ReleaseID: transaction.ToReleaseID, Action: "update", Status: "rolled_back", SummaryJSON: "{}", CreatedAt: completed} + return s.Database.CommitBundleCompensation(ctx, transaction.ID, "recovered_interrupted_update", receipt, completed) +} + +func (s Service) finishExplicitRollback(ctx context.Context, transaction model.BundleTransaction, steps []executionStep, fromVersions map[string]string) error { + completedSteps := make([]executionStep, 0, len(steps)) + for index := len(steps) - 1; index >= 0; index-- { + step := steps[index] + if step.record.Status == model.BundleStepCompensated || step.record.Status == model.BundleStepHealthy { + completedSteps = append(completedSteps, step) + continue + } + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepCompensating, "", "", "{}", nil); err != nil { + return err + } + if err := s.runCommand(ctx, step.recipe.RollbackArgv, step, DefaultInstallTimeout); err != nil { + completedSteps = append(completedSteps, step) + restoreErr := s.restoreAfterRollbackFailure(ctx, completedSteps, fromVersions) + return s.failTransaction(ctx, transaction, "recovery_rollback_failed", errors.Join(err, restoreErr)) + } + completed := s.now() + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepCompensated, "", "", "{}", &completed); err != nil { + return err + } + completedSteps = append(completedSteps, step) + } + for index := len(steps) - 1; index >= 0; index-- { + step := steps[index] + if err := s.runCommand(ctx, step.recipe.HealthArgv, step, DefaultHealthTimeout); err != nil { + restoreErr := s.restoreAfterRollbackFailure(ctx, completedSteps, fromVersions) + return s.failTransaction(ctx, transaction, "recovery_rollback_health_failed", errors.Join(err, restoreErr)) + } + completed := s.now() + _ = s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepHealthy, "", "", "{}", &completed) + } + completed := s.now() + observations := make([]store.InstallationObservation, 0, len(steps)) + for _, step := range steps { + observations = append(observations, store.InstallationObservation{InstallationID: step.installation.ID, Version: step.version}) + } + receiptID, _ := model.NewID("brc") + receipt := model.BundleReceipt{ID: receiptID, BundleID: transaction.BundleID, TransactionID: transaction.ID, + ReleaseID: transaction.ToReleaseID, Action: "rollback", Status: "succeeded", SummaryJSON: "{}", CreatedAt: completed} + if err := s.Database.CommitBundleActivation(ctx, transaction.ID, transaction.BundleID, transaction.FromReleaseID, transaction.ToReleaseID, observations, receipt, completed); err != nil { + restoreErr := s.restoreAfterRollbackFailure(ctx, completedSteps, fromVersions) + return s.failTransaction(ctx, transaction, "recovery_rollback_commit_failed", errors.Join(err, restoreErr)) + } + return nil +} diff --git a/internal/bundle/service.go b/internal/bundle/service.go new file mode 100644 index 0000000..02bef97 --- /dev/null +++ b/internal/bundle/service.go @@ -0,0 +1,785 @@ +package bundle + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/z2z23n0/tooltend/internal/config" + "github.com/z2z23n0/tooltend/internal/execx" + "github.com/z2z23n0/tooltend/internal/model" + "github.com/z2z23n0/tooltend/internal/store" +) + +type Service struct { + Database *store.Store + Paths config.Paths + Runner execx.Runner + Now func() time.Time +} + +type UpdatePreview struct { + Bundle model.Bundle `json:"bundle"` + Policy model.BundlePolicy `json:"policy"` + Current *model.BundleRelease `json:"current_release,omitempty"` + Target model.BundleRelease `json:"target_release"` + Artifacts []UpdateArtifactPreview `json:"artifacts"` + StageOnly bool `json:"stage_only"` + AutoEligible bool `json:"auto_eligible"` +} + +type UpdateArtifactPreview struct { + Artifact model.BundleArtifact `json:"artifact"` + Installations int `json:"installations"` + ResolvedVersion string `json:"resolved_version,omitempty"` + CanStage bool `json:"can_stage"` + CanActivate bool `json:"can_activate"` + CanRollback bool `json:"can_rollback"` + CanHealthCheck bool `json:"can_health_check"` +} + +type UpdateResult struct { + Transaction model.BundleTransaction `json:"transaction"` + Release model.BundleRelease `json:"release"` + Receipt *model.BundleReceipt `json:"receipt,omitempty"` +} + +type RollbackPreview struct { + Bundle model.Bundle `json:"bundle"` + Policy model.BundlePolicy `json:"policy"` + From model.BundleRelease `json:"from_release"` + To model.BundleRelease `json:"to_release"` + Steps int `json:"steps"` +} + +type RollbackResult struct { + Transaction model.BundleTransaction `json:"transaction"` + Release model.BundleRelease `json:"release"` + Receipt model.BundleReceipt `json:"receipt"` +} + +func (s Service) PrepareUpdate(ctx context.Context, bundleID string, stageOnly bool) (UpdatePreview, error) { + if err := s.validate(); err != nil { + return UpdatePreview{}, err + } + bundleValue, err := s.Database.GetBundle(ctx, bundleID) + if err != nil { + return UpdatePreview{}, err + } + policy, err := s.Database.GetBundlePolicy(ctx, bundleID) + if errors.Is(err, sql.ErrNoRows) || bundleValue.ConfigState != model.BundleConfigured { + return UpdatePreview{}, errors.New("bundle is unconfigured; run tooltend bundles configure first") + } + if err != nil { + return UpdatePreview{}, err + } + if policy.Mode == model.BundlePolicyObserve || policy.Mode == model.BundlePolicyIgnore { + return UpdatePreview{}, fmt.Errorf("bundle policy %s is observation-only", policy.Mode) + } + if bundleValue.Owner != model.LifecycleToolTend && bundleValue.Owner != model.LifecycleDelegated { + return UpdatePreview{}, fmt.Errorf("bundle lifecycle owner %s is observation-only", bundleValue.Owner) + } + artifacts, err := s.Database.ListBundleArtifacts(ctx, bundleID) + if err != nil { + return UpdatePreview{}, err + } + installations, err := s.Database.ListInstallations(ctx, bundleID) + if err != nil { + return UpdatePreview{}, err + } + byArtifact := make(map[string][]model.Installation) + for _, installation := range installations { + byArtifact[installation.ArtifactID] = append(byArtifact[installation.ArtifactID], installation) + } + preview := UpdatePreview{Bundle: bundleValue, Policy: policy, StageOnly: stageOnly, AutoEligible: true} + if bundleValue.CurrentReleaseID != "" { + current, currentErr := s.Database.GetBundleRelease(ctx, bundleValue.CurrentReleaseID) + if currentErr != nil && !errors.Is(currentErr, sql.ErrNoRows) { + return UpdatePreview{}, currentErr + } + if currentErr == nil { + preview.Current = ¤t + } + } + versions := map[string]string{} + for _, artifact := range artifacts { + recipe, err := decodeArtifactMetadata(artifact) + if err != nil { + return UpdatePreview{}, err + } + item := UpdateArtifactPreview{ + Artifact: artifact, Installations: len(byArtifact[artifact.ID]), CanStage: len(recipe.StageArgv) > 0, + CanActivate: len(recipe.ActivateArgv) > 0, CanRollback: len(recipe.RollbackArgv) > 0, CanHealthCheck: len(recipe.HealthArgv) > 0, + } + if len(byArtifact[artifact.ID]) == 0 { + if artifact.Required { + return UpdatePreview{}, fmt.Errorf("required bundle artifact %s has no physical installation", artifact.Name) + } + preview.Artifacts = append(preview.Artifacts, item) + continue + } + if len(recipe.ResolveArgv) == 0 { + preview.AutoEligible = false + return UpdatePreview{}, fmt.Errorf("installed bundle artifact %s has no release resolver", artifact.Name) + } + resolved, err := s.runResolver(ctx, recipe.ResolveArgv, firstInstallation(byArtifact[artifact.ID])) + if err != nil { + return UpdatePreview{}, fmt.Errorf("resolve artifact %s: %w", artifact.Name, err) + } + item.ResolvedVersion = resolved + versions[artifact.RecipeKey] = resolved + if !item.CanStage || !item.CanActivate || !item.CanRollback || !item.CanHealthCheck { + preview.AutoEligible = false + } + if !item.CanStage { + return UpdatePreview{}, fmt.Errorf("installed bundle artifact %s has no staging command", artifact.Name) + } + if !stageOnly && (!item.CanActivate || !item.CanHealthCheck) { + return UpdatePreview{}, fmt.Errorf("installed bundle artifact %s has no activation or health command", artifact.Name) + } + preview.Artifacts = append(preview.Artifacts, item) + } + if len(versions) == 0 { + return UpdatePreview{}, errors.New("bundle has no resolvable artifacts") + } + if policy.Mode == model.BundlePolicyAuto && !preview.AutoEligible { + return UpdatePreview{}, errors.New("bundle recipe is not eligible for auto updates because staging, rollback, or health evidence is incomplete") + } + manifest, _ := json.Marshal(map[string]any{"artifacts": versions}) + releaseVersion := bundleReleaseVersion(versions, manifest) + preview.Target = model.BundleRelease{ + ID: stableID("rel", bundleValue.ID+"\x00"+string(manifest)), BundleID: bundleValue.ID, Version: releaseVersion, + ResolvedRef: releaseVersion, ManifestJSON: string(manifest), Status: "resolved", CreatedAt: s.now(), + } + return preview, nil +} + +func (s Service) ExecuteUpdate(ctx context.Context, preview UpdatePreview) (result UpdateResult, err error) { + if err := s.validate(); err != nil { + return result, err + } + currentBundle, err := s.Database.GetBundle(ctx, preview.Bundle.ID) + if err != nil { + return result, err + } + currentPolicy, err := s.Database.GetBundlePolicy(ctx, preview.Bundle.ID) + if err != nil { + return result, err + } + if currentBundle.LastSeenAt != preview.Bundle.LastSeenAt || currentBundle.ConfigState != model.BundleConfigured || currentPolicy.UpdatedAt != preview.Policy.UpdatedAt || currentPolicy.Mode != preview.Policy.Mode { + return result, errors.New("bundle or policy changed after update preview") + } + if err := os.MkdirAll(s.Paths.StagingDir, 0o700); err != nil { + return result, err + } + transactionID, err := model.NewID("btx") + if err != nil { + return result, err + } + now := s.now() + transaction := model.BundleTransaction{ + ID: transactionID, BundleID: preview.Bundle.ID, ToReleaseID: preview.Target.ID, Status: model.BundleTransactionPrepared, + StageOnly: preview.StageOnly, StartedAt: now, UpdatedAt: now, + } + if preview.Current != nil { + transaction.FromReleaseID = preview.Current.ID + } + if err := s.Database.UpsertBundleRelease(ctx, preview.Target); err != nil { + return result, err + } + if err := s.Database.PutBundleTransaction(ctx, transaction); err != nil { + return result, err + } + result.Transaction, result.Release = transaction, preview.Target + stageRoot := filepath.Join(s.Paths.StagingDir, transactionID) + if err := os.MkdirAll(stageRoot, 0o700); err != nil { + return result, s.failTransaction(ctx, transaction, "staging_failed", err) + } + defer os.RemoveAll(stageRoot) + artifacts, err := s.Database.ListBundleArtifacts(ctx, preview.Bundle.ID) + if err != nil { + return result, s.failTransaction(ctx, transaction, "inventory_changed", err) + } + installations, err := s.Database.ListInstallations(ctx, preview.Bundle.ID) + if err != nil { + return result, s.failTransaction(ctx, transaction, "inventory_changed", err) + } + byArtifact := map[string][]model.Installation{} + for _, installation := range installations { + byArtifact[installation.ArtifactID] = append(byArtifact[installation.ArtifactID], installation) + } + versions := releaseVersions(preview.Target.ManifestJSON) + steps := []executionStep{} + ordinal := 0 + for _, artifact := range artifacts { + recipe, decodeErr := decodeArtifactMetadata(artifact) + if decodeErr != nil { + return result, s.failTransaction(ctx, transaction, "invalid_recipe", decodeErr) + } + for _, installation := range byArtifact[artifact.ID] { + if len(recipe.StageArgv) == 0 && len(recipe.ActivateArgv) == 0 { + continue + } + stepID := stableID("bst", transactionID+fmt.Sprintf("\x00%d", ordinal)) + step := executionStep{ + record: model.BundleTransactionStep{ID: stepID, TransactionID: transactionID, Ordinal: ordinal, ArtifactID: artifact.ID, + InstallationID: installation.ID, Kind: string(artifact.Kind), Status: model.BundleStepPending, CommandJSON: "[]", RollbackJSON: "[]", BeforeJSON: "{}", AfterJSON: "{}"}, + artifact: artifact, installation: installation, recipe: recipe, version: versions[artifact.RecipeKey], + rollbackVersion: installation.ObservedVersion, stagePath: filepath.Join(stageRoot, fmt.Sprintf("%03d", ordinal)), + } + if err := os.MkdirAll(step.stagePath, 0o700); err != nil { + return result, s.failTransaction(ctx, transaction, "staging_failed", err) + } + step.record.CommandJSON = commandEvidence(recipe.StageArgv) + step.record.RollbackJSON = commandEvidence(recipe.RollbackArgv) + if err := s.Database.PutBundleTransactionStep(ctx, step.record); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + steps = append(steps, step) + ordinal++ + } + } + if len(steps) == 0 { + return result, s.failTransaction(ctx, transaction, "no_update_driver", errors.New("bundle recipe has no executable update steps")) + } + if err := s.Database.UpdateBundleTransaction(ctx, transaction.ID, model.BundleTransactionStaging, "", "", nil); err != nil { + return result, err + } + transaction.Status = model.BundleTransactionStaging + for index := range steps { + step := &steps[index] + if len(step.recipe.StageArgv) > 0 { + if err := s.runCommand(ctx, step.recipe.StageArgv, *step, DefaultInstallTimeout); err != nil { + _ = s.failStep(ctx, step.record.ID, "staging_failed") + return result, s.failTransaction(ctx, transaction, "staging_failed", err) + } + } + completed := s.now() + step.record.Status = model.BundleStepStaged + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepStaged, "", "", "{}", &completed); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + } + if preview.StageOnly { + completed := s.now() + if err := s.Database.CommitStagedBundleTransaction(ctx, transaction.ID, preview.Target.ID, completed); err != nil { + return result, err + } + transaction.Status, transaction.CompletedAt = model.BundleTransactionCommitted, &completed + preview.Target.Status = "staged" + result.Release = preview.Target + result.Transaction = transaction + return result, nil + } + if err := s.Database.UpdateBundleTransaction(ctx, transaction.ID, model.BundleTransactionActivating, "", "", nil); err != nil { + return result, err + } + transaction.Status = model.BundleTransactionActivating + activated := []executionStep{} + for index := range steps { + step := &steps[index] + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepActivating, "", "", "{}", nil); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + if err := s.runCommand(ctx, step.recipe.ActivateArgv, *step, DefaultInstallTimeout); err != nil { + _ = s.failStep(ctx, step.record.ID, "activation_failed") + rollbackSteps := append(append([]executionStep(nil), activated...), *step) + rollbackErr := s.compensate(ctx, transaction, rollbackSteps) + if rollbackErr == nil { + return result, s.recordRolledBack(ctx, transaction, "activation_failed", err) + } + return result, s.failTransaction(ctx, transaction, "activation_failed", errors.Join(err, rollbackErr)) + } + completed := s.now() + step.record.Status = model.BundleStepActivated + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepActivated, "", "", "{}", &completed); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + activated = append(activated, *step) + } + for index := range activated { + step := &activated[index] + if len(step.recipe.HealthArgv) == 0 { + continue + } + if err := s.runCommand(ctx, step.recipe.HealthArgv, *step, DefaultHealthTimeout); err != nil { + _ = s.failStep(ctx, step.record.ID, "health_failed") + rollbackErr := s.compensate(ctx, transaction, activated) + if rollbackErr == nil { + return result, s.recordRolledBack(ctx, transaction, "health_failed", err) + } + return result, s.failTransaction(ctx, transaction, "health_failed", errors.Join(err, rollbackErr)) + } + checkID, _ := model.NewID("bhc") + _ = s.Database.PutBundleHealthCheck(ctx, model.BundleHealthCheck{ID: checkID, BundleID: preview.Bundle.ID, ArtifactID: step.artifact.ID, + InstallationID: step.installation.ID, Name: "recipe-health", Status: "healthy", CheckedAt: s.now()}) + completed := s.now() + _ = s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepHealthy, "", "", "{}", &completed) + } + completed := s.now() + observations := make([]store.InstallationObservation, 0, len(activated)) + for _, step := range activated { + observations = append(observations, store.InstallationObservation{InstallationID: step.installation.ID, Version: step.version}) + } + receiptID, _ := model.NewID("brc") + receipt := model.BundleReceipt{ID: receiptID, BundleID: preview.Bundle.ID, TransactionID: transaction.ID, ReleaseID: preview.Target.ID, + Action: "update", Status: "succeeded", SummaryJSON: "{}", CreatedAt: completed} + fromReleaseID := "" + if preview.Current != nil { + fromReleaseID = preview.Current.ID + } + if err := s.Database.CommitBundleActivation(ctx, transaction.ID, preview.Bundle.ID, fromReleaseID, preview.Target.ID, observations, receipt, completed); err != nil { + rollbackErr := s.compensate(ctx, transaction, activated) + if rollbackErr == nil { + return result, s.recordRolledBack(ctx, transaction, "commit_failed", err) + } + return result, s.failTransaction(ctx, transaction, "commit_failed", errors.Join(err, rollbackErr)) + } + transaction.Status, transaction.CompletedAt = model.BundleTransactionCommitted, &completed + preview.Target.Status = "active" + result.Release = preview.Target + result.Transaction, result.Receipt = transaction, &receipt + return result, nil +} + +func (s Service) PrepareRollback(ctx context.Context, bundleID, targetReleaseID string) (RollbackPreview, error) { + if err := s.validate(); err != nil { + return RollbackPreview{}, err + } + bundleValue, err := s.Database.GetBundle(ctx, bundleID) + if err != nil { + return RollbackPreview{}, err + } + if bundleValue.ConfigState != model.BundleConfigured { + return RollbackPreview{}, errors.New("bundle is unconfigured; run tooltend bundles configure first") + } + policy, err := s.Database.GetBundlePolicy(ctx, bundleID) + if err != nil { + return RollbackPreview{}, err + } + if policy.Mode == model.BundlePolicyObserve || policy.Mode == model.BundlePolicyIgnore { + return RollbackPreview{}, fmt.Errorf("bundle policy %s is observation-only", policy.Mode) + } + if bundleValue.Owner != model.LifecycleToolTend && bundleValue.Owner != model.LifecycleDelegated { + return RollbackPreview{}, fmt.Errorf("bundle lifecycle owner %s is observation-only", bundleValue.Owner) + } + if bundleValue.CurrentReleaseID == "" { + return RollbackPreview{}, errors.New("bundle has no current release") + } + if targetReleaseID == "" || targetReleaseID == bundleValue.CurrentReleaseID { + return RollbackPreview{}, errors.New("rollback target must be a prior release") + } + from, err := s.Database.GetBundleRelease(ctx, bundleValue.CurrentReleaseID) + if err != nil { + return RollbackPreview{}, err + } + to, err := s.Database.GetBundleRelease(ctx, targetReleaseID) + if err != nil { + return RollbackPreview{}, err + } + if to.BundleID != bundleID { + return RollbackPreview{}, errors.New("rollback target belongs to a different bundle") + } + targetVersions, err := parseReleaseVersions(to.ManifestJSON) + if err != nil { + return RollbackPreview{}, fmt.Errorf("rollback target manifest: %w", err) + } + artifacts, err := s.Database.ListBundleArtifacts(ctx, bundleID) + if err != nil { + return RollbackPreview{}, err + } + installations, err := s.Database.ListInstallations(ctx, bundleID) + if err != nil { + return RollbackPreview{}, err + } + byArtifact := make(map[string]int) + for _, installation := range installations { + byArtifact[installation.ArtifactID]++ + } + preview := RollbackPreview{Bundle: bundleValue, Policy: policy, From: from, To: to} + for _, artifact := range artifacts { + count := byArtifact[artifact.ID] + if count == 0 && !artifact.Required { + continue + } + version := targetVersions[artifact.RecipeKey] + if count > 0 && !exactVersion(version) { + return RollbackPreview{}, fmt.Errorf("rollback target has no exact version for artifact %s", artifact.Name) + } + recipe, err := decodeArtifactMetadata(artifact) + if err != nil { + return RollbackPreview{}, err + } + if count > 0 && (len(recipe.RollbackArgv) == 0 || len(recipe.ActivateArgv) == 0 || len(recipe.HealthArgv) == 0) { + return RollbackPreview{}, fmt.Errorf("artifact %s cannot be rolled back safely: rollback, restore, and health commands are required", artifact.Name) + } + preview.Steps += count + } + if preview.Steps == 0 { + return RollbackPreview{}, errors.New("bundle has no rollback-capable installations") + } + return preview, nil +} + +func (s Service) ExecuteRollback(ctx context.Context, preview RollbackPreview) (result RollbackResult, err error) { + if err := s.validate(); err != nil { + return result, err + } + currentBundle, err := s.Database.GetBundle(ctx, preview.Bundle.ID) + if err != nil { + return result, err + } + currentPolicy, err := s.Database.GetBundlePolicy(ctx, preview.Bundle.ID) + if err != nil { + return result, err + } + if currentBundle.CurrentReleaseID != preview.From.ID || currentBundle.LastSeenAt != preview.Bundle.LastSeenAt || currentPolicy.UpdatedAt != preview.Policy.UpdatedAt || currentPolicy.Mode != preview.Policy.Mode { + return result, errors.New("bundle, policy, or current release changed after rollback preview") + } + fromVersions, err := parseReleaseVersions(preview.From.ManifestJSON) + if err != nil { + return result, fmt.Errorf("current release manifest: %w", err) + } + toVersions, err := parseReleaseVersions(preview.To.ManifestJSON) + if err != nil { + return result, fmt.Errorf("rollback release manifest: %w", err) + } + transactionID, err := model.NewID("btx") + if err != nil { + return result, err + } + now := s.now() + transaction := model.BundleTransaction{ID: transactionID, BundleID: preview.Bundle.ID, FromReleaseID: preview.From.ID, + ToReleaseID: preview.To.ID, Status: model.BundleTransactionPrepared, StartedAt: now, UpdatedAt: now} + if err := s.Database.PutBundleTransaction(ctx, transaction); err != nil { + return result, err + } + result.Transaction, result.Release = transaction, preview.To + artifacts, err := s.Database.ListBundleArtifacts(ctx, preview.Bundle.ID) + if err != nil { + return result, s.failTransaction(ctx, transaction, "inventory_changed", err) + } + installations, err := s.Database.ListInstallations(ctx, preview.Bundle.ID) + if err != nil { + return result, s.failTransaction(ctx, transaction, "inventory_changed", err) + } + byArtifact := make(map[string][]model.Installation) + for _, installation := range installations { + byArtifact[installation.ArtifactID] = append(byArtifact[installation.ArtifactID], installation) + } + var steps []executionStep + for _, artifact := range artifacts { + recipe, decodeErr := decodeArtifactMetadata(artifact) + if decodeErr != nil { + return result, s.failTransaction(ctx, transaction, "invalid_recipe", decodeErr) + } + for _, installation := range byArtifact[artifact.ID] { + ordinal := len(steps) + step := executionStep{record: model.BundleTransactionStep{ID: stableID("bst", transactionID+fmt.Sprintf("\x00%d", ordinal)), + TransactionID: transactionID, Ordinal: ordinal, ArtifactID: artifact.ID, InstallationID: installation.ID, + Kind: "rollback", Status: model.BundleStepPending, CommandJSON: commandEvidence(recipe.RollbackArgv), + RollbackJSON: commandEvidence(recipe.ActivateArgv), BeforeJSON: "{}", AfterJSON: "{}"}, + artifact: artifact, installation: installation, recipe: recipe, version: toVersions[artifact.RecipeKey], rollbackVersion: toVersions[artifact.RecipeKey]} + if err := s.Database.PutBundleTransactionStep(ctx, step.record); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + steps = append(steps, step) + } + } + if len(steps) == 0 { + return result, s.failTransaction(ctx, transaction, "no_rollback_driver", errors.New("bundle has no rollback steps")) + } + if err := s.Database.UpdateBundleTransaction(ctx, transaction.ID, model.BundleTransactionRollingBack, "", "", nil); err != nil { + return result, err + } + transaction.Status = model.BundleTransactionRollingBack + completedSteps := make([]executionStep, 0, len(steps)) + for index := len(steps) - 1; index >= 0; index-- { + step := steps[index] + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepCompensating, "", "", "{}", nil); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + if err := s.runCommand(ctx, step.recipe.RollbackArgv, step, DefaultInstallTimeout); err != nil { + _ = s.failStep(ctx, step.record.ID, "rollback_failed") + completedSteps = append(completedSteps, step) + restoreErr := s.restoreAfterRollbackFailure(ctx, completedSteps, fromVersions) + return result, s.failTransaction(ctx, transaction, "rollback_failed", errors.Join(err, restoreErr)) + } + completed := s.now() + if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepCompensated, "", "", "{}", &completed); err != nil { + return result, s.failTransaction(ctx, transaction, "journal_failed", err) + } + completedSteps = append(completedSteps, step) + } + for index := len(steps) - 1; index >= 0; index-- { + step := steps[index] + if err := s.runCommand(ctx, step.recipe.HealthArgv, step, DefaultHealthTimeout); err != nil { + restoreErr := s.restoreAfterRollbackFailure(ctx, completedSteps, fromVersions) + return result, s.failTransaction(ctx, transaction, "rollback_health_failed", errors.Join(err, restoreErr)) + } + completed := s.now() + _ = s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepHealthy, "", "", "{}", &completed) + checkID, _ := model.NewID("bhc") + _ = s.Database.PutBundleHealthCheck(ctx, model.BundleHealthCheck{ID: checkID, BundleID: preview.Bundle.ID, ArtifactID: step.artifact.ID, + InstallationID: step.installation.ID, Name: "rollback-health", Status: "healthy", CheckedAt: completed}) + } + completed := s.now() + observations := make([]store.InstallationObservation, 0, len(steps)) + for _, step := range steps { + observations = append(observations, store.InstallationObservation{InstallationID: step.installation.ID, Version: step.version}) + } + receiptID, _ := model.NewID("brc") + receipt := model.BundleReceipt{ID: receiptID, BundleID: preview.Bundle.ID, TransactionID: transaction.ID, + ReleaseID: preview.To.ID, Action: "rollback", Status: "succeeded", SummaryJSON: "{}", CreatedAt: completed} + if err := s.Database.CommitBundleActivation(ctx, transaction.ID, preview.Bundle.ID, preview.From.ID, preview.To.ID, observations, receipt, completed); err != nil { + restoreErr := s.restoreAfterRollbackFailure(ctx, completedSteps, fromVersions) + return result, s.failTransaction(ctx, transaction, "rollback_commit_failed", errors.Join(err, restoreErr)) + } + transaction.Status, transaction.CompletedAt = model.BundleTransactionCommitted, &completed + preview.To.Status = "active" + result.Release = preview.To + result.Transaction, result.Receipt = transaction, receipt + return result, nil +} + +func (s Service) restoreAfterRollbackFailure(ctx context.Context, completed []executionStep, versions map[string]string) error { + var failures []error + for index := len(completed) - 1; index >= 0; index-- { + step := completed[index] + step.version = versions[step.artifact.RecipeKey] + if !exactVersion(step.version) { + failures = append(failures, fmt.Errorf("artifact %s has no exact restore version", step.artifact.Name)) + continue + } + if err := s.runCommand(context.WithoutCancel(ctx), step.recipe.ActivateArgv, step, DefaultInstallTimeout); err != nil { + failures = append(failures, err) + } + } + return errors.Join(failures...) +} + +type executionStep struct { + record model.BundleTransactionStep + artifact model.BundleArtifact + installation model.Installation + recipe ArtifactRecipe + version string + rollbackVersion string + stagePath string +} + +func (s Service) runResolver(ctx context.Context, argv []string, installation model.Installation) (string, error) { + resolved := substituteArgv(argv, map[string]string{"${path}": installation.Path, "${version}": installation.ObservedVersion}) + result, err := s.runWithRetry(ctx, resolved, DefaultResolveTimeout) + if err != nil { + return "", err + } + version := strings.TrimSpace(string(result.Stdout)) + if index := strings.IndexByte(version, '\n'); index >= 0 { + version = strings.TrimSpace(version[:index]) + } + if !exactVersion(version) { + return "", errors.New("resolver did not return an exact semantic version") + } + return strings.TrimPrefix(version, "v"), nil +} + +func (s Service) runCommand(ctx context.Context, argv []string, step executionStep, timeout time.Duration) error { + if len(argv) == 0 { + return nil + } + values := map[string]string{ + "${version}": step.version, "${resolved_ref}": step.version, "${stage}": step.stagePath, + "${path}": step.installation.Path, "${previous_version}": step.installation.ObservedVersion, + "${rollback_version}": step.rollbackVersion, + } + _, err := s.runWithRetry(ctx, substituteArgv(argv, values), timeout) + return err +} + +func (s Service) runWithRetry(ctx context.Context, argv []string, timeout time.Duration) (execx.Result, error) { + if len(argv) == 0 { + return execx.Result{}, nil + } + var last error + for attempt := 0; attempt < DefaultRetries; attempt++ { + commandCtx, cancel := context.WithTimeout(ctx, timeout) + result, err := s.Runner.Run(commandCtx, argv[0], argv[1:]...) + cancel() + if err == nil { + return result, nil + } + last = err + if ctx.Err() != nil { + break + } + } + return execx.Result{}, fmt.Errorf("driver command failed after %d attempts: %w", DefaultRetries, last) +} + +func (s Service) compensate(ctx context.Context, transaction model.BundleTransaction, activated []executionStep) error { + if err := s.Database.UpdateBundleTransaction(ctx, transaction.ID, model.BundleTransactionRollingBack, "", "", nil); err != nil { + return err + } + var failures []error + for index := len(activated) - 1; index >= 0; index-- { + step := activated[index] + if len(step.recipe.RollbackArgv) == 0 { + failures = append(failures, fmt.Errorf("artifact %s has no rollback command", step.artifact.Name)) + continue + } + if err := s.Database.UpdateBundleTransactionStep(context.WithoutCancel(ctx), step.record.ID, model.BundleStepCompensating, "", "", "{}", nil); err != nil { + failures = append(failures, err) + continue + } + if err := s.runCommand(context.WithoutCancel(ctx), step.recipe.RollbackArgv, step, DefaultInstallTimeout); err != nil { + failures = append(failures, err) + continue + } + completed := s.now() + _ = s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepCompensated, "", "", "{}", &completed) + } + return errors.Join(failures...) +} + +func (s Service) failTransaction(ctx context.Context, transaction model.BundleTransaction, code string, cause error) error { + completed := s.now() + _ = s.Database.UpdateBundleTransaction(ctx, transaction.ID, model.BundleTransactionFailed, code, "", &completed) + return fmt.Errorf("bundle transaction failed (%s): %w", code, cause) +} + +func (s Service) recordRolledBack(ctx context.Context, transaction model.BundleTransaction, code string, cause error) error { + completed := s.now() + receiptID, _ := model.NewID("brc") + receipt := model.BundleReceipt{ID: receiptID, BundleID: transaction.BundleID, TransactionID: transaction.ID, + ReleaseID: transaction.ToReleaseID, Action: "update", Status: "rolled_back", SummaryJSON: "{}", CreatedAt: completed} + if err := s.Database.CommitBundleCompensation(ctx, transaction.ID, code, receipt, completed); err != nil { + return fmt.Errorf("bundle transaction compensation commit failed: %w", err) + } + return fmt.Errorf("bundle transaction rolled back (%s): %w", code, cause) +} + +func (s Service) failStep(ctx context.Context, id, code string) error { + completed := s.now() + return s.Database.UpdateBundleTransactionStep(ctx, id, model.BundleStepFailed, code, "", "{}", &completed) +} + +func (s Service) validate() error { + if s.Database == nil || s.Database.DB() == nil { + return errors.New("bundle service: database is required") + } + if s.Paths.StagingDir == "" { + return errors.New("bundle service: staging directory is required") + } + if s.Runner == nil { + return errors.New("bundle service: runner is required") + } + return nil +} + +func (s Service) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func decodeArtifactMetadata(artifact model.BundleArtifact) (ArtifactRecipe, error) { + var value ArtifactRecipe + if err := json.Unmarshal([]byte(artifact.MetadataJSON), &value); err != nil { + return value, fmt.Errorf("decode recipe for artifact %s: %w", artifact.Name, err) + } + if err := value.Kind.Validate(); err != nil { + return value, err + } + for _, argv := range [][]string{value.ResolveArgv, value.StageArgv, value.ActivateArgv, value.RollbackArgv, value.HealthArgv} { + if err := validateStaticArgv(argv); err != nil { + return value, err + } + } + return value, nil +} + +func firstInstallation(values []model.Installation) model.Installation { + if len(values) == 0 { + return model.Installation{} + } + return values[0] +} + +func substituteArgv(argv []string, values map[string]string) []string { + result := make([]string, len(argv)) + for index, argument := range argv { + for variable, value := range values { + argument = strings.ReplaceAll(argument, variable, value) + } + result[index] = argument + } + return result +} + +func commandEvidence(argv []string) string { + if len(argv) == 0 { + return "[]" + } + encoded, _ := json.Marshal(map[string]any{"program": argv[0], "argument_count": len(argv) - 1}) + return string(encoded) +} + +func releaseVersions(manifest string) map[string]string { + value, _ := parseReleaseVersions(manifest) + return value +} + +func parseReleaseVersions(manifest string) (map[string]string, error) { + var value struct { + Artifacts map[string]json.RawMessage `json:"artifacts"` + } + if err := json.Unmarshal([]byte(manifest), &value); err != nil { + return nil, err + } + result := make(map[string]string, len(value.Artifacts)) + for key, raw := range value.Artifacts { + var exact string + if err := json.Unmarshal(raw, &exact); err == nil { + result[key] = strings.TrimPrefix(strings.TrimSpace(exact), "v") + continue + } + var observed []string + if err := json.Unmarshal(raw, &observed); err != nil { + return nil, fmt.Errorf("artifact %s has an invalid version", key) + } + if len(observed) == 1 { + result[key] = strings.TrimPrefix(strings.TrimSpace(observed[0]), "v") + } + } + return result, nil +} + +func bundleReleaseVersion(versions map[string]string, manifest []byte) string { + values := make([]string, 0, len(versions)) + for _, value := range versions { + values = append(values, value) + } + sort.Strings(values) + if len(values) > 0 { + allSame := true + for _, value := range values[1:] { + if value != values[0] { + allSame = false + break + } + } + if allSame { + return values[0] + } + } + return "bundle-" + strings.TrimPrefix(stableID("", string(manifest)), "_")[:12] +} diff --git a/internal/bundle/service_test.go b/internal/bundle/service_test.go new file mode 100644 index 0000000..c454150 --- /dev/null +++ b/internal/bundle/service_test.go @@ -0,0 +1,283 @@ +package bundle + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/z2z23n0/tooltend/internal/config" + "github.com/z2z23n0/tooltend/internal/execx" + "github.com/z2z23n0/tooltend/internal/model" + "github.com/z2z23n0/tooltend/internal/store" +) + +type transactionRunner struct { + mu sync.Mutex + calls []string +} + +func (r *transactionRunner) Run(_ context.Context, name string, args ...string) (execx.Result, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, name) + switch name { + case "resolver": + return execx.Result{Stdout: []byte("2.0.0\n")}, nil + case "activate-two": + return execx.Result{}, errors.New("activation failed") + default: + return execx.Result{}, nil + } +} + +func TestBundleTransactionStagesAllArtifactsBeforeActivationAndCompensates(t *testing.T) { + root := t.TempDir() + paths := config.ResolveWith(root, func(key string) string { + if key == config.EnvHome { + return filepath.Join(root, "tooltend") + } + return "" + }) + if err := paths.Ensure(); err != nil { + t.Fatal(err) + } + database, err := store.OpenRW(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + defer database.Close() + now := time.Now().UTC() + bundleValue := model.Bundle{ID: "bundle", Slug: "bundle", Name: "Bundle", RecipeID: "test", RecipeVersion: "1", RecipeSource: "local", Owner: model.LifecycleDelegated, ConfigState: model.BundleUnconfigured, Confidence: model.BundleConfidenceHigh, MetadataJSON: "{}", DiscoveredAt: now, LastSeenAt: now} + if err := database.UpsertBundle(context.Background(), bundleValue); err != nil { + t.Fatal(err) + } + makeArtifact := func(id, key, activate string, ordinal int) model.BundleArtifact { + recipe := ArtifactRecipe{Key: key, Name: key, Kind: model.ArtifactCLI, Driver: "test", Required: true, + ResolveArgv: []string{"resolver"}, StageArgv: []string{"stage-" + key, "${stage}"}, ActivateArgv: []string{activate, "${path}"}, RollbackArgv: []string{"rollback-" + key, "${previous_version}"}, HealthArgv: []string{"health-" + key}} + metadata, _ := jsonMarshal(recipe) + return model.BundleArtifact{ID: id, BundleID: bundleValue.ID, RecipeKey: key, Kind: model.ArtifactCLI, Name: key, Ordinal: ordinal, Required: true, Driver: "test", MetadataJSON: metadata} + } + artifacts := []model.BundleArtifact{makeArtifact("artifact-one", "one", "activate-one", 0), makeArtifact("artifact-two", "two", "activate-two", 1)} + for _, artifact := range artifacts { + if err := database.UpsertBundleArtifact(context.Background(), artifact); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, artifact.RecipeKey) + if err := os.WriteFile(path, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if err := database.UpsertInstallation(context.Background(), model.Installation{ID: "installation-" + artifact.RecipeKey, BundleID: bundleValue.ID, ArtifactID: artifact.ID, Driver: "test", Path: path, PackageIdentity: artifact.RecipeKey, ObservedVersion: "1.0.0", Owner: model.LifecycleDelegated, MetadataJSON: "{}", LastSeenAt: now}); err != nil { + t.Fatal(err) + } + } + policy := model.BundlePolicy{BundleID: bundleValue.ID, Mode: model.BundlePolicyManual, RecipeTrusted: true, UpdatedAt: now} + if err := database.ConfigureBundle(context.Background(), policy); err != nil { + t.Fatal(err) + } + current, err := database.GetBundle(context.Background(), bundleValue.ID) + if err != nil { + t.Fatal(err) + } + runner := &transactionRunner{} + service := Service{Database: database, Paths: paths, Runner: runner} + preview, err := service.PrepareUpdate(context.Background(), current.ID, false) + if err != nil { + t.Fatal(err) + } + if _, err := service.ExecuteUpdate(context.Background(), preview); err == nil { + t.Fatal("expected activation failure") + } + runner.mu.Lock() + calls := append([]string(nil), runner.calls...) + runner.mu.Unlock() + wantPrefix := []string{"resolver", "resolver", "stage-one", "stage-two", "activate-one"} + if len(calls) < len(wantPrefix)+4 { + t.Fatalf("calls = %#v", calls) + } + for index, want := range wantPrefix { + if calls[index] != want { + t.Fatalf("calls = %#v", calls) + } + } + if calls[len(calls)-1] != "rollback-one" { + t.Fatalf("calls = %#v", calls) + } + var status string + if err := database.DB().QueryRow(`SELECT status FROM bundle_transactions ORDER BY started_at DESC LIMIT 1`).Scan(&status); err != nil { + t.Fatal(err) + } + if status != string(model.BundleTransactionRolledBack) { + t.Fatalf("transaction status = %s", status) + } +} + +func TestBundleRollbackUsesTargetReleaseAndUpdatesPhysicalObservation(t *testing.T) { + root := t.TempDir() + paths := config.ResolveWith(root, func(key string) string { + if key == config.EnvHome { + return filepath.Join(root, "tooltend") + } + return "" + }) + if err := paths.Ensure(); err != nil { + t.Fatal(err) + } + database, err := store.OpenRW(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + now := time.Now().UTC() + bundleValue := model.Bundle{ID: "bundle", Slug: "bundle", Name: "Bundle", RecipeID: "test", RecipeVersion: "1", RecipeSource: "local", + Owner: model.LifecycleDelegated, ConfigState: model.BundleUnconfigured, Confidence: model.BundleConfidenceHigh, MetadataJSON: "{}", DiscoveredAt: now, LastSeenAt: now} + if err := database.UpsertBundle(ctx, bundleValue); err != nil { + t.Fatal(err) + } + recipe := ArtifactRecipe{Key: "cli", Name: "CLI", Kind: model.ArtifactCLI, Driver: "test", Required: true, + ResolveArgv: []string{"resolver"}, StageArgv: []string{"stage", "${stage}"}, ActivateArgv: []string{"activate", "${version}"}, + RollbackArgv: []string{"rollback", "${version}"}, HealthArgv: []string{"health"}} + metadata, _ := jsonMarshal(recipe) + artifact := model.BundleArtifact{ID: "artifact", BundleID: bundleValue.ID, RecipeKey: "cli", Kind: model.ArtifactCLI, Name: "CLI", Required: true, Driver: "test", MetadataJSON: metadata} + if err := database.UpsertBundleArtifact(ctx, artifact); err != nil { + t.Fatal(err) + } + installation := model.Installation{ID: "installation", BundleID: bundleValue.ID, ArtifactID: artifact.ID, Driver: "test", Path: filepath.Join(root, "cli"), + PackageIdentity: "cli", ObservedVersion: "2.0.0", Owner: model.LifecycleDelegated, MetadataJSON: "{}", LastSeenAt: now} + if err := database.UpsertInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + oldRelease := model.BundleRelease{ID: "release-old", BundleID: bundleValue.ID, Version: "1.0.0", ManifestJSON: `{"artifacts":{"cli":"1.0.0"}}`, Status: "active", CreatedAt: now.Add(-time.Hour)} + currentRelease := model.BundleRelease{ID: "release-current", BundleID: bundleValue.ID, Version: "2.0.0", ManifestJSON: `{"artifacts":{"cli":"2.0.0"}}`, Status: "active", CreatedAt: now} + for _, release := range []model.BundleRelease{oldRelease, currentRelease} { + if err := database.UpsertBundleRelease(ctx, release); err != nil { + t.Fatal(err) + } + } + if err := database.SetBundleCurrentRelease(ctx, bundleValue.ID, currentRelease.ID); err != nil { + t.Fatal(err) + } + if err := database.ConfigureBundle(ctx, model.BundlePolicy{BundleID: bundleValue.ID, Mode: model.BundlePolicyManual, RecipeTrusted: true, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + configured, err := database.GetBundle(ctx, bundleValue.ID) + if err != nil { + t.Fatal(err) + } + runner := &transactionRunner{} + service := Service{Database: database, Paths: paths, Runner: runner} + preview, err := service.PrepareRollback(ctx, configured.ID, oldRelease.ID) + if err != nil { + t.Fatal(err) + } + result, err := service.ExecuteRollback(ctx, preview) + if err != nil { + t.Fatal(err) + } + if result.Release.ID != oldRelease.ID || result.Receipt.Action != "rollback" || result.Receipt.Status != "succeeded" { + t.Fatalf("rollback result = %#v", result) + } + updated, err := database.GetBundle(ctx, bundleValue.ID) + if err != nil { + t.Fatal(err) + } + if updated.CurrentReleaseID != oldRelease.ID { + t.Fatalf("current release = %s", updated.CurrentReleaseID) + } + installations, err := database.ListInstallations(ctx, bundleValue.ID) + if err != nil { + t.Fatal(err) + } + if len(installations) != 1 || installations[0].ObservedVersion != "1.0.0" { + t.Fatalf("installations = %#v", installations) + } +} + +func TestRecoverTransactionsCompensatesAmbiguousActivation(t *testing.T) { + root := t.TempDir() + paths := config.ResolveWith(root, func(key string) string { + if key == config.EnvHome { + return filepath.Join(root, "tooltend") + } + return "" + }) + if err := paths.Ensure(); err != nil { + t.Fatal(err) + } + database, err := store.OpenRW(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + now := time.Now().UTC() + bundleValue := model.Bundle{ID: "bundle", Slug: "bundle", Name: "Bundle", RecipeID: "test", RecipeVersion: "1", RecipeSource: "local", + Owner: model.LifecycleDelegated, ConfigState: model.BundleConfigured, Confidence: model.BundleConfidenceHigh, MetadataJSON: "{}", DiscoveredAt: now, LastSeenAt: now} + if err := database.UpsertBundle(ctx, bundleValue); err != nil { + t.Fatal(err) + } + recipe := ArtifactRecipe{Key: "cli", Name: "CLI", Kind: model.ArtifactCLI, Driver: "test", Required: true, + RollbackArgv: []string{"rollback", "${rollback_version}"}, HealthArgv: []string{"health"}} + metadata, _ := jsonMarshal(recipe) + artifact := model.BundleArtifact{ID: "artifact", BundleID: bundleValue.ID, RecipeKey: "cli", Kind: model.ArtifactCLI, Name: "CLI", Required: true, Driver: "test", MetadataJSON: metadata} + if err := database.UpsertBundleArtifact(ctx, artifact); err != nil { + t.Fatal(err) + } + installation := model.Installation{ID: "installation", BundleID: bundleValue.ID, ArtifactID: artifact.ID, Driver: "test", Path: filepath.Join(root, "cli"), + PackageIdentity: "cli", ObservedVersion: "1.0.0", Owner: model.LifecycleDelegated, MetadataJSON: "{}", LastSeenAt: now} + if err := database.UpsertInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + target := model.BundleRelease{ID: "release-target", BundleID: bundleValue.ID, Version: "2.0.0", ManifestJSON: `{"artifacts":{"cli":"2.0.0"}}`, Status: "resolved", CreatedAt: now} + if err := database.UpsertBundleRelease(ctx, target); err != nil { + t.Fatal(err) + } + transaction := model.BundleTransaction{ID: "transaction", BundleID: bundleValue.ID, ToReleaseID: target.ID, Status: model.BundleTransactionActivating, StartedAt: now, UpdatedAt: now} + if err := database.PutBundleTransaction(ctx, transaction); err != nil { + t.Fatal(err) + } + step := model.BundleTransactionStep{ID: "step", TransactionID: transaction.ID, ArtifactID: artifact.ID, InstallationID: installation.ID, + Kind: "cli", Status: model.BundleStepActivating, CommandJSON: "{}", RollbackJSON: "{}", BeforeJSON: "{}", AfterJSON: "{}"} + if err := database.PutBundleTransactionStep(ctx, step); err != nil { + t.Fatal(err) + } + stagePath := filepath.Join(paths.StagingDir, transaction.ID) + if err := os.MkdirAll(stagePath, 0o700); err != nil { + t.Fatal(err) + } + runner := &transactionRunner{} + service := Service{Database: database, Paths: paths, Runner: runner} + result, err := service.RecoverTransactions(ctx) + if err != nil { + t.Fatal(err) + } + if result.CompensatedUpdates != 1 || result.Total() != 1 { + t.Fatalf("recovery result = %+v", result) + } + var status string + if err := database.DB().QueryRow(`SELECT status FROM bundle_transactions WHERE id=?`, transaction.ID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != string(model.BundleTransactionRolledBack) { + t.Fatalf("transaction status = %s", status) + } + if _, err := os.Stat(stagePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("recovery staging path still exists: %v", err) + } + runner.mu.Lock() + calls := append([]string(nil), runner.calls...) + runner.mu.Unlock() + if len(calls) != 1 || calls[0] != "rollback" { + t.Fatalf("recovery calls = %#v", calls) + } +} + +func jsonMarshal(value any) (string, error) { + data, err := json.Marshal(value) + return string(data), err +} diff --git a/internal/cli/app.go b/internal/cli/app.go index 163a9d0..88f1567 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -59,15 +59,16 @@ type App struct { runner execx.Runner global globalOptions selfApply selfupdate.ApplyResult + warnings []v1.Warning } -// New builds the complete ToolTend V1 command tree. Constructing it is +// New builds the complete ToolTend v0.2 command tree. Constructing it is // side-effect free: it does not create configuration, state, or data paths. func New(options Options) *cobra.Command { a := newApp(options) root := &cobra.Command{ Use: "tooltend", - Short: "Lifecycle manager for coding-agent extensions", + Short: "Bundle lifecycle manager for coding-agent tooling", SilenceErrors: true, SilenceUsage: true, Args: func(cmd *cobra.Command, args []string) error { @@ -90,6 +91,10 @@ func New(options Options) *cobra.Command { flags.StringVar(&a.global.StateDir, "state-dir", "", "use an alternate state directory") flags.BoolVar(&a.global.NoColor, "no-color", false, "disable colored human output") root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + a.warnings = nil + if legacyCommand(commandName(cmd)) { + a.warnings = append(a.warnings, v1.Warning{Code: "deprecated_component_api", Message: "this component-level command is deprecated; use tooltend bundles instead"}) + } if a.global.DryRun { return nil } @@ -108,6 +113,9 @@ func New(options Options) *cobra.Command { } return a.writeFailure(commandName(cmd), err) } + if a.selfApply.Applied { + a.warnings = append(a.warnings, a.repairAfterSelfUpdate(cmd.Context(), paths)...) + } return nil } @@ -115,6 +123,7 @@ func New(options Options) *cobra.Command { a.newInitCommand(), a.newScanCommand(), a.newStatusCommand(), + a.newBundlesCommand(), a.newComponentsCommand(), a.newPolicyCommand(), a.newUpdateCommand(), @@ -295,7 +304,7 @@ func (a *App) run(command string, action func(context.Context) (any, error)) fun func (a *App) writeSuccess(command string, data any) error { if a.global.JSON { - var warnings []v1.Warning + warnings := append([]v1.Warning(nil), a.warnings...) if a.selfApply.Applied { warnings = append(warnings, v1.Warning{ Code: "self_update_applied", Message: "a previously confirmed ToolTend self-update was applied before this command", @@ -304,12 +313,27 @@ func (a *App) writeSuccess(command string, data any) error { } return v1.Write(a.out, v1.Success(command, data, warnings...)) } + for _, warning := range a.warnings { + _, _ = fmt.Fprintf(a.errOut, "Warning: %s\n", warning.Message) + } if a.selfApply.Applied { _, _ = fmt.Fprintf(a.out, "ToolTend self-update %s was applied before this command.\n", a.selfApply.Version) } return writeHuman(a.out, data) } +func legacyCommand(name string) bool { + if strings.HasPrefix(name, "components ") || strings.HasPrefix(name, "policy ") { + return true + } + switch name { + case "update", "adopt", "rollback", "history", "review": + return true + default: + return false + } +} + func (a *App) writeFailure(command string, err error) error { value := classifyError(err) if a.global.JSON { diff --git a/internal/cli/bundle_commands.go b/internal/cli/bundle_commands.go new file mode 100644 index 0000000..2f40971 --- /dev/null +++ b/internal/cli/bundle_commands.go @@ -0,0 +1,742 @@ +package cli + +import ( + "bufio" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/z2z23n0/tooltend/internal/bundle" + "github.com/z2z23n0/tooltend/internal/model" + "github.com/z2z23n0/tooltend/internal/plan" + "github.com/z2z23n0/tooltend/internal/store" +) + +type bundleSummary struct { + Bundle model.Bundle `json:"bundle"` + Policy *model.BundlePolicy `json:"policy,omitempty"` + Artifacts int `json:"artifacts"` + Installations int `json:"installations"` + Consumers int `json:"consumer_bindings"` + CurrentRelease *model.BundleRelease `json:"current_release,omitempty"` + Status string `json:"status"` +} + +type bundleDetail struct { + Bundle model.Bundle `json:"bundle"` + Policy *model.BundlePolicy `json:"policy,omitempty"` + CurrentRelease *model.BundleRelease `json:"current_release,omitempty"` + Artifacts []bundleArtifactDetail `json:"artifacts"` + History []model.BundleReceipt `json:"history,omitempty"` + Health []model.BundleHealthCheck `json:"health,omitempty"` +} + +type bundleArtifactDetail struct { + Artifact model.BundleArtifact `json:"artifact"` + Installations []bundleInstallationDetail `json:"installations"` +} + +type bundleInstallationDetail struct { + Installation model.Installation `json:"installation"` + Consumers []model.ConsumerBinding `json:"consumers"` +} + +func (a *App) newBundlesCommand() *cobra.Command { + parent := &cobra.Command{Use: "bundles", Short: "Manage complete tool bundles", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} + parent.AddCommand( + a.newBundlesListCommand(), + a.newBundlesShowCommand(), + a.newBundlesConfigureCommand(), + a.newBundlesUpdateCommand(), + a.newBundlesRollbackCommand(), + a.newBundlesHistoryCommand(), + a.newBundlesDoctorCommand(), + ) + return parent +} + +func (a *App) newBundlesListCommand() *cobra.Command { + var all bool + command := &cobra.Command{Use: "list", Short: "List discovered bundles", Args: cobra.NoArgs} + command.Flags().BoolVar(&all, "all", false, "include unresolved and host-owned fallback bundles") + command.RunE = a.run("bundles list", func(ctx context.Context) (any, error) { + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + defer database.Close() + bundles, err := database.ListBundles(ctx) + if err != nil { + return nil, err + } + result := []bundleSummary{} + for _, value := range bundles { + if !all && value.RecipeSource == "fallback" && (value.Owner == model.LifecycleHostOwned || value.Owner == model.LifecycleUnresolved) { + continue + } + item, err := loadBundleSummary(ctx, database, value) + if err != nil { + return nil, err + } + result = append(result, item) + } + return result, nil + }) + return command +} + +func (a *App) newBundlesShowCommand() *cobra.Command { + command := &cobra.Command{Use: "show ", Short: "Show a bundle, artifacts, and physical installations", Args: cobra.ExactArgs(1)} + command.RunE = func(cmd *cobra.Command, args []string) error { + return a.run("bundles show", func(ctx context.Context) (any, error) { + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + defer database.Close() + value, err := resolveBundle(ctx, database, args[0]) + if err != nil { + return nil, err + } + return loadBundleDetail(ctx, database, value) + })(cmd, args) + } + return command +} + +func (a *App) newBundlesConfigureCommand() *cobra.Command { + var settings, trusted []string + command := &cobra.Command{Use: "configure", Short: "Choose lifecycle policy for discovered bundles", Args: cobra.NoArgs} + command.Flags().StringSliceVar(&settings, "set", nil, "set =auto|manual|observe|ignore (repeatable)") + command.Flags().StringSliceVar(&trusted, "trust-local", nil, "explicitly trust a local recipe (repeatable)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + return a.run("bundles configure", func(ctx context.Context) (any, error) { + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + bundles, err := database.ListBundles(ctx) + if err != nil { + database.Close() + return nil, err + } + bySelector := map[string]model.Bundle{} + for _, value := range bundles { + bySelector[value.ID], bySelector[strings.ToLower(value.Slug)], bySelector[strings.ToLower(value.Name)] = value, value, value + } + if len(settings) == 0 { + if a.global.JSON { + database.Close() + return nil, cliError("invalid_argument", "JSON mode requires at least one --set", nil) + } + settings, err = a.interactiveBundleSettings(ctx, database, bundles) + if err != nil { + database.Close() + return nil, err + } + } + trustSet := map[string]struct{}{} + for _, selector := range trusted { + value, ok := bySelector[strings.ToLower(strings.TrimSpace(selector))] + if !ok { + database.Close() + return nil, cliError("not_found", fmt.Sprintf("local recipe bundle %q was not found", selector), nil) + } + trustSet[value.ID] = struct{}{} + } + type desiredPolicy struct { + bundle model.Bundle + policy model.BundlePolicy + } + var desired []desiredPolicy + seen := map[string]struct{}{} + for _, setting := range settings { + selector, modeText, ok := strings.Cut(setting, "=") + if !ok || strings.TrimSpace(selector) == "" || strings.TrimSpace(modeText) == "" { + database.Close() + return nil, cliError("invalid_argument", fmt.Sprintf("invalid --set %q; expected =", setting), nil) + } + value, ok := bySelector[strings.ToLower(strings.TrimSpace(selector))] + if !ok { + database.Close() + return nil, cliError("not_found", fmt.Sprintf("bundle %q was not found", selector), nil) + } + if _, duplicate := seen[value.ID]; duplicate { + database.Close() + return nil, cliError("invalid_argument", fmt.Sprintf("bundle %q was configured more than once", selector), nil) + } + seen[value.ID] = struct{}{} + mode := model.BundlePolicyMode(strings.ToLower(strings.TrimSpace(modeText))) + if err := mode.Validate(); err != nil { + database.Close() + return nil, cliError("invalid_argument", err.Error(), err) + } + if err := validateBundleMode(ctx, database, value, mode); err != nil { + database.Close() + return nil, cliError("unsafe_policy", err.Error(), err) + } + _, trustRequested := trustSet[value.ID] + existing, getErr := database.GetBundlePolicy(ctx, value.ID) + alreadyTrusted := getErr == nil && existing.RecipeTrusted + if value.RecipeSource == "local" && mode != model.BundlePolicyIgnore && !trustRequested && !alreadyTrusted { + database.Close() + return nil, cliError("recipe_trust_required", fmt.Sprintf("local recipe for %s requires --trust-local %s", value.Name, value.Slug), nil) + } + desired = append(desired, desiredPolicy{bundle: value, policy: model.BundlePolicy{ + BundleID: value.ID, Mode: mode, RecipeTrusted: trustRequested || alreadyTrusted || value.RecipeSource != "local", UpdatedAt: time.Now().UTC(), + }}) + } + database.Close() + value := plan.Plan{ID: "bundle-configure-v1", Title: "Configure ToolTend bundle lifecycle policies"} + for _, item := range desired { + item := item + value.Operations = append(value.Operations, plan.FuncOperation{ + Description: plan.OperationPreview{ID: "configure-" + item.bundle.ID, Kind: plan.OperationDatabase, Target: item.bundle.Slug, + Summary: "Set the bundle lifecycle policy without installing or updating it", RequiresConfirmation: true, + Details: map[string]string{"mode": string(item.policy.Mode), "owner": string(item.bundle.Owner), "recipe_source": item.bundle.RecipeSource}}, + ApplyFunc: func(ctx context.Context) error { + return withLifecycleStateLock(ctx, paths, func(db *store.Store) error { return db.ConfigureBundle(ctx, item.policy) }) + }, + }) + } + return a.applyPlan(ctx, value, func() any { + result := make([]model.BundlePolicy, 0, len(desired)) + for _, item := range desired { + result = append(result, item.policy) + } + return result + }) + })(cmd, nil) + } + return command +} + +func (a *App) newBundlesUpdateCommand() *cobra.Command { + var all, stageOnly bool + command := &cobra.Command{Use: "update [bundle]", Short: "Resolve and transactionally update a configured bundle", Args: cobra.MaximumNArgs(1)} + command.Flags().BoolVar(&all, "all", false, "update all configured auto/manual bundles") + command.Flags().BoolVar(&stageOnly, "stage-only", false, "stage every artifact without activation") + command.RunE = func(cmd *cobra.Command, args []string) error { + return a.run("bundles update", func(ctx context.Context) (any, error) { + if all == (len(args) == 1) { + return nil, cliError("invalid_argument", "provide one bundle or --all", nil) + } + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + var targets []model.Bundle + if all { + values, listErr := database.ListBundles(ctx) + if listErr != nil { + database.Close() + return nil, listErr + } + for _, value := range values { + policy, policyErr := database.GetBundlePolicy(ctx, value.ID) + if policyErr == nil && (policy.Mode == model.BundlePolicyAuto || policy.Mode == model.BundlePolicyManual) { + targets = append(targets, value) + } + } + } else { + value, resolveErr := resolveBundle(ctx, database, args[0]) + if resolveErr != nil { + database.Close() + return nil, resolveErr + } + targets = append(targets, value) + } + database.Close() + if len(targets) == 0 { + return nil, cliError("not_configured", "no configured bundle can be updated", nil) + } + var previews []bundle.UpdatePreview + for _, target := range targets { + db, openErr := store.OpenRW(paths.DatabaseFile) + if openErr != nil { + return nil, openErr + } + service := bundle.Service{Database: db, Paths: paths, Runner: a.runner} + preview, prepareErr := service.PrepareUpdate(ctx, target.ID, stageOnly) + _ = db.Close() + if prepareErr != nil { + return nil, prepareErr + } + previews = append(previews, preview) + } + var results []bundle.UpdateResult + value := plan.Plan{ID: "bundle-update-v1", Title: "Update complete ToolTend bundles"} + for _, preview := range previews { + preview := preview + value.Operations = append(value.Operations, plan.FuncOperation{ + Description: plan.OperationPreview{ID: "update-" + preview.Bundle.ID, Kind: plan.OperationActivate, Target: preview.Bundle.Slug, + Summary: "Stage every artifact, activate once, validate health, and compensate in reverse on failure", Reversible: false, RequiresConfirmation: true, + Details: map[string]string{"current_release": releaseID(preview.Current), "target_release": preview.Target.Version, "stage_only": fmt.Sprint(stageOnly)}}, + ApplyFunc: func(ctx context.Context) error { + return withLifecycleStateLock(ctx, paths, func(db *store.Store) error { + service := bundle.Service{Database: db, Paths: paths, Runner: a.runner} + result, executeErr := service.ExecuteUpdate(ctx, preview) + if executeErr == nil { + results = append(results, result) + } + return executeErr + }) + }, + }) + } + return a.applyPlan(ctx, value, func() any { return results }) + })(cmd, args) + } + return command +} + +func (a *App) newBundlesRollbackCommand() *cobra.Command { + var target string + command := &cobra.Command{Use: "rollback ", Short: "Roll back a bundle to a prior release receipt", Args: cobra.ExactArgs(1)} + command.Flags().StringVar(&target, "to", "", "release or receipt ID; defaults to the previous successful receipt") + command.RunE = func(cmd *cobra.Command, args []string) error { + return a.run("bundles rollback", func(ctx context.Context) (any, error) { + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + value, err := resolveBundle(ctx, database, args[0]) + if err != nil { + database.Close() + return nil, err + } + receipts, err := database.ListBundleReceipts(ctx, value.ID, 100) + if err != nil { + database.Close() + return nil, err + } + selected := selectRollbackReceipt(receipts, target, value.CurrentReleaseID) + database.Close() + if selected.ID == "" { + return nil, cliError("not_found", "no matching prior bundle receipt was found", nil) + } + writeDatabase, err := store.OpenRW(paths.DatabaseFile) + if err != nil { + return nil, err + } + service := bundle.Service{Database: writeDatabase, Paths: paths, Runner: a.runner} + preview, err := service.PrepareRollback(ctx, value.ID, selected.ReleaseID) + _ = writeDatabase.Close() + if err != nil { + return nil, err + } + var result bundle.RollbackResult + valuePlan := plan.Plan{ID: "bundle-rollback-v1", Title: "Roll back a complete ToolTend bundle"} + valuePlan.Operations = append(valuePlan.Operations, plan.FuncOperation{ + Description: plan.OperationPreview{ID: "rollback-" + value.ID, Kind: plan.OperationActivate, Target: value.Slug, + Summary: "Roll back every installation, validate health, and restore the current release on failure", + RequiresConfirmation: true, Details: map[string]string{"from_release": preview.From.Version, "to_release": preview.To.Version, "steps": fmt.Sprint(preview.Steps)}}, + ApplyFunc: func(ctx context.Context) error { + return withLifecycleStateLock(ctx, paths, func(db *store.Store) error { + service := bundle.Service{Database: db, Paths: paths, Runner: a.runner} + applied, executeErr := service.ExecuteRollback(ctx, preview) + if executeErr == nil { + result = applied + } + return executeErr + }) + }, + }) + return a.applyPlan(ctx, valuePlan, func() any { return result }) + })(cmd, args) + } + return command +} + +func (a *App) newBundlesHistoryCommand() *cobra.Command { + var limit int + command := &cobra.Command{Use: "history [bundle]", Short: "Show bundle-level update and rollback receipts", Args: cobra.MaximumNArgs(1)} + command.Flags().IntVar(&limit, "limit", 100, "maximum receipts") + command.RunE = func(cmd *cobra.Command, args []string) error { + return a.run("bundles history", func(ctx context.Context) (any, error) { + if limit <= 0 { + return nil, cliError("invalid_argument", "limit must be positive", nil) + } + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + defer database.Close() + bundleID := "" + if len(args) == 1 { + value, resolveErr := resolveBundle(ctx, database, args[0]) + if resolveErr != nil { + return nil, resolveErr + } + bundleID = value.ID + } + return database.ListBundleReceipts(ctx, bundleID, limit) + })(cmd, args) + } + return command +} + +type bundleDoctorReport struct { + Healthy bool `json:"healthy"` + Bundle *model.Bundle `json:"bundle,omitempty"` + Checks []bundleDoctorCheck `json:"checks"` +} + +type bundleDoctorCheck struct { + Name string `json:"name"` + Level string `json:"level"` + Message string `json:"message"` +} + +func (a *App) newBundlesDoctorCommand() *cobra.Command { + command := &cobra.Command{Use: "doctor [bundle]", Short: "Validate bundle coverage, ownership, and transaction health", Args: cobra.MaximumNArgs(1)} + command.RunE = func(cmd *cobra.Command, args []string) error { + return a.run("bundles doctor", func(ctx context.Context) (any, error) { + paths, err := a.paths() + if err != nil { + return nil, err + } + database, err := a.openReadOnly(paths) + if err != nil { + return nil, err + } + defer database.Close() + report := bundleDoctorReport{Healthy: true} + if len(args) == 0 { + counts, countErr := database.BundleCounts(ctx) + if countErr != nil { + return nil, countErr + } + level, message := "ok", fmt.Sprintf("%d bundles discovered; %d configured", counts.Total, counts.Configured) + if counts.Configured == 0 { + level, message = "warning", "infrastructure is healthy but bundle management coverage is zero; run tooltend bundles configure" + } + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "coverage", Level: level, Message: message}) + if counts.FailedTransactions > 0 { + report.Healthy = false + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "transactions", Level: "error", Message: fmt.Sprintf("%d bundle transactions failed", counts.FailedTransactions)}) + } else { + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "transactions", Level: "ok", Message: "no failed bundle transactions"}) + } + return report, nil + } + value, err := resolveBundle(ctx, database, args[0]) + if err != nil { + return nil, err + } + report.Bundle = &value + installations, err := database.ListInstallations(ctx, value.ID) + if err != nil { + return nil, err + } + if len(installations) == 0 { + report.Healthy = false + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "installations", Level: "error", Message: "bundle has no physical installations"}) + } else { + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "installations", Level: "ok", Message: fmt.Sprintf("%d physical installations", len(installations))}) + } + if value.ConfigState == model.BundleUnconfigured { + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "policy", Level: "warning", Message: "bundle is unconfigured and will not be checked or updated"}) + } else { + report.Checks = append(report.Checks, bundleDoctorCheck{Name: "policy", Level: "ok", Message: "bundle is configured"}) + } + return report, nil + })(cmd, args) + } + return command +} + +func loadBundleSummary(ctx context.Context, database *store.Store, value model.Bundle) (bundleSummary, error) { + result := bundleSummary{Bundle: value, Status: "unconfigured"} + policy, err := database.GetBundlePolicy(ctx, value.ID) + if err == nil { + result.Policy, result.Status = &policy, string(policy.Mode) + } else if !errors.Is(err, sql.ErrNoRows) { + return result, err + } + artifacts, err := database.ListBundleArtifacts(ctx, value.ID) + if err != nil { + return result, err + } + result.Artifacts = len(artifacts) + installations, err := database.ListInstallations(ctx, value.ID) + if err != nil { + return result, err + } + result.Installations = len(installations) + for _, installation := range installations { + consumers, listErr := database.ListConsumerBindings(ctx, installation.ID) + if listErr != nil { + return result, listErr + } + result.Consumers += len(consumers) + } + if value.CurrentReleaseID != "" { + release, releaseErr := database.GetBundleRelease(ctx, value.CurrentReleaseID) + if releaseErr == nil { + result.CurrentRelease = &release + } else if !errors.Is(releaseErr, sql.ErrNoRows) { + return result, releaseErr + } + } + return result, nil +} + +func loadBundleDetail(ctx context.Context, database *store.Store, value model.Bundle) (bundleDetail, error) { + result := bundleDetail{Bundle: value} + policy, err := database.GetBundlePolicy(ctx, value.ID) + if err == nil { + result.Policy = &policy + } else if !errors.Is(err, sql.ErrNoRows) { + return result, err + } + if value.CurrentReleaseID != "" { + release, releaseErr := database.GetBundleRelease(ctx, value.CurrentReleaseID) + if releaseErr == nil { + result.CurrentRelease = &release + } else if !errors.Is(releaseErr, sql.ErrNoRows) { + return result, releaseErr + } + } + artifacts, err := database.ListBundleArtifacts(ctx, value.ID) + if err != nil { + return result, err + } + installations, err := database.ListInstallations(ctx, value.ID) + if err != nil { + return result, err + } + byArtifact := map[string][]model.Installation{} + for _, installation := range installations { + byArtifact[installation.ArtifactID] = append(byArtifact[installation.ArtifactID], installation) + } + for _, artifact := range artifacts { + item := bundleArtifactDetail{Artifact: artifact} + for _, installation := range byArtifact[artifact.ID] { + consumers, listErr := database.ListConsumerBindings(ctx, installation.ID) + if listErr != nil { + return result, listErr + } + item.Installations = append(item.Installations, bundleInstallationDetail{Installation: installation, Consumers: consumers}) + } + result.Artifacts = append(result.Artifacts, item) + } + result.History, err = database.ListBundleReceipts(ctx, value.ID, 20) + if err != nil { + return result, err + } + result.Health, err = database.ListBundleHealthChecks(ctx, value.ID, 20) + return result, err +} + +func resolveBundle(ctx context.Context, database *store.Store, selector string) (model.Bundle, error) { + if value, err := database.GetBundle(ctx, selector); err == nil { + return value, nil + } + if value, err := database.GetBundleBySlug(ctx, selector); err == nil { + return value, nil + } + values, err := database.ListBundles(ctx) + if err != nil { + return model.Bundle{}, err + } + var matches []model.Bundle + for _, value := range values { + if strings.EqualFold(value.Name, selector) { + matches = append(matches, value) + } + } + if len(matches) == 0 { + return model.Bundle{}, sql.ErrNoRows + } + if len(matches) > 1 { + return model.Bundle{}, cliError("ambiguous_selector", fmt.Sprintf("bundle selector %q is ambiguous", selector), nil) + } + return matches[0], nil +} + +func validateBundleMode(ctx context.Context, database *store.Store, value model.Bundle, mode model.BundlePolicyMode) error { + if mode == model.BundlePolicyIgnore { + return nil + } + switch value.Owner { + case model.LifecycleToolTend, model.LifecycleDelegated: + case model.LifecycleHostOwned, model.LifecycleAppOwned, model.LifecycleWorkspaceLinked, model.LifecycleUnresolved: + if mode != model.BundlePolicyObserve { + return fmt.Errorf("bundle owner %s only supports observe or ignore", value.Owner) + } + } + if mode == model.BundlePolicyAuto { + eligible, err := bundleAutoEligible(ctx, database, value.ID) + if err != nil { + return err + } + if !eligible { + return errors.New("bundle recipe is not eligible for auto: every installed artifact needs resolve, stage, activate, rollback, and health commands") + } + } + if mode == model.BundlePolicyManual { + checkable, err := bundleCheckable(ctx, database, value.ID) + if err != nil { + return err + } + if !checkable { + return errors.New("bundle recipe cannot perform a manual update: installed artifacts need resolve, stage, activate, and health commands") + } + } + return nil +} + +func (a *App) interactiveBundleSettings(ctx context.Context, database *store.Store, values []model.Bundle) ([]string, error) { + sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) + scanner := bufio.NewScanner(a.in) + result := []string{} + for _, value := range values { + if value.RecipeSource == "fallback" && value.Owner == model.LifecycleHostOwned { + continue + } + recommended := "observe" + if value.Owner == model.LifecycleToolTend || value.Owner == model.LifecycleDelegated { + if eligible, _ := bundleAutoEligible(ctx, database, value.ID); eligible { + recommended = "auto" + } else if checkable, _ := bundleCheckable(ctx, database, value.ID); checkable { + recommended = "manual" + } + } + _, _ = fmt.Fprintf(a.out, "%s (%s) [auto/manual/observe/ignore, Enter to skip; recommended %s]: ", value.Name, value.Owner, recommended) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, err + } + return result, nil + } + answer := strings.ToLower(strings.TrimSpace(scanner.Text())) + if answer == "" { + continue + } + result = append(result, value.Slug+"="+answer) + } + return result, nil +} + +func bundleAutoEligible(ctx context.Context, database *store.Store, bundleID string) (bool, error) { + artifacts, err := database.ListBundleArtifacts(ctx, bundleID) + if err != nil { + return false, err + } + installations, err := database.ListInstallations(ctx, bundleID) + if err != nil { + return false, err + } + installed := make(map[string]bool) + for _, installation := range installations { + installed[installation.ArtifactID] = true + } + if len(installed) == 0 { + return false, nil + } + for _, artifact := range artifacts { + if !installed[artifact.ID] { + continue + } + var recipe bundle.ArtifactRecipe + if err := json.Unmarshal([]byte(artifact.MetadataJSON), &recipe); err != nil { + return false, err + } + if len(recipe.ResolveArgv) == 0 || len(recipe.StageArgv) == 0 || len(recipe.ActivateArgv) == 0 || len(recipe.RollbackArgv) == 0 || len(recipe.HealthArgv) == 0 { + return false, nil + } + } + return true, nil +} + +func bundleCheckable(ctx context.Context, database *store.Store, bundleID string) (bool, error) { + artifacts, err := database.ListBundleArtifacts(ctx, bundleID) + if err != nil { + return false, err + } + installations, err := database.ListInstallations(ctx, bundleID) + if err != nil { + return false, err + } + installed := make(map[string]bool) + for _, installation := range installations { + installed[installation.ArtifactID] = true + } + if len(installed) == 0 { + return false, nil + } + for _, artifact := range artifacts { + if !installed[artifact.ID] { + continue + } + var recipe bundle.ArtifactRecipe + if err := json.Unmarshal([]byte(artifact.MetadataJSON), &recipe); err != nil { + return false, err + } + if len(recipe.ResolveArgv) == 0 || len(recipe.StageArgv) == 0 || len(recipe.ActivateArgv) == 0 || len(recipe.HealthArgv) == 0 { + return false, nil + } + } + return true, nil +} + +func releaseID(value *model.BundleRelease) string { + if value == nil { + return "" + } + return value.Version +} + +func selectRollbackReceipt(values []model.BundleReceipt, selector, currentReleaseID string) model.BundleReceipt { + if selector != "" { + for _, value := range values { + if (value.ID == selector || value.ReleaseID == selector) && value.ReleaseID != currentReleaseID && value.Status == "succeeded" { + return value + } + } + return model.BundleReceipt{} + } + for _, value := range values { + if value.Status == "succeeded" && value.ReleaseID != "" && value.ReleaseID != currentReleaseID { + return value + } + } + return model.BundleReceipt{} +} + +func encodeJSON(value any) string { + data, _ := json.Marshal(value) + return string(data) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 1e06c8c..ca96eee 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -29,6 +29,7 @@ func TestCommandTreeContainsCompleteV1Surface(t *testing.T) { command := New(testOptions(t, &bytes.Buffer{}, &bytes.Buffer{}, strings.NewReader(""))) for _, path := range []string{ "init", "scan", "status", "components list", "components show", "policy set", + "bundles list", "bundles show", "bundles configure", "bundles update", "bundles rollback", "bundles history", "bundles doctor", "update", "review", "history", "rollback", "adopt", "project init", "project export", "project sync", "self status", "self update", "doctor", "hook", "kick", "reconcile", "version", } { @@ -43,6 +44,211 @@ func TestCommandTreeContainsCompleteV1Surface(t *testing.T) { } } +func TestInitDiscoversUnconfiguredBundlesWithoutSchedulingLegacyTasks(t *testing.T) { + var out, stderr bytes.Buffer + options := testOptions(t, &out, &stderr, strings.NewReader("")) + options.Runner = &successfulRunner{} + command := New(options) + command.SetArgs([]string{"init", "--yes", "--json"}) + if err := command.Execute(); err != nil { + t.Fatalf("init: %v\nstdout=%s\nstderr=%s", err, out.String(), stderr.String()) + } + paths := config.ResolveWith(options.HomeDir, options.Getenv) + database, err := store.OpenReadOnly(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + defer database.Close() + counts, err := database.BundleCounts(context.Background()) + if err != nil { + t.Fatal(err) + } + if counts.Total == 0 || counts.Configured != 0 || counts.Managed != 0 || counts.Unconfigured != counts.Total { + t.Fatalf("bundle counts = %+v", counts) + } + legacy, err := database.CountTasks(context.Background()) + if err != nil { + t.Fatal(err) + } + if legacy.Pending+legacy.Running != 0 { + t.Fatalf("legacy tasks were scheduled: %+v", legacy) + } + var bundleTasks int + if err := database.DB().QueryRow(`SELECT COUNT(*) FROM bundle_tasks`).Scan(&bundleTasks); err != nil || bundleTasks != 0 { + t.Fatalf("bundle tasks=%d err=%v", bundleTasks, err) + } +} + +func TestResetStateDryRunIsReadOnlyAndConfirmedResetBacksUp(t *testing.T) { + var out, stderr bytes.Buffer + options := testOptions(t, &out, &stderr, strings.NewReader("")) + options.Runner = &successfulRunner{} + command := New(options) + command.SetArgs([]string{"init", "--yes", "--json"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + paths := config.ResolveWith(options.HomeDir, options.Getenv) + marker := filepath.Join(paths.ObjectsDir, "keep-until-confirmed") + if err := os.WriteFile(marker, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + backupParent := filepath.Join(filepath.Dir(paths.StateDir), "tooltend-backups") + out.Reset() + command = New(options) + command.SetArgs([]string{"init", "--reset-state", "--dry-run", "--json"}) + if err := command.Execute(); err != nil { + t.Fatalf("reset dry-run: %v\n%s", err, out.String()) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("dry-run changed old state: %v", err) + } + if _, err := os.Stat(backupParent); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("dry-run created backup directory: %v", err) + } + out.Reset() + command = New(options) + command.SetArgs([]string{"init", "--reset-state", "--yes", "--json"}) + if err := command.Execute(); err != nil { + t.Fatalf("reset: %v\nstdout=%s\nstderr=%s", err, out.String(), stderr.String()) + } + if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("confirmed reset retained old marker: %v", err) + } + backups, err := filepath.Glob(filepath.Join(backupParent, "*", "manifest.json")) + if err != nil || len(backups) != 1 { + t.Fatalf("backups=%v err=%v", backups, err) + } + database, err := store.OpenReadOnly(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + defer database.Close() + counts, err := database.BundleCounts(context.Background()) + if err != nil || counts.Total == 0 || counts.Configured != 0 || counts.Managed != 0 { + t.Fatalf("post-reset counts=%+v err=%v", counts, err) + } +} + +func TestResetStateRestoresOldStateWhenSchedulerReactivationFails(t *testing.T) { + var out, stderr bytes.Buffer + runner := &successfulRunner{} + options := testOptions(t, &out, &stderr, strings.NewReader("")) + options.Runner = runner + command := New(options) + command.SetArgs([]string{"init", "--yes", "--json"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + paths := config.ResolveWith(options.HomeDir, options.Getenv) + marker := filepath.Join(paths.ObjectsDir, "restore-me") + if err := os.WriteFile(marker, []byte("old-state"), 0o600); err != nil { + t.Fatal(err) + } + configHash := fileHashOrEmpty(paths.ConfigFile) + databaseHash := fileHashOrEmpty(paths.DatabaseFile) + runner.failAt = runner.calls + 3 // deactivate and best-effort bootout succeed; final registration fails once + out.Reset() + command = New(options) + command.SetArgs([]string{"init", "--reset-state", "--yes", "--json"}) + if err := command.Execute(); err == nil || !IsReported(err) { + t.Fatalf("reset unexpectedly succeeded: %v", err) + } + data, err := os.ReadFile(marker) + if err != nil || string(data) != "old-state" { + t.Fatalf("old data was not restored: data=%q err=%v", data, err) + } + if fileHashOrEmpty(paths.ConfigFile) != configHash || fileHashOrEmpty(paths.DatabaseFile) != databaseHash { + t.Fatal("old configuration or database was not restored byte-for-byte") + } +} + +func TestResetStateRefusesConfiguredManagedBundle(t *testing.T) { + var out, stderr bytes.Buffer + options := testOptions(t, &out, &stderr, strings.NewReader("")) + options.Runner = &successfulRunner{} + command := New(options) + command.SetArgs([]string{"init", "--yes", "--json"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + paths := config.ResolveWith(options.HomeDir, options.Getenv) + database, err := store.OpenRW(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + bundles, err := database.ListBundles(context.Background()) + if err != nil || len(bundles) == 0 { + t.Fatalf("bundles=%v err=%v", bundles, err) + } + if err := database.ConfigureBundle(context.Background(), model.BundlePolicy{BundleID: bundles[0].ID, Mode: model.BundlePolicyManual, RecipeTrusted: true, UpdatedAt: time.Now().UTC()}); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + out.Reset() + command = New(options) + command.SetArgs([]string{"init", "--reset-state", "--dry-run", "--json"}) + err = command.Execute() + if err == nil || !IsReported(err) { + t.Fatalf("configured bundle reset unexpectedly succeeded: %v", err) + } + var envelope v1.Envelope + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Error == nil || envelope.Error.Code != "reset_refused" { + t.Fatalf("unexpected reset error: %+v", envelope.Error) + } +} + +func TestBundleConfigureLeavesSkippedBundlesUnconfiguredAndRejectsUnsafeAuto(t *testing.T) { + var out, stderr bytes.Buffer + options := testOptions(t, &out, &stderr, strings.NewReader("\n")) + options.Runner = &successfulRunner{} + command := New(options) + command.SetArgs([]string{"init", "--yes", "--json"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + out.Reset() + command = New(options) + command.SetArgs([]string{"bundles", "configure", "--yes"}) + if err := command.Execute(); err != nil { + t.Fatalf("skip configure: %v", err) + } + paths := config.ResolveWith(options.HomeDir, options.Getenv) + database, err := store.OpenReadOnly(paths.DatabaseFile) + if err != nil { + t.Fatal(err) + } + counts, err := database.BundleCounts(context.Background()) + if err != nil { + t.Fatal(err) + } + if counts.Configured != 0 || counts.Managed != 0 { + t.Fatalf("skipped bundles were configured: %+v", counts) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + out.Reset() + command = New(options) + command.SetArgs([]string{"bundles", "configure", "--set", "tooltend=auto", "--yes", "--json"}) + err = command.Execute() + if err == nil || !IsReported(err) { + t.Fatalf("unsafe auto configuration unexpectedly succeeded: %v", err) + } + var envelope v1.Envelope + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Error == nil || envelope.Error.Code != "unsafe_policy" { + t.Fatalf("unexpected configure error: %+v", envelope.Error) + } +} + func TestComponentsListSeparatesActionableManagedAndCompleteInventory(t *testing.T) { var seedOut, seedErr bytes.Buffer options := testOptions(t, &seedOut, &seedErr, strings.NewReader("")) @@ -111,10 +317,16 @@ func TestComponentsListSeparatesActionableManagedAndCompleteInventory(t *testing } } -type successfulRunner struct{ calls int } +type successfulRunner struct { + calls int + failAt int +} func (r *successfulRunner) Run(context.Context, string, ...string) (execx.Result, error) { r.calls++ + if r.failAt > 0 && r.calls == r.failAt { + return execx.Result{}, errors.New("injected runner failure") + } return execx.Result{}, nil } diff --git a/internal/cli/init_scan.go b/internal/cli/init_scan.go index 583414e..000b453 100644 --- a/internal/cli/init_scan.go +++ b/internal/cli/init_scan.go @@ -12,17 +12,16 @@ import ( "regexp" "sort" "strings" - "time" "github.com/spf13/cobra" + "github.com/z2z23n0/tooltend/internal/buildinfo" + "github.com/z2z23n0/tooltend/internal/bundle" "github.com/z2z23n0/tooltend/internal/config" "github.com/z2z23n0/tooltend/internal/host" "github.com/z2z23n0/tooltend/internal/inventory" - "github.com/z2z23n0/tooltend/internal/kick" "github.com/z2z23n0/tooltend/internal/model" "github.com/z2z23n0/tooltend/internal/plan" - "github.com/z2z23n0/tooltend/internal/reconcile" "github.com/z2z23n0/tooltend/internal/safeio" "github.com/z2z23n0/tooltend/internal/scheduler" "github.com/z2z23n0/tooltend/internal/store" @@ -30,8 +29,9 @@ import ( ) type initOptions struct { - Projects []string - Agents []string + Projects []string + Agents []string + ResetState bool } type profileMutation struct { @@ -65,11 +65,15 @@ func (a *App) newInitCommand() *cobra.Command { } command.Flags().StringSliceVar(&options.Projects, "project", nil, "select a project (repeatable)") command.Flags().StringSliceVar(&options.Agents, "agent", nil, "select codex and/or claude") + command.Flags().BoolVar(&options.ResetState, "reset-state", false, "back up and rebuild ToolTend state without configuring bundles") command.RunE = a.run("init", func(ctx context.Context) (any, error) { paths, err := a.paths() if err != nil { return nil, err } + if options.ResetState { + return a.resetState(ctx, paths, options) + } cfg, err := a.loadConfig(paths) if err != nil { return nil, err @@ -124,13 +128,11 @@ func (a *App) newInitCommand() *cobra.Command { } var persisted inventory.PersistResult - var runtimeMigrations int - var kickWarning string + var bundleInventory bundle.DiscoverResult configBeforeHash := fileHashOrEmpty(paths.ConfigFile) - value := plan.Plan{ID: "init-v1", Title: "Initialize ToolTend V1"} + value := plan.Plan{ID: "init-v2", Title: "Initialize ToolTend v0.2 bundle inventory"} candidateJSON, _ := json.Marshal(projectCandidates) inventoryPreview := buildInitInventoryPreview(report) - eligibleRuntimeMigrations := countInitRuntimeMigrationCandidates(report) inventoryJSON, _ := json.Marshal(inventoryPreview) value.Operations = append(value.Operations, plan.FuncOperation{ Description: plan.OperationPreview{ @@ -146,7 +148,7 @@ func (a *App) newInitCommand() *cobra.Command { value.Operations = append(value.Operations, plan.FuncOperation{ Description: plan.OperationPreview{ ID: "preview-inventory", Kind: plan.OperationOther, Target: "component inventory", - Summary: "Show discovered components, bindings, classifications, duplicate copies, version drift, and recommended apply modes", + Summary: "Show discovery evidence that will be grouped into unconfigured bundles", RequiresConfirmation: true, Details: map[string]string{"components": string(inventoryJSON)}, }, @@ -202,11 +204,7 @@ func (a *App) newInitCommand() *cobra.Command { ID: "initialize-inventory", Kind: plan.OperationDatabase, Target: paths.DatabaseFile, Summary: "Create or migrate the SQLite state database and persist the read-only discovery result", RequiresConfirmation: true, - Details: map[string]string{ - "observations": fmt.Sprint(len(report.HostResult.Observations)), - "bindings": fmt.Sprint(len(report.HostResult.Bindings)), - "runtime_migration_candidates": fmt.Sprint(eligibleRuntimeMigrations), - }, + Details: map[string]string{"observations": fmt.Sprint(len(report.HostResult.Observations)), "bindings": fmt.Sprint(len(report.HostResult.Bindings)), "bundle_policy": "all discovered bundles remain unconfigured"}, }, ApplyFunc: func(ctx context.Context) error { database, openErr := store.OpenRW(paths.DatabaseFile) @@ -218,7 +216,11 @@ func (a *App) newInitCommand() *cobra.Command { if openErr != nil { return openErr } - runtimeMigrations, openErr = reconcile.EnqueueRuntimeMigrations(ctx, database, time.Now().UTC()) + bundleInventory, openErr = bundle.Discover(ctx, database, bundle.DiscoverOptions{ + HomeDir: a.home, Executable: a.executable, BuildVersion: buildinfo.Version, + LocalRecipeDir: filepath.Join(paths.ConfigDir, "bundles.d"), + LookupPath: a.lookupPath, + }) return openErr }, }, @@ -279,33 +281,11 @@ func (a *App) newInitCommand() *cobra.Command { }, }, ) - value.Operations = append(value.Operations, plan.FuncOperation{ - Description: plan.OperationPreview{ - ID: "queue-first-reconcile", Kind: plan.OperationOther, Target: paths.StateDir, - Summary: "Queue a detached one-shot worker for runtime migration and the first update check", - RequiresConfirmation: true, - Details: map[string]string{ - "eligible_candidates": fmt.Sprint(eligibleRuntimeMigrations), - "queued": "only after confirmed inventory persistence; the detached worker adopts exact npm/pypi runtimes", - }, - }, - ApplyFunc: func(context.Context) error { - _, queueErr := kick.Queue(a.executable, paths.StateDir, "reconcile", "--once", "--reason", "kick", "--state-dir", paths.StateDir, "--json") - if queueErr != nil { - kickWarning = "initial worker was not started; SessionStart and the daily schedule will retry" - } - return nil - }, - }) return a.applyPlan(ctx, value, func() any { result := map[string]any{ - "paths": paths, "inventory": persisted, - "project_candidates": projectCandidates, - "runtime_migrations_queued": runtimeMigrations, - "warnings": report.HostResult.Warnings, - } - if kickWarning != "" { - result["worker_warning"] = kickWarning + "paths": paths, "inventory": persisted, "bundles": bundleInventory, + "project_candidates": projectCandidates, "warnings": report.HostResult.Warnings, + "next_command": "tooltend bundles configure", } return result }) @@ -711,6 +691,7 @@ func (a *App) newScanCommand() *cobra.Command { return nil, err } var persisted inventory.PersistResult + var bundleInventory bundle.DiscoverResult value := plan.Plan{ID: "scan-v1", Title: "Persist the current ToolTend inventory", Operations: []plan.Operation{ plan.FuncOperation{ Description: plan.OperationPreview{ @@ -723,14 +704,39 @@ func (a *App) newScanCommand() *cobra.Command { return withLifecycleStateLock(ctx, paths, func(database *store.Store) error { var persistErr error persisted, persistErr = inventory.Persist(ctx, database, report) + if persistErr != nil { + return persistErr + } + bundleInventory, persistErr = bundle.Discover(ctx, database, bundle.DiscoverOptions{ + HomeDir: a.home, Executable: a.executable, BuildVersion: buildinfo.Version, + LocalRecipeDir: filepath.Join(paths.ConfigDir, "bundles.d"), + LookupPath: a.lookupPath, + }) return persistErr }) }, }, }} return a.applyPlan(ctx, value, func() any { - return map[string]any{"inventory": persisted, "warnings": report.HostResult.Warnings} + return map[string]any{"inventory": persisted, "bundles": bundleInventory, "warnings": report.HostResult.Warnings} }) }) return command } + +func (a *App) lookupPath(name string) (string, error) { + if name == "" || filepath.Base(name) != name { + return "", os.ErrNotExist + } + for _, directory := range filepath.SplitList(a.getenv("PATH")) { + if directory == "" || !filepath.IsAbs(directory) { + continue + } + candidate := filepath.Join(directory, name) + info, err := os.Stat(candidate) + if err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 { + return candidate, nil + } + } + return "", os.ErrNotExist +} diff --git a/internal/cli/maintenance_commands.go b/internal/cli/maintenance_commands.go index c3466ce..52c43f0 100644 --- a/internal/cli/maintenance_commands.go +++ b/internal/cli/maintenance_commands.go @@ -19,7 +19,7 @@ import ( func (a *App) selfUpdateManager(stateDir, manifestURL string) selfupdate.Manager { return selfupdate.Manager{ StateDir: stateDir, Executable: a.executable, ManifestURL: manifestURL, - CurrentVersion: buildinfo.Version, + CurrentVersion: buildinfo.Version, CurrentSequence: buildinfo.ReleaseSequence(), } } @@ -35,7 +35,7 @@ func (a *App) newSelfCommand() *cobra.Command { if err != nil { return nil, err } - result := map[string]any{"build": buildinfo.Current(), "update": value} + result := map[string]any{"build": buildinfo.Current(), "update": value, "signature": selfupdate.EmbeddedSignatureCapability()} if a.selfApply.Applied { result["applied_before_command"] = a.selfApply } diff --git a/internal/cli/read_commands.go b/internal/cli/read_commands.go index ab13c0f..cc01024 100644 --- a/internal/cli/read_commands.go +++ b/internal/cli/read_commands.go @@ -17,16 +17,28 @@ import ( ) type statusData struct { - Initialized bool `json:"initialized"` - Issues []string `json:"issues,omitempty"` - Components int `json:"components"` - Bindings int `json:"bindings"` - ManagedBindings int `json:"managed_bindings"` - UpdatesAvailable int `json:"updates_available"` - NeedsReview int `json:"needs_review"` - FailedCandidates int `json:"failed_candidates"` - PendingTasks int `json:"pending_tasks"` - UnfinishedActions int `json:"unfinished_activations"` + Initialized bool `json:"initialized"` + Issues []string `json:"issues,omitempty"` + Bundles int `json:"bundles"` + ConfiguredBundles int `json:"configured_bundles"` + ManagedBundles int `json:"managed_bundles"` + ObservedBundles int `json:"observed_bundles"` + UnconfiguredBundles int `json:"unconfigured_bundles"` + UnresolvedBundles int `json:"unresolved_bundles"` + UpdatesAvailable int `json:"updates_available"` + FailedTransactions int `json:"failed_transactions"` + PendingTasks int `json:"pending_tasks"` + UnfinishedActions int `json:"unfinished_transactions"` + Debug statusDebug `json:"debug"` +} + +type statusDebug struct { + Components int `json:"components"` + Bindings int `json:"bindings"` + ManagedBindings int `json:"managed_bindings"` + NeedsReview int `json:"needs_review"` + FailedCandidates int `json:"failed_candidates"` + LegacyTasks int `json:"legacy_pending_tasks"` } type componentSummary struct { @@ -97,10 +109,10 @@ func (a *App) newStatusCommand() *cobra.Command { if err != nil { return nil, err } - result.Components, result.Bindings = len(components), len(bindings) + result.Debug.Components, result.Debug.Bindings = len(components), len(bindings) for _, binding := range bindings { if binding.Managed { - result.ManagedBindings++ + result.Debug.ManagedBindings++ } } for _, binding := range bindings { @@ -120,20 +132,36 @@ func (a *App) newStatusCommand() *cobra.Command { } for _, candidate := range candidates { switch candidate.Status { - case model.CandidateAvailable: - result.UpdatesAvailable++ case model.CandidateNeedsReview: - result.NeedsReview++ + result.Debug.NeedsReview++ case model.CandidateFailed: - result.FailedCandidates++ + result.Debug.FailedCandidates++ } } } + bundleCounts, err := database.BundleCounts(ctx) + if err != nil { + return nil, err + } + result.Bundles = bundleCounts.Total + result.ConfiguredBundles = bundleCounts.Configured + result.ManagedBundles = bundleCounts.Managed + result.ObservedBundles = bundleCounts.Observe + result.UnconfiguredBundles = bundleCounts.Unconfigured + result.UnresolvedBundles = bundleCounts.Unresolved + result.UpdatesAvailable = bundleCounts.UpdatesAvailable + result.FailedTransactions = bundleCounts.FailedTransactions + if result.Bundles == 0 { + result.Issues = append(result.Issues, "bundle_inventory_missing") + } tasks, err := database.CountTasks(ctx) if err != nil { return nil, err } - result.PendingTasks = tasks.Pending + tasks.Running + result.Debug.LegacyTasks = tasks.Pending + tasks.Running + if err := database.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM bundle_tasks WHERE status IN ('pending','running')`).Scan(&result.PendingTasks); err != nil { + return nil, err + } intents, err := database.ListUnfinishedActivations(ctx) if err != nil { return nil, err @@ -142,7 +170,11 @@ func (a *App) newStatusCommand() *cobra.Command { if err != nil { return nil, err } - result.UnfinishedActions = len(intents) + len(adoptions) + bundleTransactions, err := database.ListUnfinishedBundleTransactions(ctx) + if err != nil { + return nil, err + } + result.UnfinishedActions = len(intents) + len(adoptions) + len(bundleTransactions) for _, intent := range adoptions { if intent.Phase == store.AdoptionBlocked { result.Issues = append(result.Issues, "adoption_recovery_blocked") diff --git a/internal/cli/reset.go b/internal/cli/reset.go new file mode 100644 index 0000000..4e3dce0 --- /dev/null +++ b/internal/cli/reset.go @@ -0,0 +1,418 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/z2z23n0/tooltend/internal/buildinfo" + "github.com/z2z23n0/tooltend/internal/bundle" + "github.com/z2z23n0/tooltend/internal/config" + "github.com/z2z23n0/tooltend/internal/host" + "github.com/z2z23n0/tooltend/internal/inventory" + "github.com/z2z23n0/tooltend/internal/lockfile" + "github.com/z2z23n0/tooltend/internal/plan" + "github.com/z2z23n0/tooltend/internal/safeio" + "github.com/z2z23n0/tooltend/internal/scheduler" + "github.com/z2z23n0/tooltend/internal/store" +) + +type resetBlockers struct { + ManagedBindings int `json:"managed_bindings"` + ManagedInstallations int `json:"managed_installations"` + ManagedBundles int `json:"managed_bundles"` + ActivationJournals int `json:"activation_journals"` + AdoptionJournals int `json:"adoption_journals"` + BundleTransactions int `json:"bundle_transactions"` +} + +type resetSnapshot struct { + Source string `json:"source"` + Backup string `json:"backup"` +} + +type resetResult struct { + Backup string `json:"backup"` + Inventory inventory.PersistResult `json:"inventory"` + Bundles bundle.DiscoverResult `json:"bundles"` + NextCommand string `json:"next_command"` +} + +func (a *App) resetState(ctx context.Context, paths config.Paths, options initOptions) (mutationResult, error) { + blockers, schema, err := inspectResetBlockers(ctx, paths.DatabaseFile) + if err != nil { + return mutationResult{}, err + } + if blockers.ManagedBindings+blockers.ManagedInstallations+blockers.ManagedBundles+blockers.ActivationJournals+blockers.AdoptionJournals+blockers.BundleTransactions > 0 { + return mutationResult{}, &commandError{Code: "reset_refused", Message: "state reset is unsafe while managed objects or unfinished journals exist", Details: map[string]any{"blockers": blockers}} + } + cfg, err := config.Load(paths.ConfigFile) + if err != nil { + return mutationResult{}, err + } + if len(options.Agents) > 0 { + cfg.Agents, err = parseAgents(options.Agents) + if err != nil { + return mutationResult{}, err + } + } + if len(options.Projects) > 0 { + cfg.Projects, err = a.initProjects(nil, options.Projects) + if err != nil { + return mutationResult{}, err + } + } + var profile *profileMutation + if cfg.Runtime.ShimDir == "" { + cfg.Runtime.ShimDir, profile, err = a.planShimPath(paths) + if err != nil { + return mutationResult{}, err + } + } + paths.ShimDir = cfg.Runtime.ShimDir + currentProject := "" + if containsPath(cfg.Projects, a.workingDir) { + currentProject = a.workingDir + } + report, err := a.scanInventory(ctx, cfg.Agents, currentProject, cfg.Projects) + if err != nil { + return mutationResult{}, err + } + hookPlans, err := a.planHooks(ctx, cfg.Agents) + if err != nil { + return mutationResult{}, err + } + schedule, err := scheduler.BuildPlan(scheduler.Options{Executable: a.executable, Home: a.home, StateDir: paths.StateDir, Hour: -1, Minute: -1}) + if err != nil { + return mutationResult{}, err + } + backupRoot := filepath.Join(filepath.Dir(paths.StateDir), "tooltend-backups", time.Now().UTC().Format("20060102T150405.000000000Z")) + snapshots := resetSnapshots(paths, hookPlans, schedule, profile, backupRoot) + configBeforeHash := fileHashOrEmpty(paths.ConfigFile) + var completed resetResult + value := plan.Plan{ID: "init-reset-v2", Title: "Back up and rebuild ToolTend v0.2 state", Operations: []plan.Operation{ + plan.FuncOperation{ + Description: plan.OperationPreview{ + ID: "backup-and-reset", Kind: plan.OperationDatabase, Target: paths.StateDir, + Summary: "Pause the scheduler, back up all ToolTend state, rebuild schema v5, and discover unconfigured bundles", + Reversible: false, RequiresConfirmation: true, + Details: map[string]string{ + "backup": backupRoot, "schema_before": fmt.Sprint(schema), "schema_after": fmt.Sprint(store.SchemaVersion), + "snapshots": fmt.Sprint(len(snapshots)), "bundle_policy": "all bundles remain unconfigured", + }, + }, + ApplyFunc: func(applyCtx context.Context) error { + result, applyErr := a.executeReset(applyCtx, paths, cfg, report, hookPlans, schedule, profile, backupRoot, snapshots, configBeforeHash) + if applyErr == nil { + completed = result + } + return applyErr + }, + }, + }} + return a.applyPlan(ctx, value, func() any { return completed }) +} + +func inspectResetBlockers(ctx context.Context, databasePath string) (resetBlockers, int, error) { + database, err := store.OpenReadOnly(databasePath) + if err != nil { + return resetBlockers{}, 0, err + } + defer database.Close() + version, err := database.UserVersion(ctx) + if err != nil { + return resetBlockers{}, 0, err + } + var value resetBlockers + queries := []struct { + target *int + query string + }{ + {&value.ManagedBindings, `SELECT COUNT(*) FROM bindings WHERE managed=1`}, + {&value.ActivationJournals, `SELECT COUNT(*) FROM activation_intents WHERE phase IN ('prepared','pointer_switched')`}, + {&value.AdoptionJournals, `SELECT COUNT(*) FROM adoption_intents WHERE phase IN ('prepared','switched','blocked')`}, + } + if version >= 5 { + queries = append(queries, + struct { + target *int + query string + }{&value.ManagedInstallations, `SELECT COUNT(*) FROM installations WHERE managed=1`}, + struct { + target *int + query string + }{&value.ManagedBundles, `SELECT COUNT(*) FROM bundle_policies WHERE mode IN ('auto','manual')`}, + struct { + target *int + query string + }{&value.BundleTransactions, `SELECT COUNT(*) FROM bundle_transactions WHERE status IN ('prepared','staging','activating','rolling_back')`}, + ) + } + for _, item := range queries { + if err := database.DB().QueryRowContext(ctx, item.query).Scan(item.target); err != nil { + return value, version, err + } + } + return value, version, nil +} + +func (a *App) executeReset(ctx context.Context, paths config.Paths, cfg config.Config, report inventory.Report, hookPlans []host.MutationPlan, schedule scheduler.Plan, profile *profileMutation, backupRoot string, snapshots []resetSnapshot, configBeforeHash string) (result resetResult, err error) { + lock, err := lockfile.Try(paths.ActivationLock) + if err != nil { + return result, fmt.Errorf("reset: acquire activation lock: %w", err) + } + defer func() { err = errors.Join(err, lock.Close()) }() + if fileHashOrEmpty(paths.ConfigFile) != configBeforeHash { + return result, errors.New("reset: configuration changed after preview") + } + blockers, _, err := inspectResetBlockers(ctx, paths.DatabaseFile) + if err != nil { + return result, err + } + if blockers.ManagedBindings+blockers.ManagedInstallations+blockers.ManagedBundles+blockers.ActivationJournals+blockers.AdoptionJournals+blockers.BundleTransactions > 0 { + return result, &commandError{Code: "reset_refused", Message: "state changed after preview and reset is no longer safe", Details: map[string]any{"blockers": blockers}} + } + + deactivated := false + if deactivateErr := scheduler.Deactivate(ctx, schedule, a.runner); deactivateErr == nil { + deactivated = true + } else { + return result, fmt.Errorf("reset: pause scheduler: %w", deactivateErr) + } + backupComplete := false + rollback := func(cause error) error { + var restoreErr error + if backupComplete { + restoreErr = restoreSnapshots(snapshots, paths.ActivationLock) + } + if deactivated { + activateErr := scheduler.Activate(context.WithoutCancel(ctx), schedule, a.runner) + restoreErr = errors.Join(restoreErr, activateErr) + } + return errors.Join(cause, restoreErr) + } + if err := os.MkdirAll(backupRoot, 0o700); err != nil { + return result, rollback(err) + } + for _, snapshot := range snapshots { + if err := copyPath(snapshot.Source, snapshot.Backup, ""); err != nil { + return result, rollback(fmt.Errorf("reset: back up %s: %w", snapshot.Source, err)) + } + } + manifestData, _ := json.MarshalIndent(map[string]any{"schema": store.SchemaVersion, "created_at": time.Now().UTC(), "snapshots": snapshots}, "", " ") + if err := safeio.AtomicWriteFile(filepath.Join(backupRoot, "manifest.json"), append(manifestData, '\n'), 0o600); err != nil { + return result, rollback(err) + } + backupComplete = true + for _, root := range uniqueResetRoots(paths) { + if err := clearRoot(root, paths.ActivationLock); err != nil { + return result, rollback(fmt.Errorf("reset: clear %s: %w", root, err)) + } + } + if err := paths.Ensure(); err != nil { + return result, rollback(err) + } + if err := os.MkdirAll(paths.ShimDir, 0o755); err != nil { + return result, rollback(err) + } + if profile != nil { + if err := safeio.AtomicWriteFile(profile.Path, profile.Content, profile.Mode); err != nil { + return result, rollback(err) + } + } + if err := config.SaveAtomic(paths.ConfigFile, cfg); err != nil { + return result, rollback(err) + } + database, err := store.OpenRW(paths.DatabaseFile) + if err != nil { + return result, rollback(err) + } + result.Inventory, err = inventory.Persist(ctx, database, report) + if err == nil { + result.Bundles, err = bundle.Discover(ctx, database, bundle.DiscoverOptions{ + HomeDir: a.home, Executable: a.executable, BuildVersion: buildinfo.Version, + LocalRecipeDir: filepath.Join(paths.ConfigDir, "bundles.d"), + LookupPath: a.lookupPath, + }) + } + closeErr := database.Close() + if err = errors.Join(err, closeErr); err != nil { + return result, rollback(err) + } + for _, hookPlan := range hookPlans { + for _, mutation := range hookPlan.Mutations { + if mutation.Changed { + if err := host.ApplyMutation(mutation); err != nil { + return result, rollback(err) + } + } + } + } + if err := scheduler.Apply(schedule); err != nil { + return result, rollback(err) + } + if err := scheduler.Activate(ctx, schedule, a.runner); err != nil { + return result, rollback(err) + } + deactivated = false + result.Backup, result.NextCommand = backupRoot, "tooltend bundles configure" + return result, nil +} + +func resetSnapshots(paths config.Paths, hookPlans []host.MutationPlan, schedule scheduler.Plan, profile *profileMutation, backupRoot string) []resetSnapshot { + var sources []string + sources = append(sources, uniqueResetRoots(paths)...) + for _, hookPlan := range hookPlans { + for _, mutation := range hookPlan.Mutations { + sources = append(sources, mutation.Path) + } + } + for _, file := range schedule.Files { + sources = append(sources, file.Path) + } + if profile != nil { + sources = append(sources, profile.Path) + } + seen := map[string]struct{}{} + result := []resetSnapshot{} + for index, source := range sources { + source = filepath.Clean(source) + if _, exists := seen[source]; exists { + continue + } + seen[source] = struct{}{} + result = append(result, resetSnapshot{Source: source, Backup: filepath.Join(backupRoot, fmt.Sprintf("%03d-%s", index, sanitizeBackupName(source)))}) + } + return result +} + +func uniqueResetRoots(paths config.Paths) []string { + values := []string{paths.ConfigDir, paths.StateDir, paths.DataDir} + seen := map[string]struct{}{} + result := []string{} + for _, value := range values { + value = filepath.Clean(value) + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { return len(result[i]) > len(result[j]) }) + return result +} + +func clearRoot(root, preserved string) error { + entries, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + path := filepath.Join(root, entry.Name()) + if filepath.Clean(path) == filepath.Clean(preserved) { + continue + } + if err := os.RemoveAll(path); err != nil { + return err + } + } + return nil +} + +func restoreSnapshots(values []resetSnapshot, preserved string) error { + var result error + for _, value := range values { + if filepath.Clean(value.Source) == filepath.Clean(preserved) { + continue + } + _ = os.RemoveAll(value.Source) + if _, err := os.Lstat(value.Backup); errors.Is(err, os.ErrNotExist) { + continue + } + if err := copyPath(value.Backup, value.Source, preserved); err != nil { + result = errors.Join(result, err) + } + } + return result +} + +func copyPath(source, destination, preserved string) error { + info, err := os.Lstat(source) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(source) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + return os.Symlink(target, destination) + } + if info.IsDir() { + if err := os.MkdirAll(destination, info.Mode().Perm()); err != nil { + return err + } + entries, err := os.ReadDir(source) + if err != nil { + return err + } + for _, entry := range entries { + childSource := filepath.Join(source, entry.Name()) + if preserved != "" && filepath.Clean(childSource) == filepath.Clean(preserved) { + continue + } + if err := copyPath(childSource, filepath.Join(destination, entry.Name()), preserved); err != nil { + return err + } + } + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported special file %s", source) + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + syncErr := output.Sync() + closeErr := output.Close() + return errors.Join(copyErr, syncErr, closeErr) +} + +func sanitizeBackupName(path string) string { + path = strings.Trim(filepath.ToSlash(path), "/") + path = strings.NewReplacer("/", "_", " ", "-").Replace(path) + if len(path) > 80 { + path = path[len(path)-80:] + } + if path == "" { + return runtime.GOOS + } + return path +} diff --git a/internal/cli/self_repair.go b/internal/cli/self_repair.go new file mode 100644 index 0000000..700fb99 --- /dev/null +++ b/internal/cli/self_repair.go @@ -0,0 +1,50 @@ +package cli + +import ( + "context" + "fmt" + "path/filepath" + + v1 "github.com/z2z23n0/tooltend/internal/api/v1" + "github.com/z2z23n0/tooltend/internal/config" + "github.com/z2z23n0/tooltend/internal/host" + "github.com/z2z23n0/tooltend/internal/scheduler" +) + +// repairAfterSelfUpdate touches only integrations whose entries are owned by +// ToolTend. Host planners preserve unrelated user hooks and fail closed when +// their structure cannot be inspected safely. +func (a *App) repairAfterSelfUpdate(ctx context.Context, paths config.Paths) []v1.Warning { + cfg, err := config.Load(paths.ConfigFile) + if err != nil { + return []v1.Warning{{Code: "self_update_repair_skipped", Message: "self-update applied, but integration repair was skipped because configuration is unavailable"}} + } + if cfg.Runtime.ShimDir != "" { + paths.ShimDir = filepath.Clean(cfg.Runtime.ShimDir) + } + var warnings []v1.Warning + plans, err := a.planHooks(ctx, cfg.Agents) + if err != nil { + warnings = append(warnings, v1.Warning{Code: "self_update_hook_conflict", Message: "self-update applied, but user hook configuration could not be repaired safely"}) + } else { + for _, value := range plans { + for _, mutation := range value.Mutations { + if mutation.Changed { + if err := host.ApplyMutation(mutation); err != nil { + warnings = append(warnings, v1.Warning{Code: "self_update_hook_conflict", Message: "self-update applied, but a hook changed concurrently and was not overwritten"}) + } + } + } + } + } + schedule, err := scheduler.BuildPlan(scheduler.Options{Executable: a.executable, Home: a.home, StateDir: paths.StateDir, Hour: -1, Minute: -1}) + if err == nil { + if err = scheduler.Apply(schedule); err == nil { + err = scheduler.Activate(ctx, schedule, a.runner) + } + } + if err != nil { + warnings = append(warnings, v1.Warning{Code: "self_update_scheduler_repair_failed", Message: fmt.Sprintf("self-update applied, but the ToolTend scheduler needs repair: %s", err)}) + } + return warnings +} diff --git a/internal/cli/worker_commands.go b/internal/cli/worker_commands.go index 05508c1..6520fe8 100644 --- a/internal/cli/worker_commands.go +++ b/internal/cli/worker_commands.go @@ -14,6 +14,8 @@ import ( "github.com/spf13/cobra" + "github.com/z2z23n0/tooltend/internal/buildinfo" + "github.com/z2z23n0/tooltend/internal/bundle" "github.com/z2z23n0/tooltend/internal/config" "github.com/z2z23n0/tooltend/internal/hook" "github.com/z2z23n0/tooltend/internal/inventory" @@ -265,6 +267,29 @@ func (a *App) reconcileOnce(ctx context.Context, paths config.Paths, reason stri } return inventory.Persist(scanCtx, scanDB, report) }, + BundleInventory: func(scanCtx context.Context, scanDB *store.Store) (bundle.DiscoverResult, error) { + return bundle.Discover(scanCtx, scanDB, bundle.DiscoverOptions{ + HomeDir: a.home, Executable: a.executable, BuildVersion: buildinfo.Version, + LocalRecipeDir: filepath.Join(paths.ConfigDir, "bundles.d"), + LookupPath: a.lookupPath, + }) + }, + BundleRecovery: func(recoveryCtx context.Context) (bundle.RecoveryResult, error) { + bundleService := bundle.Service{Database: database, Paths: paths, Runner: a.runner} + return bundleService.RecoverTransactions(recoveryCtx) + }, + BundleCoordinator: reconcile.BundleCoordinatorFunc(func(bundleCtx context.Context, value model.Bundle, _ model.BundlePolicy, activate bool) error { + bundleService := bundle.Service{Database: database, Paths: paths, Runner: a.runner} + preview, prepareErr := bundleService.PrepareUpdate(bundleCtx, value.ID, false) + if prepareErr != nil { + return prepareErr + } + if !activate { + return database.UpsertBundleRelease(bundleCtx, preview.Target) + } + _, executeErr := bundleService.ExecuteUpdate(bundleCtx, preview) + return executeErr + }), } return worker.RunOnce(ctx, reason) } diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 0664fd9..50d6080 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -93,8 +93,9 @@ func Run(ctx context.Context, paths config.Paths) Report { if version == store.SchemaVersion { activations, activationErr := database.ListUnfinishedActivations(ctx) adoptions, adoptionErr := database.ListPendingAdoptions(ctx) + bundleTransactions, bundleErr := database.ListUnfinishedBundleTransactions(ctx) switch { - case activationErr != nil || adoptionErr != nil: + case activationErr != nil || adoptionErr != nil || bundleErr != nil: appendCheck(Check{Name: "lifecycle_journal", Level: LevelError, Message: "lifecycle recovery journals cannot be inspected", Repairable: false}) default: blocked := false @@ -103,12 +104,23 @@ func Run(ctx context.Context, paths config.Paths) Report { } if blocked { appendCheck(Check{Name: "lifecycle_journal", Level: LevelError, Message: "an adoption recovery is blocked by externally changed state", Repairable: false}) - } else if len(activations)+len(adoptions) != 0 { + } else if len(activations)+len(adoptions)+len(bundleTransactions) != 0 { appendCheck(Check{Name: "lifecycle_journal", Level: LevelWarning, Message: "unfinished lifecycle operations are waiting for recovery", Repairable: true}) } else { appendCheck(Check{Name: "lifecycle_journal", Level: LevelOK, Message: "lifecycle recovery journals are clear"}) } } + counts, countErr := database.BundleCounts(ctx) + switch { + case countErr != nil: + appendCheck(Check{Name: "bundle_coverage", Level: LevelError, Message: "bundle management coverage cannot be inspected", Repairable: false}) + case counts.Total == 0: + appendCheck(Check{Name: "bundle_coverage", Level: LevelWarning, Message: "bundle discovery has not been persisted; run tooltend scan", Repairable: true}) + case counts.Configured == 0: + appendCheck(Check{Name: "bundle_coverage", Level: LevelWarning, Message: fmt.Sprintf("infrastructure is healthy and %d bundles were discovered, but management coverage is zero; run tooltend bundles configure", counts.Total)}) + default: + appendCheck(Check{Name: "bundle_coverage", Level: LevelOK, Message: fmt.Sprintf("%d of %d bundles are configured", counts.Configured, counts.Total)}) + } } } diff --git a/internal/model/enums.go b/internal/model/enums.go index bef3248..70df8be 100644 --- a/internal/model/enums.go +++ b/internal/model/enums.go @@ -176,6 +176,83 @@ const ( ReceiptFailed ReceiptStatus = "failed" ) +// LifecycleOwner identifies the authority that is allowed to mutate an +// installation. Only tooltend and delegated owners may ever execute update +// commands; every other owner is observation-only. +type LifecycleOwner string + +const ( + LifecycleToolTend LifecycleOwner = "tooltend" + LifecycleDelegated LifecycleOwner = "delegated" + LifecycleHostOwned LifecycleOwner = "host-owned" + LifecycleAppOwned LifecycleOwner = "app-owned" + LifecycleWorkspaceLinked LifecycleOwner = "workspace-linked" + LifecycleUnresolved LifecycleOwner = "unresolved" +) + +type BundlePolicyMode string + +const ( + BundlePolicyAuto BundlePolicyMode = "auto" + BundlePolicyManual BundlePolicyMode = "manual" + BundlePolicyObserve BundlePolicyMode = "observe" + BundlePolicyIgnore BundlePolicyMode = "ignore" +) + +type BundleConfigState string + +const ( + BundleUnconfigured BundleConfigState = "unconfigured" + BundleConfigured BundleConfigState = "configured" +) + +type BundleConfidence string + +const ( + BundleConfidenceHigh BundleConfidence = "high" + BundleConfidenceMedium BundleConfidence = "medium" + BundleConfidenceLow BundleConfidence = "low" + BundleConfidenceUnresolved BundleConfidence = "unresolved" +) + +type ArtifactKind string + +const ( + ArtifactCLI ArtifactKind = "cli" + ArtifactSkill ArtifactKind = "skill" + ArtifactHook ArtifactKind = "hook" + ArtifactApp ArtifactKind = "app" + ArtifactConfig ArtifactKind = "config" + ArtifactBinary ArtifactKind = "embedded_binary" + ArtifactPlugin ArtifactKind = "plugin" + ArtifactMCP ArtifactKind = "mcp" +) + +type BundleTransactionStatus string + +const ( + BundleTransactionPrepared BundleTransactionStatus = "prepared" + BundleTransactionStaging BundleTransactionStatus = "staging" + BundleTransactionActivating BundleTransactionStatus = "activating" + BundleTransactionCommitted BundleTransactionStatus = "committed" + BundleTransactionRollingBack BundleTransactionStatus = "rolling_back" + BundleTransactionRolledBack BundleTransactionStatus = "rolled_back" + BundleTransactionFailed BundleTransactionStatus = "failed" +) + +type BundleStepStatus string + +const ( + BundleStepPending BundleStepStatus = "pending" + BundleStepStaged BundleStepStatus = "staged" + BundleStepActivating BundleStepStatus = "activating" + BundleStepActivated BundleStepStatus = "activated" + BundleStepHealthy BundleStepStatus = "healthy" + BundleStepCompensating BundleStepStatus = "compensating" + BundleStepCompensated BundleStepStatus = "compensated" + BundleStepFailed BundleStepStatus = "failed" +) + func validateEnum[T ~string](name string, value T, allowed ...T) error { for _, candidate := range allowed { if value == candidate { @@ -235,3 +312,24 @@ func (v ReceiptAction) Validate() error { func (v ReceiptStatus) Validate() error { return validateEnum("receipt status", v, ReceiptSucceeded, ReceiptRolledBack, ReceiptFailed) } +func (v LifecycleOwner) Validate() error { + return validateEnum("lifecycle owner", v, LifecycleToolTend, LifecycleDelegated, LifecycleHostOwned, LifecycleAppOwned, LifecycleWorkspaceLinked, LifecycleUnresolved) +} +func (v BundlePolicyMode) Validate() error { + return validateEnum("bundle policy", v, BundlePolicyAuto, BundlePolicyManual, BundlePolicyObserve, BundlePolicyIgnore) +} +func (v BundleConfigState) Validate() error { + return validateEnum("bundle config state", v, BundleUnconfigured, BundleConfigured) +} +func (v BundleConfidence) Validate() error { + return validateEnum("bundle confidence", v, BundleConfidenceHigh, BundleConfidenceMedium, BundleConfidenceLow, BundleConfidenceUnresolved) +} +func (v ArtifactKind) Validate() error { + return validateEnum("artifact kind", v, ArtifactCLI, ArtifactSkill, ArtifactHook, ArtifactApp, ArtifactConfig, ArtifactBinary, ArtifactPlugin, ArtifactMCP) +} +func (v BundleTransactionStatus) Validate() error { + return validateEnum("bundle transaction status", v, BundleTransactionPrepared, BundleTransactionStaging, BundleTransactionActivating, BundleTransactionCommitted, BundleTransactionRollingBack, BundleTransactionRolledBack, BundleTransactionFailed) +} +func (v BundleStepStatus) Validate() error { + return validateEnum("bundle step status", v, BundleStepPending, BundleStepStaged, BundleStepActivating, BundleStepActivated, BundleStepHealthy, BundleStepCompensating, BundleStepCompensated, BundleStepFailed) +} diff --git a/internal/model/types.go b/internal/model/types.go index 5ecbdf3..470960c 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -172,7 +172,7 @@ type UpdateCandidate struct { UpdatedAt time.Time `json:"updated_at"` } -type ReviewBundle struct { +type ReviewPacket struct { ID string `json:"id"` CandidateID string `json:"candidate_id"` CandidateHash string `json:"candidate_hash"` @@ -181,6 +181,154 @@ type ReviewBundle struct { CreatedAt time.Time `json:"created_at"` } +// ReviewBundle is kept as a source-compatible alias for the v0.1 component +// review API. The product-level Bundle model is intentionally separate. +type ReviewBundle = ReviewPacket + +type Bundle struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + RecipeID string `json:"recipe_id"` + RecipeVersion string `json:"recipe_version"` + RecipeSource string `json:"recipe_source"` + Owner LifecycleOwner `json:"lifecycle_owner"` + ConfigState BundleConfigState `json:"config_state"` + Confidence BundleConfidence `json:"confidence"` + CurrentReleaseID string `json:"current_release_id,omitempty"` + MetadataJSON string `json:"metadata_json,omitempty"` + DiscoveredAt time.Time `json:"discovered_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type BundleRelease struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + Version string `json:"version"` + ResolvedRef string `json:"resolved_ref,omitempty"` + ManifestJSON string `json:"manifest_json"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type BundleArtifact struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + ReleaseID string `json:"release_id,omitempty"` + RecipeKey string `json:"recipe_key"` + Kind ArtifactKind `json:"kind"` + Name string `json:"name"` + Ordinal int `json:"ordinal"` + Required bool `json:"required"` + Driver string `json:"driver"` + MetadataJSON string `json:"metadata_json"` +} + +type Installation struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + ArtifactID string `json:"artifact_id,omitempty"` + Driver string `json:"driver"` + Path string `json:"path"` + PackageIdentity string `json:"package_identity,omitempty"` + SourceIdentity string `json:"source_identity,omitempty"` + ObservedVersion string `json:"observed_version,omitempty"` + ObservedHash string `json:"observed_hash,omitempty"` + Owner LifecycleOwner `json:"lifecycle_owner"` + Managed bool `json:"managed"` + MetadataJSON string `json:"metadata_json"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type ConsumerBinding struct { + ID string `json:"id"` + InstallationID string `json:"installation_id"` + BindingID string `json:"binding_id,omitempty"` + Host HostKind `json:"host"` + ProjectID string `json:"project_id,omitempty"` + Scope ScopeKind `json:"scope"` + ConfigPath string `json:"config_path,omitempty"` + ConfigPointer string `json:"config_pointer,omitempty"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type BundlePolicy struct { + BundleID string `json:"bundle_id"` + Mode BundlePolicyMode `json:"mode"` + RecipeTrusted bool `json:"recipe_trusted"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BundleTransaction struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + FromReleaseID string `json:"from_release_id,omitempty"` + ToReleaseID string `json:"to_release_id,omitempty"` + Status BundleTransactionStatus `json:"status"` + StageOnly bool `json:"stage_only"` + ErrorCode string `json:"error_code,omitempty"` + ErrorSummary string `json:"error_summary,omitempty"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type BundleTransactionStep struct { + ID string `json:"id"` + TransactionID string `json:"transaction_id"` + Ordinal int `json:"ordinal"` + ArtifactID string `json:"artifact_id,omitempty"` + InstallationID string `json:"installation_id,omitempty"` + Kind string `json:"kind"` + Status BundleStepStatus `json:"status"` + CommandJSON string `json:"command_json"` + RollbackJSON string `json:"rollback_json"` + BeforeJSON string `json:"before_json"` + AfterJSON string `json:"after_json"` + ErrorCode string `json:"error_code,omitempty"` + ErrorSummary string `json:"error_summary,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type BundleReceipt struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + TransactionID string `json:"transaction_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + SummaryJSON string `json:"summary_json"` + CreatedAt time.Time `json:"created_at"` +} + +type BundleHealthCheck struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + ArtifactID string `json:"artifact_id,omitempty"` + InstallationID string `json:"installation_id,omitempty"` + Name string `json:"name"` + Status string `json:"status"` + Summary string `json:"summary,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + +type BundleTask struct { + ID string `json:"id"` + BundleID string `json:"bundle_id"` + InstallationID string `json:"installation_id,omitempty"` + Kind string `json:"kind"` + IdempotencyKey string `json:"idempotency_key"` + Status TaskStatus `json:"status"` + Attempts int `json:"attempts"` + NextAttemptAt time.Time `json:"next_attempt_at"` + LeaseUntil *time.Time `json:"lease_until,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorSummary string `json:"error_summary,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Review struct { ID string `json:"id"` CandidateID string `json:"candidate_id"` diff --git a/internal/reconcile/types.go b/internal/reconcile/types.go index cf7fc6e..b3fca57 100644 --- a/internal/reconcile/types.go +++ b/internal/reconcile/types.go @@ -7,6 +7,7 @@ import ( "regexp" "time" + "github.com/z2z23n0/tooltend/internal/bundle" "github.com/z2z23n0/tooltend/internal/inventory" "github.com/z2z23n0/tooltend/internal/model" ) @@ -59,6 +60,16 @@ func (f RuntimeAdopterFunc) AdoptRuntime(ctx context.Context, binding model.Bind return f(ctx, binding) } +type BundleCoordinator interface { + ReconcileBundle(context.Context, model.Bundle, model.BundlePolicy, bool) error +} + +type BundleCoordinatorFunc func(context.Context, model.Bundle, model.BundlePolicy, bool) error + +func (f BundleCoordinatorFunc) ReconcileBundle(ctx context.Context, value model.Bundle, policy model.BundlePolicy, activate bool) error { + return f(ctx, value, policy, activate) +} + type BindingResult struct { BindingID string `json:"binding_id"` TaskID string `json:"task_id"` @@ -78,6 +89,7 @@ type RunResult struct { StartedAt time.Time `json:"started_at"` FinishedAt time.Time `json:"finished_at"` Recovered int `json:"recovered_activations"` + BundleRecovery bundle.RecoveryResult `json:"bundle_recovery"` Inventory inventory.PersistResult `json:"inventory"` Scheduled int `json:"scheduled"` Succeeded int `json:"succeeded"` diff --git a/internal/reconcile/worker.go b/internal/reconcile/worker.go index 8612d5a..2a0ed6f 100644 --- a/internal/reconcile/worker.go +++ b/internal/reconcile/worker.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/z2z23n0/tooltend/internal/bundle" "github.com/z2z23n0/tooltend/internal/config" "github.com/z2z23n0/tooltend/internal/inventory" "github.com/z2z23n0/tooltend/internal/kick" @@ -33,6 +34,8 @@ type InventoryOptions struct { } type InventoryFunc func(context.Context, *store.Store, InventoryOptions) (inventory.PersistResult, error) +type BundleInventoryFunc func(context.Context, *store.Store) (bundle.DiscoverResult, error) +type BundleRecoveryFunc func(context.Context) (bundle.RecoveryResult, error) type Worker struct { Database *store.Store @@ -43,14 +46,17 @@ type Worker struct { // reconciliation deliberately ignores process cwd and scans Config.Projects // only; init and explicit scan pass their working directory directly to the // inventory package. - CurrentProject string - Coordinator Coordinator - RuntimeAdopter RuntimeAdopter - Inventory InventoryFunc - Recover RecoveryFunc - Now func() time.Time - Lease time.Duration - MaxTasks int + CurrentProject string + Coordinator Coordinator + RuntimeAdopter RuntimeAdopter + BundleCoordinator BundleCoordinator + Inventory InventoryFunc + BundleInventory BundleInventoryFunc + BundleRecovery BundleRecoveryFunc + Recover RecoveryFunc + Now func() time.Time + Lease time.Duration + MaxTasks int } func (w *Worker) RunOnce(ctx context.Context, reason string) (RunResult, error) { @@ -89,6 +95,13 @@ func (w *Worker) RunOnce(ctx context.Context, reason string) (RunResult, error) finish() return result, fmt.Errorf("reconcile: activation recovery failed: %w", err) } + if w.BundleRecovery != nil { + result.BundleRecovery, err = w.BundleRecovery(ctx) + if err != nil { + finish() + return result, fmt.Errorf("reconcile: bundle transaction recovery failed: %w", err) + } + } scanID, err := model.NewID("scan") if err != nil { @@ -118,12 +131,39 @@ func (w *Worker) RunOnce(ctx context.Context, reason string) (RunResult, error) finish() return result, fmt.Errorf("reconcile: finish scan: %w", err) } + if w.BundleInventory != nil { + if _, err := w.BundleInventory(ctx, w.Database); err != nil { + finish() + return result, fmt.Errorf("reconcile: bundle discovery failed: %w", err) + } + } signal, err := hookSignal(ctx, w.Database) if err != nil { finish() return result, fmt.Errorf("reconcile: read hook signal: %w", err) } + bundleCounts, err := w.Database.BundleCounts(ctx) + if err != nil { + finish() + return result, err + } + if bundleCounts.Total > 0 { + if err := w.scheduleBundleTasks(ctx, signal, started, &result); err != nil { + finish() + return result, err + } + if err := markHookSignalsProcessed(ctx, w.Database, signal, started); err != nil { + finish() + return result, fmt.Errorf("reconcile: acknowledge hook signal: %w", err) + } + if err := w.runBundleTasks(ctx, &result); err != nil { + finish() + return result, err + } + finish() + return result, nil + } bindings, err := w.Database.ListBindings(ctx, "") if err != nil { finish() @@ -181,6 +221,116 @@ func (w *Worker) RunOnce(ctx context.Context, reason string) (RunResult, error) return result, nil } +func (w *Worker) scheduleBundleTasks(ctx context.Context, signal int64, started time.Time, result *RunResult) error { + values, err := w.Database.ListBundles(ctx) + if err != nil { + return err + } + for _, value := range values { + if value.ConfigState != model.BundleConfigured { + result.Skipped++ + continue + } + policy, err := w.Database.GetBundlePolicy(ctx, value.ID) + if err != nil { + return err + } + kind := "" + switch policy.Mode { + case model.BundlePolicyAuto: + kind = "update" + case model.BundlePolicyManual: + kind = "check" + case model.BundlePolicyObserve, model.BundlePolicyIgnore: + result.Skipped++ + continue + } + key := w.bundleIdempotencyKey(value, policy, signal, started) + task := model.BundleTask{ID: stableTaskID("bundle:" + key), BundleID: value.ID, Kind: kind, IdempotencyKey: "bundle:" + key, + Status: model.TaskPending, NextAttemptAt: started, CreatedAt: started, UpdatedAt: started} + inserted, err := w.Database.EnqueueBundleTask(ctx, task) + if err != nil { + return err + } + if inserted { + result.Scheduled++ + } + } + return nil +} + +func (w *Worker) runBundleTasks(ctx context.Context, result *RunResult) error { + lease := w.Lease + if lease <= 0 { + lease = defaultLease + } + limit := w.MaxTasks + if limit <= 0 { + limit = defaultMaxTasks + } + for processed := 0; processed < limit; processed++ { + now := w.now() + task, err := w.Database.ClaimBundleTask(ctx, now, lease) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + value, err := w.Database.GetBundle(ctx, task.BundleID) + if err != nil { + _ = w.Database.FailBundleTask(ctx, task.ID, "bundle_unavailable") + result.Failed++ + continue + } + policy, err := w.Database.GetBundlePolicy(ctx, value.ID) + if err != nil || value.ConfigState != model.BundleConfigured { + _ = w.Database.FailBundleTask(ctx, task.ID, "bundle_policy_unavailable") + result.Failed++ + continue + } + if w.BundleCoordinator == nil { + _ = w.Database.FailBundleTask(ctx, task.ID, "bundle_coordinator_unavailable") + result.Failed++ + continue + } + activate := task.Kind == "update" && policy.Mode == model.BundlePolicyAuto + err = w.BundleCoordinator.ReconcileBundle(ctx, value, policy, activate) + if err == nil { + if err := w.Database.CompleteBundleTask(ctx, task.ID); err != nil { + return err + } + result.Succeeded++ + continue + } + code, retryable := classifyError(err) + if retryable && task.Attempts < maxAttempts { + if err := w.Database.RetryBundleTask(ctx, task.ID, code, now.Add(retryDelay(task.Attempts))); err != nil { + return err + } + result.Retried++ + continue + } + if err := w.Database.FailBundleTask(ctx, task.ID, code); err != nil { + return err + } + result.Failed++ + } + return nil +} + +func (w *Worker) bundleIdempotencyKey(value model.Bundle, policy model.BundlePolicy, signal int64, now time.Time) string { + interval := w.Config.Check.Interval + if interval <= 0 { + interval = 24 * time.Hour + } + slot := now.UnixNano() / interval.Nanoseconds() + material := strings.Join([]string{"reconcile-bundle-v1", value.ID, value.CurrentReleaseID, string(policy.Mode), + strconv.FormatInt(policy.UpdatedAt.UnixNano(), 10), strconv.FormatInt(slot, 10), strconv.FormatInt(signal, 10)}, "\x00") + hash := sha256.Sum256([]byte(material)) + return hex.EncodeToString(hash[:]) +} + func (w *Worker) runTasks(ctx context.Context, reason string, result *RunResult) error { lease := w.Lease if lease <= 0 { diff --git a/internal/releasemanifest/manifest.go b/internal/releasemanifest/manifest.go new file mode 100644 index 0000000..ec33c7e --- /dev/null +++ b/internal/releasemanifest/manifest.go @@ -0,0 +1,138 @@ +package releasemanifest + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + + "github.com/z2z23n0/tooltend/internal/selfupdate" +) + +const KeyID = "tooltend-release-v1" + +type Options struct { + Version string + Repository string + AssetsDir string + PrivateKey ed25519.PrivateKey + PublishedAt time.Time +} + +type Output struct { + Envelope []byte + Checksums []byte + PublicKey string +} + +func Generate(options Options) (Output, error) { + version := strings.TrimPrefix(strings.TrimSpace(options.Version), "v") + parsed, err := semver.StrictNewVersion(version) + if err != nil || parsed.Prerelease() != "" || parsed.Metadata() != "" { + return Output{}, errors.New("release manifest: version must be a stable semantic version") + } + if err := ValidateSequenceVersion(parsed); err != nil { + return Output{}, err + } + if options.Repository == "" || strings.ContainsAny(options.Repository, "\x00\r\n") || strings.Count(options.Repository, "/") != 1 { + return Output{}, errors.New("release manifest: repository must be owner/name") + } + if !filepath.IsAbs(options.AssetsDir) { + return Output{}, errors.New("release manifest: assets directory must be absolute") + } + if len(options.PrivateKey) != ed25519.PrivateKeySize { + return Output{}, errors.New("release manifest: invalid Ed25519 private key") + } + published := options.PublishedAt.UTC() + if published.IsZero() { + published = time.Now().UTC() + } + platforms := []struct{ os, arch string }{{"darwin", "arm64"}, {"darwin", "amd64"}, {"linux", "arm64"}, {"linux", "amd64"}} + assets := make([]selfupdate.Asset, 0, len(platforms)) + checksums := map[string]string{} + for _, platform := range platforms { + name := fmt.Sprintf("tooltend-%s-%s", platform.os, platform.arch) + data, err := os.ReadFile(filepath.Join(options.AssetsDir, name)) + if err != nil { + return Output{}, fmt.Errorf("release manifest: read %s: %w", name, err) + } + if len(data) == 0 { + return Output{}, fmt.Errorf("release manifest: asset %s is empty", name) + } + digest := sha256.Sum256(data) + hash := hex.EncodeToString(digest[:]) + checksums[name] = hash + assets = append(assets, selfupdate.Asset{ + OS: platform.os, Arch: platform.arch, + URL: fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", options.Repository, version, name), + SHA256: hash, Size: int64(len(data)), + }) + } + manifest := selfupdate.Manifest{SchemaVersion: 1, Sequence: Sequence(parsed), Version: version, PublishedAt: published, Assets: assets} + manifestJSON, err := json.Marshal(manifest) + if err != nil { + return Output{}, err + } + envelope := selfupdate.Envelope{KeyID: KeyID, Manifest: manifestJSON, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(options.PrivateKey, manifestJSON))} + envelopeJSON, err := json.Marshal(envelope) + if err != nil { + return Output{}, err + } + envelopeJSON = append(envelopeJSON, '\n') + manifestDigest := sha256.Sum256(envelopeJSON) + checksums["tooltend-manifest.json"] = hex.EncodeToString(manifestDigest[:]) + names := make([]string, 0, len(checksums)) + for name := range checksums { + names = append(names, name) + } + sort.Strings(names) + var checksumText strings.Builder + for _, name := range names { + fmt.Fprintf(&checksumText, "%s %s\n", checksums[name], name) + } + publicKey := options.PrivateKey.Public().(ed25519.PublicKey) + return Output{Envelope: envelopeJSON, Checksums: []byte(checksumText.String()), PublicKey: hex.EncodeToString(publicKey)}, nil +} + +func Sequence(version *semver.Version) uint64 { + // Keep stable SemVer ordering deterministic while leaving one million + // patch slots per minor and one million minor slots per major. + return version.Major()*1_000_000_000_000 + version.Minor()*1_000_000 + version.Patch() + 1 +} + +func ValidateSequenceVersion(version *semver.Version) error { + const slots = uint64(1_000_000) + if version.Minor() >= slots || version.Patch() >= slots { + return errors.New("release manifest: minor and patch versions must be below 1000000") + } + tail := version.Minor()*slots + version.Patch() + 1 + if version.Major() > (^uint64(0)-tail)/(slots*slots) { + return errors.New("release manifest: version exceeds release sequence range") + } + return nil +} + +func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) { + data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil, errors.New("release manifest: private key is not valid base64") + } + switch len(data) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(data), nil + case ed25519.PrivateKeySize: + return ed25519.PrivateKey(data), nil + default: + return nil, errors.New("release manifest: private key must be a 32-byte seed or 64-byte private key") + } +} diff --git a/internal/releasemanifest/manifest_test.go b/internal/releasemanifest/manifest_test.go new file mode 100644 index 0000000..95be34f --- /dev/null +++ b/internal/releasemanifest/manifest_test.go @@ -0,0 +1,67 @@ +package releasemanifest + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + + "github.com/z2z23n0/tooltend/internal/selfupdate" +) + +func TestGenerateProducesVerifiableFourPlatformManifest(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + for _, name := range []string{"tooltend-darwin-arm64", "tooltend-darwin-amd64", "tooltend-linux-arm64", "tooltend-linux-amd64"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("binary-"+name), 0o755); err != nil { + t.Fatal(err) + } + } + result, err := Generate(Options{Version: "v0.2.0", Repository: "z2z23n0/tooltend", AssetsDir: dir, PrivateKey: privateKey, PublishedAt: time.Unix(1, 0)}) + if err != nil { + t.Fatal(err) + } + if result.PublicKey != hex.EncodeToString(publicKey) { + t.Fatal("generated public key does not match signer") + } + verifier := selfupdate.Verifier{Keys: map[string]ed25519.PublicKey{KeyID: publicKey}, OS: "darwin", Arch: "arm64"} + verified, err := verifier.Verify(result.Envelope) + if err != nil { + t.Fatal(err) + } + if verified.Manifest.Version != "0.2.0" || len(verified.Manifest.Assets) != 4 { + t.Fatalf("unexpected manifest: %#v", verified.Manifest) + } + tampered := append([]byte(nil), result.Envelope...) + tampered[len(tampered)/2] ^= 1 + if _, err := verifier.Verify(tampered); err == nil { + t.Fatal("tampered manifest was accepted") + } +} + +func TestSequenceFollowsStableSemver(t *testing.T) { + versions := []string{"0.1.999999", "0.2.0", "1.0.0", "1.0.1"} + var previous uint64 + for _, raw := range versions { + version := semver.MustParse(raw) + if err := ValidateSequenceVersion(version); err != nil { + t.Fatal(err) + } + current := Sequence(version) + if current <= previous { + t.Fatalf("release sequence does not preserve semver ordering: %s = %d after %d", raw, current, previous) + } + previous = current + } + if err := ValidateSequenceVersion(semver.MustParse("0.1000000.0")); err == nil { + t.Fatal("oversized minor version was accepted") + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index ed51456..dfcbaaf 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -102,6 +102,29 @@ func Activate(ctx context.Context, schedule Plan, runner execx.Runner) error { } } +// Deactivate pauses the registered one-shot schedule without deleting its +// files. A reset uses this before snapshotting state and re-activates either +// the new schedule or the restored old schedule. +func Deactivate(ctx context.Context, schedule Plan, runner execx.Runner) error { + if runner == nil { + runner = execx.ExecRunner{} + } + switch schedule.Platform { + case "launchd": + if len(schedule.Files) != 1 { + return errors.New("scheduler: invalid launchd plan") + } + domain := "gui/" + strconv.Itoa(os.Getuid()) + _, err := runner.Run(ctx, "launchctl", "bootout", domain, schedule.Files[0].Path) + return err + case "systemd": + _, err := runner.Run(ctx, "systemctl", "--user", "disable", "--now", "tooltend-reconcile.timer") + return err + default: + return fmt.Errorf("scheduler: unsupported plan platform %q", schedule.Platform) + } +} + func randomDailyTime() (int, int) { var value uint16 if err := binary.Read(rand.Reader, binary.LittleEndian, &value); err != nil { diff --git a/internal/selfupdate/manager.go b/internal/selfupdate/manager.go index 230deb9..2f81b95 100644 --- a/internal/selfupdate/manager.go +++ b/internal/selfupdate/manager.go @@ -26,13 +26,14 @@ var ErrHomebrewManaged = errors.New("self-update is managed by Homebrew; run bre type VerifierFactory func(currentSequence uint64) (Verifier, error) type Manager struct { - StateDir string - Executable string - ManifestURL string - Fetcher Fetcher - Verifier VerifierFactory - Now func() time.Time - CurrentVersion string + StateDir string + Executable string + ManifestURL string + Fetcher Fetcher + Verifier VerifierFactory + Now func() time.Time + CurrentVersion string + CurrentSequence uint64 // Test seams for deterministic concurrency barriers. Production managers // leave both callbacks nil. @@ -70,7 +71,7 @@ type PreparedRelease struct { } func (m Manager) Status() (Status, error) { - sequence, err := readSequence(m.StateDir) + sequence, err := m.currentSequence() if err != nil { return Status{}, err } @@ -102,7 +103,7 @@ func (m Manager) Check(ctx context.Context) (Verified, error) { // Prepare binds a later confirmed stage operation to the exact signed // envelope shown in its preview. It is read-only and does not fetch the asset. func (m Manager) Prepare(ctx context.Context) (PreparedRelease, error) { - sequence, err := readSequence(m.StateDir) + sequence, err := m.currentSequence() if err != nil { return PreparedRelease{}, err } @@ -159,7 +160,7 @@ func (m Manager) StagePrepared(ctx context.Context, prepared PreparedRelease) (p } func (m Manager) stagePreparedLocked(ctx context.Context, prepared PreparedRelease) (Pending, error) { - sequence, err := readSequence(m.StateDir) + sequence, err := m.currentSequence() if err != nil { return Pending{}, err } @@ -255,7 +256,7 @@ func (m Manager) applyPendingLocked(ctx context.Context) (ApplyResult, error) { if isHomebrewExecutable(executable) { return ApplyResult{}, ErrHomebrewManaged } - sequence, err := readSequence(m.StateDir) + sequence, err := m.currentSequence() if err != nil { return ApplyResult{}, err } @@ -509,6 +510,17 @@ func readPending(stateDir string) (*Pending, error) { func sequenceFile(stateDir string) string { return filepath.Join(stateDir, "self-update", "sequence") } +func (m Manager) currentSequence() (uint64, error) { + stored, err := readSequence(m.StateDir) + if err != nil { + return 0, err + } + if m.CurrentSequence > stored { + return m.CurrentSequence, nil + } + return stored, nil +} + func readSequence(stateDir string) (uint64, error) { data, err := os.ReadFile(sequenceFile(stateDir)) if errors.Is(err, os.ErrNotExist) { diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go index 3ab92cd..59611f5 100644 --- a/internal/selfupdate/verify.go +++ b/internal/selfupdate/verify.go @@ -22,6 +22,19 @@ const MaxManifestBytes = 1 << 20 // self-update. var ReleasePublicKeyHex string +type SignatureCapability struct { + KeyID string `json:"key_id"` + Embedded bool `json:"embedded"` + Valid bool `json:"valid"` +} + +func EmbeddedSignatureCapability() SignatureCapability { + value := SignatureCapability{KeyID: "tooltend-release-v1", Embedded: ReleasePublicKeyHex != ""} + key, err := hex.DecodeString(ReleasePublicKeyHex) + value.Valid = err == nil && len(key) == ed25519.PublicKeySize + return value +} + type Envelope struct { KeyID string `json:"key_id"` Manifest json.RawMessage `json:"manifest"` diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index 0ffff87..0ad3b6c 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "strings" "testing" "time" ) @@ -61,3 +62,16 @@ func TestVerifierRejectsReplayAndDuplicateKeys(t *testing.T) { t.Fatal("expected replay rejection") } } + +func TestEmbeddedSignatureCapabilityDoesNotExposeKeyMaterial(t *testing.T) { + original := ReleasePublicKeyHex + t.Cleanup(func() { ReleasePublicKeyHex = original }) + ReleasePublicKeyHex = "" + if value := EmbeddedSignatureCapability(); value.Embedded || value.Valid || value.KeyID == "" { + t.Fatalf("development capability = %+v", value) + } + ReleasePublicKeyHex = strings.Repeat("ab", ed25519.PublicKeySize) + if value := EmbeddedSignatureCapability(); !value.Embedded || !value.Valid { + t.Fatalf("release capability = %+v", value) + } +} diff --git a/internal/store/bundles.go b/internal/store/bundles.go new file mode 100644 index 0000000..bfa50ec --- /dev/null +++ b/internal/store/bundles.go @@ -0,0 +1,748 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/z2z23n0/tooltend/internal/model" +) + +type BundleCounts struct { + Total int `json:"total"` + Configured int `json:"configured"` + Unconfigured int `json:"unconfigured"` + Managed int `json:"managed"` + Observe int `json:"observe"` + Unresolved int `json:"unresolved"` + FailedTransactions int `json:"failed_transactions"` + UpdatesAvailable int `json:"updates_available"` +} + +type InstallationObservation struct { + InstallationID string + Version string + Hash string +} + +func (s *Store) UpsertBundle(ctx context.Context, value model.Bundle) error { + if err := value.Owner.Validate(); err != nil { + return err + } + if err := value.ConfigState.Validate(); err != nil { + return err + } + if err := value.Confidence.Validate(); err != nil { + return err + } + if value.ID == "" || value.Slug == "" || value.Name == "" || value.RecipeID == "" || value.RecipeVersion == "" { + return errors.New("store: bundle identity is incomplete") + } + if value.MetadataJSON == "" { + value.MetadataJSON = "{}" + } + now := time.Now().UTC() + if value.DiscoveredAt.IsZero() { + value.DiscoveredAt = now + } + if value.LastSeenAt.IsZero() { + value.LastSeenAt = now + } + _, err := s.db.ExecContext(ctx, `INSERT INTO bundles(id,slug,name,recipe_id,recipe_version,recipe_source,lifecycle_owner,config_state,confidence,current_release_id,metadata_json,discovered_at,last_seen_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET slug=excluded.slug,name=excluded.name, + recipe_id=excluded.recipe_id,recipe_version=excluded.recipe_version,recipe_source=excluded.recipe_source, + lifecycle_owner=excluded.lifecycle_owner,confidence=excluded.confidence,metadata_json=excluded.metadata_json,last_seen_at=excluded.last_seen_at`, + value.ID, value.Slug, value.Name, value.RecipeID, value.RecipeVersion, value.RecipeSource, value.Owner, + value.ConfigState, value.Confidence, nullIfEmpty(value.CurrentReleaseID), value.MetadataJSON, + timeText(value.DiscoveredAt), timeText(value.LastSeenAt)) + if err != nil { + return fmt.Errorf("store: upsert bundle: %w", err) + } + return nil +} + +func (s *Store) GetBundle(ctx context.Context, id string) (model.Bundle, error) { + return scanBundle(s.db.QueryRowContext(ctx, `SELECT id,slug,name,recipe_id,recipe_version,recipe_source,lifecycle_owner,config_state,confidence,current_release_id,metadata_json,discovered_at,last_seen_at FROM bundles WHERE id=?`, id)) +} + +func (s *Store) GetBundleBySlug(ctx context.Context, slug string) (model.Bundle, error) { + return scanBundle(s.db.QueryRowContext(ctx, `SELECT id,slug,name,recipe_id,recipe_version,recipe_source,lifecycle_owner,config_state,confidence,current_release_id,metadata_json,discovered_at,last_seen_at FROM bundles WHERE slug=? COLLATE NOCASE`, slug)) +} + +func (s *Store) ListBundles(ctx context.Context) ([]model.Bundle, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,slug,name,recipe_id,recipe_version,recipe_source,lifecycle_owner,config_state,confidence,current_release_id,metadata_json,discovered_at,last_seen_at FROM bundles ORDER BY lower(name),slug`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []model.Bundle{} + for rows.Next() { + value, scanErr := scanBundle(rows) + if scanErr != nil { + return nil, scanErr + } + result = append(result, value) + } + return result, rows.Err() +} + +type bundleScanner interface{ Scan(...any) error } + +func scanBundle(row bundleScanner) (model.Bundle, error) { + var value model.Bundle + var current sql.NullString + var discovered, seen string + if err := row.Scan(&value.ID, &value.Slug, &value.Name, &value.RecipeID, &value.RecipeVersion, &value.RecipeSource, + &value.Owner, &value.ConfigState, &value.Confidence, ¤t, &value.MetadataJSON, &discovered, &seen); err != nil { + return value, err + } + value.CurrentReleaseID = current.String + var err error + value.DiscoveredAt, err = parseTime(discovered) + if err != nil { + return value, err + } + value.LastSeenAt, err = parseTime(seen) + return value, err +} + +// PruneUnconfiguredBundles removes stale discovery-only rows. Configured +// bundles and their receipts are durable lifecycle state and are never +// deleted by a scan. +func (s *Store) PruneUnconfiguredBundles(ctx context.Context, seenBefore time.Time) (int64, error) { + result, err := s.db.ExecContext(ctx, `DELETE FROM bundles WHERE config_state='unconfigured' AND last_seen_atCOALESCE(b.current_release_id,'')`).Scan(&value.UpdatesAvailable); err != nil { + return value, err + } + return value, nil +} + +func (s *Store) ListUnfinishedBundleTransactions(ctx context.Context) ([]model.BundleTransaction, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,bundle_id,from_release_id,to_release_id,status,stage_only,error_code,error_summary,started_at,updated_at,completed_at FROM bundle_transactions WHERE status IN ('prepared','staging','activating','rolling_back') ORDER BY started_at,id`) + if err != nil { + return nil, err + } + defer rows.Close() + var result []model.BundleTransaction + for rows.Next() { + value, scanErr := scanBundleTransaction(rows) + if scanErr != nil { + return nil, scanErr + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) PutBundleTransaction(ctx context.Context, value model.BundleTransaction) error { + if err := value.Status.Validate(); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, `INSERT INTO bundle_transactions(id,bundle_id,from_release_id,to_release_id,status,stage_only,error_code,error_summary,started_at,updated_at,completed_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, value.ID, value.BundleID, nullIfEmpty(value.FromReleaseID), nullIfEmpty(value.ToReleaseID), value.Status, + boolInt(value.StageOnly), value.ErrorCode, value.ErrorSummary, timeText(value.StartedAt), timeText(value.UpdatedAt), nullableTimeText(value.CompletedAt)) + return err +} + +func (s *Store) UpdateBundleTransaction(ctx context.Context, id string, status model.BundleTransactionStatus, code, summary string, completed *time.Time) error { + if err := status.Validate(); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, `UPDATE bundle_transactions SET status=?,error_code=?,error_summary=?,updated_at=?,completed_at=? WHERE id=?`, + status, code, summary, timeText(time.Now().UTC()), nullableTimeText(completed), id) + return err +} + +func (s *Store) PutBundleTransactionStep(ctx context.Context, value model.BundleTransactionStep) error { + if err := value.Status.Validate(); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, `INSERT INTO bundle_transaction_steps(id,transaction_id,ordinal,artifact_id,installation_id,kind,status,command_json,rollback_json,before_json,after_json,error_code,error_summary,started_at,completed_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, value.ID, value.TransactionID, value.Ordinal, nullIfEmpty(value.ArtifactID), nullIfEmpty(value.InstallationID), + value.Kind, value.Status, jsonOr(value.CommandJSON, "[]"), jsonOr(value.RollbackJSON, "[]"), jsonOr(value.BeforeJSON, "{}"), jsonOr(value.AfterJSON, "{}"), + value.ErrorCode, value.ErrorSummary, nullableTimeText(value.StartedAt), nullableTimeText(value.CompletedAt)) + return err +} + +func (s *Store) UpdateBundleTransactionStep(ctx context.Context, id string, status model.BundleStepStatus, code, summary, afterJSON string, completed *time.Time) error { + if err := status.Validate(); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, `UPDATE bundle_transaction_steps SET status=?,error_code=?,error_summary=?,after_json=?,completed_at=? WHERE id=?`, + status, code, summary, jsonOr(afterJSON, "{}"), nullableTimeText(completed), id) + return err +} + +func (s *Store) ListBundleTransactionSteps(ctx context.Context, transactionID string) ([]model.BundleTransactionStep, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,transaction_id,ordinal,artifact_id,installation_id,kind,status,command_json,rollback_json,before_json,after_json,error_code,error_summary,started_at,completed_at FROM bundle_transaction_steps WHERE transaction_id=? ORDER BY ordinal,id`, transactionID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []model.BundleTransactionStep + for rows.Next() { + var value model.BundleTransactionStep + var artifact, installation, started, completed sql.NullString + if err := rows.Scan(&value.ID, &value.TransactionID, &value.Ordinal, &artifact, &installation, &value.Kind, &value.Status, + &value.CommandJSON, &value.RollbackJSON, &value.BeforeJSON, &value.AfterJSON, &value.ErrorCode, &value.ErrorSummary, &started, &completed); err != nil { + return nil, err + } + value.ArtifactID, value.InstallationID = artifact.String, installation.String + if started.Valid { + parsed, err := parseTime(started.String) + if err != nil { + return nil, err + } + value.StartedAt = &parsed + } + if completed.Valid { + parsed, err := parseTime(completed.String) + if err != nil { + return nil, err + } + value.CompletedAt = &parsed + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) PutBundleReceipt(ctx context.Context, value model.BundleReceipt) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO bundle_receipts(id,bundle_id,transaction_id,release_id,action,status,summary_json,created_at) VALUES(?,?,?,?,?,?,?,?)`, + value.ID, value.BundleID, nullIfEmpty(value.TransactionID), nullIfEmpty(value.ReleaseID), value.Action, value.Status, jsonOr(value.SummaryJSON, "{}"), timeText(value.CreatedAt)) + return err +} + +func (s *Store) ListBundleReceipts(ctx context.Context, bundleID string, limit int) ([]model.BundleReceipt, error) { + if limit <= 0 { + limit = 100 + } + query := `SELECT id,bundle_id,transaction_id,release_id,action,status,summary_json,created_at FROM bundle_receipts` + args := []any{} + if bundleID != "" { + query += ` WHERE bundle_id=?` + args = append(args, bundleID) + } + query += ` ORDER BY created_at DESC,id DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + result := []model.BundleReceipt{} + for rows.Next() { + var value model.BundleReceipt + var transaction, release sql.NullString + var created string + if err := rows.Scan(&value.ID, &value.BundleID, &transaction, &release, &value.Action, &value.Status, &value.SummaryJSON, &created); err != nil { + return nil, err + } + value.TransactionID, value.ReleaseID = transaction.String, release.String + value.CreatedAt, err = parseTime(created) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) PutBundleHealthCheck(ctx context.Context, value model.BundleHealthCheck) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO bundle_health_checks(id,bundle_id,artifact_id,installation_id,name,status,summary,checked_at) VALUES(?,?,?,?,?,?,?,?)`, + value.ID, value.BundleID, nullIfEmpty(value.ArtifactID), nullIfEmpty(value.InstallationID), value.Name, value.Status, value.Summary, timeText(value.CheckedAt)) + return err +} + +func (s *Store) ListBundleHealthChecks(ctx context.Context, bundleID string, limit int) ([]model.BundleHealthCheck, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, `SELECT id,bundle_id,artifact_id,installation_id,name,status,summary,checked_at FROM bundle_health_checks WHERE bundle_id=? ORDER BY checked_at DESC,id DESC LIMIT ?`, bundleID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := []model.BundleHealthCheck{} + for rows.Next() { + var value model.BundleHealthCheck + var artifact, installation sql.NullString + var checked string + if err := rows.Scan(&value.ID, &value.BundleID, &artifact, &installation, &value.Name, &value.Status, &value.Summary, &checked); err != nil { + return nil, err + } + value.ArtifactID, value.InstallationID = artifact.String, installation.String + value.CheckedAt, err = parseTime(checked) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) EnqueueBundleTask(ctx context.Context, value model.BundleTask) (bool, error) { + if value.ID == "" || value.BundleID == "" || value.IdempotencyKey == "" || value.Kind == "" { + return false, errors.New("store: bundle task identity is incomplete") + } + if value.Status == "" { + value.Status = model.TaskPending + } + if err := value.Status.Validate(); err != nil { + return false, err + } + result, err := s.db.ExecContext(ctx, `INSERT INTO bundle_tasks(id,bundle_id,installation_id,kind,idempotency_key,status,attempts,next_attempt_at,lease_until,error_code,error_summary,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(idempotency_key) DO NOTHING`, value.ID, value.BundleID, nullIfEmpty(value.InstallationID), value.Kind, + value.IdempotencyKey, value.Status, value.Attempts, timeText(value.NextAttemptAt), nullableTimeText(value.LeaseUntil), value.ErrorCode, value.ErrorSummary, + timeText(value.CreatedAt), timeText(value.UpdatedAt)) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count == 1, err +} + +func (s *Store) ClaimBundleTask(ctx context.Context, now time.Time, lease time.Duration) (model.BundleTask, error) { + var claimed model.BundleTask + err := s.WithTx(ctx, func(tx *sql.Tx) error { + nowText := timeText(now) + if _, err := tx.ExecContext(ctx, `UPDATE bundle_tasks SET status='pending',lease_until=NULL,updated_at=? WHERE status='running' AND lease_until IS NOT NULL AND lease_until<=?`, nowText, nowText); err != nil { + return err + } + var id string + if err := tx.QueryRowContext(ctx, `SELECT id FROM bundle_tasks WHERE status='pending' AND next_attempt_at<=? ORDER BY next_attempt_at,created_at,id LIMIT 1`, nowText).Scan(&id); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE bundle_tasks SET status='running',attempts=attempts+1,lease_until=?,updated_at=? WHERE id=? AND status='pending'`, timeText(now.Add(lease)), nowText, id) + if err != nil { + return err + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + if err != nil { + return err + } + return errors.New("store: bundle task claim raced") + } + var installation, leaseText sql.NullString + var next, created, updated string + if err := tx.QueryRowContext(ctx, `SELECT id,bundle_id,installation_id,kind,idempotency_key,status,attempts,next_attempt_at,lease_until,error_code,error_summary,created_at,updated_at FROM bundle_tasks WHERE id=?`, id). + Scan(&claimed.ID, &claimed.BundleID, &installation, &claimed.Kind, &claimed.IdempotencyKey, &claimed.Status, &claimed.Attempts, &next, + &leaseText, &claimed.ErrorCode, &claimed.ErrorSummary, &created, &updated); err != nil { + return err + } + claimed.InstallationID = installation.String + claimed.NextAttemptAt, err = parseTime(next) + if err != nil { + return err + } + claimed.LeaseUntil, err = scanNullableTime(leaseText) + if err != nil { + return err + } + claimed.CreatedAt, err = parseTime(created) + if err != nil { + return err + } + claimed.UpdatedAt, err = parseTime(updated) + return err + }) + return claimed, err +} + +func (s *Store) CompleteBundleTask(ctx context.Context, id string) error { + return updateBundleTaskTerminal(ctx, s.db, id, "succeeded", "", "") +} + +func (s *Store) FailBundleTask(ctx context.Context, id, code string) error { + return updateBundleTaskTerminal(ctx, s.db, id, "failed", code, "") +} + +func (s *Store) RetryBundleTask(ctx context.Context, id, code string, next time.Time) error { + result, err := s.db.ExecContext(ctx, `UPDATE bundle_tasks SET status='pending',lease_until=NULL,error_code=?,error_summary='',next_attempt_at=?,updated_at=? WHERE id=? AND status='running'`, code, timeText(next), timeText(time.Now()), id) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return sql.ErrNoRows + } + return nil +} + +func updateBundleTaskTerminal(ctx context.Context, db *sql.DB, id, status, code, summary string) error { + result, err := db.ExecContext(ctx, `UPDATE bundle_tasks SET status=?,lease_until=NULL,error_code=?,error_summary=?,updated_at=? WHERE id=? AND status='running'`, status, code, summary, timeText(time.Now()), id) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return sql.ErrNoRows + } + return nil +} + +func scanBundleTransaction(row bundleScanner) (model.BundleTransaction, error) { + var value model.BundleTransaction + var from, to, completed sql.NullString + var stageOnly int + var started, updated string + if err := row.Scan(&value.ID, &value.BundleID, &from, &to, &value.Status, &stageOnly, &value.ErrorCode, &value.ErrorSummary, &started, &updated, &completed); err != nil { + return value, err + } + value.FromReleaseID, value.ToReleaseID, value.StageOnly = from.String, to.String, stageOnly != 0 + var err error + value.StartedAt, err = parseTime(started) + if err != nil { + return value, err + } + value.UpdatedAt, err = parseTime(updated) + if err != nil { + return value, err + } + if completed.Valid { + parsed, parseErr := parseTime(completed.String) + if parseErr != nil { + return value, parseErr + } + value.CompletedAt = &parsed + } + return value, nil +} + +func jsonOr(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/internal/store/bundles_test.go b/internal/store/bundles_test.go new file mode 100644 index 0000000..6f53f53 --- /dev/null +++ b/internal/store/bundles_test.go @@ -0,0 +1,62 @@ +package store + +import ( + "context" + "io/fs" + "path/filepath" + "sort" + "strconv" + "testing" +) + +func TestSchemaV5MigratesV4WithBackup(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + db, err := open(path, "rwc", 0, false) + if err != nil { + t.Fatal(err) + } + entries, err := fs.ReadDir(migrationFiles, "migrations") + if err != nil { + t.Fatal(err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + version, ok := migrationVersion(entry.Name()) + if !ok || version > 4 { + continue + } + body, err := migrationFiles.ReadFile("migrations/" + entry.Name()) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(string(body)); err != nil { + t.Fatalf("apply %s: %v", entry.Name(), err) + } + if _, err := db.Exec("PRAGMA user_version = " + strconv.Itoa(version)); err != nil { + t.Fatal(err) + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + database, err := OpenRW(path) + if err != nil { + t.Fatal(err) + } + defer database.Close() + version, err := database.UserVersion(context.Background()) + if err != nil || version != 5 { + t.Fatalf("version=%d err=%v", version, err) + } + var tables int + if err := database.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('bundles','bundle_releases','bundle_artifacts','installations','consumer_bindings','bundle_policies','bundle_transactions','bundle_transaction_steps','bundle_receipts','bundle_health_checks','bundle_tasks')`).Scan(&tables); err != nil { + t.Fatal(err) + } + if tables != 11 { + t.Fatalf("bundle tables = %d", tables) + } + backups, err := filepath.Glob(path + ".backup-v4-*") + if err != nil || len(backups) != 1 { + t.Fatalf("migration backups = %v err=%v", backups, err) + } +} diff --git a/internal/store/inventory.go b/internal/store/inventory.go index cfb87ff..9b3017a 100644 --- a/internal/store/inventory.go +++ b/internal/store/inventory.go @@ -367,6 +367,26 @@ func (s *Store) PutDependency(ctx context.Context, value model.Dependency) error return err } +func (s *Store) ListDependencies(ctx context.Context) ([]model.Dependency, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,from_component_id,to_component_id,package_identity,constraint_text,evidence_path,evidence_line,explicit FROM dependencies ORDER BY from_component_id,package_identity,id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []model.Dependency{} + for rows.Next() { + var value model.Dependency + var to sql.NullString + var explicit int + if err := rows.Scan(&value.ID, &value.FromComponentID, &to, &value.PackageIdentity, &value.Constraint, &value.EvidencePath, &value.EvidenceLine, &explicit); err != nil { + return nil, err + } + value.ToComponentID, value.Explicit = to.String, explicit != 0 + result = append(result, value) + } + return result, rows.Err() +} + func (s *Store) PutGeneration(ctx context.Context, value model.Generation) error { _, err := s.db.ExecContext(ctx, `INSERT INTO generations(id,binding_id,candidate_id,resolved_ref,tree_hash,integrity_hash,state,created_at,activated_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET candidate_id=excluded.candidate_id,resolved_ref=excluded.resolved_ref,tree_hash=excluded.tree_hash,integrity_hash=excluded.integrity_hash,state=excluded.state,activated_at=excluded.activated_at`, diff --git a/internal/store/migrations/0005_bundle_lifecycle.sql b/internal/store/migrations/0005_bundle_lifecycle.sql new file mode 100644 index 0000000..c894778 --- /dev/null +++ b/internal/store/migrations/0005_bundle_lifecycle.sql @@ -0,0 +1,157 @@ +CREATE TABLE bundles ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + recipe_id TEXT NOT NULL, + recipe_version TEXT NOT NULL, + recipe_source TEXT NOT NULL CHECK (recipe_source IN ('builtin','local','fallback')), + lifecycle_owner TEXT NOT NULL CHECK (lifecycle_owner IN ('tooltend','delegated','host-owned','app-owned','workspace-linked','unresolved')), + config_state TEXT NOT NULL DEFAULT 'unconfigured' CHECK (config_state IN ('unconfigured','configured')), + confidence TEXT NOT NULL CHECK (confidence IN ('high','medium','low','unresolved')), + current_release_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + discovered_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL +); + +CREATE TABLE bundle_releases ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + version TEXT NOT NULL, + resolved_ref TEXT NOT NULL DEFAULT '', + manifest_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(manifest_json)), + status TEXT NOT NULL CHECK (status IN ('observed','resolved','staged','active','superseded','failed')), + created_at TEXT NOT NULL, + UNIQUE(bundle_id, version, resolved_ref) +); + +CREATE TABLE bundle_artifacts ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + release_id TEXT REFERENCES bundle_releases(id) ON DELETE SET NULL, + recipe_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('cli','skill','hook','app','config','embedded_binary','plugin','mcp')), + name TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + required INTEGER NOT NULL DEFAULT 1 CHECK (required IN (0,1)), + driver TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + UNIQUE(bundle_id, recipe_key) +); + +CREATE TABLE installations ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + artifact_id TEXT REFERENCES bundle_artifacts(id) ON DELETE SET NULL, + driver TEXT NOT NULL, + normalized_path TEXT NOT NULL, + package_identity TEXT NOT NULL DEFAULT '', + source_identity TEXT NOT NULL DEFAULT '', + observed_version TEXT NOT NULL DEFAULT '', + observed_hash TEXT NOT NULL DEFAULT '', + lifecycle_owner TEXT NOT NULL CHECK (lifecycle_owner IN ('tooltend','delegated','host-owned','app-owned','workspace-linked','unresolved')), + managed INTEGER NOT NULL DEFAULT 0 CHECK (managed IN (0,1)), + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + last_seen_at TEXT NOT NULL, + UNIQUE(driver, normalized_path, package_identity, source_identity) +); + +CREATE TABLE consumer_bindings ( + id TEXT PRIMARY KEY, + installation_id TEXT NOT NULL REFERENCES installations(id) ON DELETE CASCADE, + binding_id TEXT REFERENCES bindings(id) ON DELETE SET NULL, + host TEXT NOT NULL CHECK (host IN ('codex','claude','system')), + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK (scope IN ('global','project')), + config_path TEXT NOT NULL DEFAULT '', + config_pointer TEXT NOT NULL DEFAULT '', + last_seen_at TEXT NOT NULL, + UNIQUE(installation_id, host, project_id, config_path, config_pointer) +); + +CREATE TABLE bundle_policies ( + bundle_id TEXT PRIMARY KEY REFERENCES bundles(id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('auto','manual','observe','ignore')), + recipe_trusted INTEGER NOT NULL DEFAULT 0 CHECK (recipe_trusted IN (0,1)), + updated_at TEXT NOT NULL +); + +CREATE TABLE bundle_transactions ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE RESTRICT, + from_release_id TEXT REFERENCES bundle_releases(id) ON DELETE SET NULL, + to_release_id TEXT REFERENCES bundle_releases(id) ON DELETE SET NULL, + status TEXT NOT NULL CHECK (status IN ('prepared','staging','activating','committed','rolling_back','rolled_back','failed')), + stage_only INTEGER NOT NULL DEFAULT 0 CHECK (stage_only IN (0,1)), + error_code TEXT NOT NULL DEFAULT '', + error_summary TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT +); + +CREATE TABLE bundle_transaction_steps ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES bundle_transactions(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + artifact_id TEXT REFERENCES bundle_artifacts(id) ON DELETE SET NULL, + installation_id TEXT REFERENCES installations(id) ON DELETE SET NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending','staged','activating','activated','healthy','compensating','compensated','failed')), + command_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(command_json)), + rollback_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(rollback_json)), + before_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(before_json)), + after_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(after_json)), + error_code TEXT NOT NULL DEFAULT '', + error_summary TEXT NOT NULL DEFAULT '', + started_at TEXT, + completed_at TEXT, + UNIQUE(transaction_id, ordinal) +); + +CREATE TABLE bundle_receipts ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + transaction_id TEXT REFERENCES bundle_transactions(id) ON DELETE SET NULL, + release_id TEXT REFERENCES bundle_releases(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK (action IN ('update','rollback','observe')), + status TEXT NOT NULL CHECK (status IN ('succeeded','rolled_back','failed')), + summary_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(summary_json)), + created_at TEXT NOT NULL +); + +CREATE TABLE bundle_health_checks ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + artifact_id TEXT REFERENCES bundle_artifacts(id) ON DELETE SET NULL, + installation_id TEXT REFERENCES installations(id) ON DELETE SET NULL, + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('healthy','warning','failed','unknown')), + summary TEXT NOT NULL DEFAULT '', + checked_at TEXT NOT NULL +); + +CREATE TABLE bundle_tasks ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + installation_id TEXT REFERENCES installations(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('pending','running','succeeded','failed')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TEXT NOT NULL, + lease_until TEXT, + error_code TEXT NOT NULL DEFAULT '', + error_summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_bundles_state_owner ON bundles(config_state, lifecycle_owner); +CREATE INDEX idx_bundle_artifacts_bundle ON bundle_artifacts(bundle_id, ordinal); +CREATE INDEX idx_installations_bundle ON installations(bundle_id); +CREATE INDEX idx_consumer_bindings_installation ON consumer_bindings(installation_id); +CREATE INDEX idx_bundle_transactions_unfinished ON bundle_transactions(status) WHERE status IN ('prepared','staging','activating','rolling_back'); +CREATE INDEX idx_bundle_receipts_created ON bundle_receipts(bundle_id, created_at DESC); +CREATE INDEX idx_bundle_health_latest ON bundle_health_checks(bundle_id, checked_at DESC); +CREATE INDEX idx_bundle_tasks_due ON bundle_tasks(status, next_attempt_at); diff --git a/internal/store/store.go b/internal/store/store.go index 49dc7d8..80d98c3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -17,7 +17,7 @@ import ( _ "modernc.org/sqlite" ) -const SchemaVersion = 4 +const SchemaVersion = 5 //go:embed migrations/*.sql var migrationFiles embed.FS From 5d2c8c239bbf337c85816358b6b94c78fb73afeb Mon Sep 17 00:00:00 2001 From: z2z23n0 Date: Wed, 15 Jul 2026 19:04:44 +0800 Subject: [PATCH 2/2] fix: format bundle discovery options --- internal/cli/init_scan.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/init_scan.go b/internal/cli/init_scan.go index 000b453..e32252e 100644 --- a/internal/cli/init_scan.go +++ b/internal/cli/init_scan.go @@ -219,7 +219,7 @@ func (a *App) newInitCommand() *cobra.Command { bundleInventory, openErr = bundle.Discover(ctx, database, bundle.DiscoverOptions{ HomeDir: a.home, Executable: a.executable, BuildVersion: buildinfo.Version, LocalRecipeDir: filepath.Join(paths.ConfigDir, "bundles.d"), - LookupPath: a.lookupPath, + LookupPath: a.lookupPath, }) return openErr }, @@ -710,7 +710,7 @@ func (a *App) newScanCommand() *cobra.Command { bundleInventory, persistErr = bundle.Discover(ctx, database, bundle.DiscoverOptions{ HomeDir: a.home, Executable: a.executable, BuildVersion: buildinfo.Version, LocalRecipeDir: filepath.Join(paths.ConfigDir, "bundles.d"), - LookupPath: a.lookupPath, + LookupPath: a.lookupPath, }) return persistErr })