diff --git a/.env.example b/.env.example index 00b52a9..ea8e344 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,9 @@ TUNNEL_DOMAIN= # Antidetect Browser MCP # Local: http://localhost:8765/mcp # Remote: http://:8765/mcp -ANTIDETECT_BROWSER_MCP_URL=http://localhost:8765/mcp \ No newline at end of file +ANTIDETECT_BROWSER_MCP_URL=http://localhost:8765/mcp + +# OpenCode Memory (required — memory-setup tool exits 1 if OPENCODE_MEMORY_REMOTE unset) +# Point at your fork of opencode-memory (see README "Memory setup" for the upstream repo) +OPENCODE_MEMORY_REMOTE=https://github.com/your-username/your-opencode-memory.git +OPENCODE_MEMORY_DIR=/root/.local/share/opencode/opencode-memory \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6cfd13b..67b37f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ app_data/workspaces/* app_data/ssh/* !app_data/ssh/.gitkeep +# opencode-memory lives in app_data/ as a separate git repo (clone target of memory-setup) +app_data/opencode-memory/ + # opencode plugin node_modules .opencode/node_modules/ diff --git a/.opencode/scripts/setup-memory.sh b/.opencode/scripts/setup-memory.sh index 317dc30..e724e1d 100755 --- a/.opencode/scripts/setup-memory.sh +++ b/.opencode/scripts/setup-memory.sh @@ -1,46 +1,80 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail -MEMORY_DIR="${OPENCODE_MEMORY_DIR}" -REMOTE="${OPENCODE_MEMORY_REMOTE:-https://github.com/slaid098/opencode-memory.git}" +MEMORY_DIR="${OPENCODE_MEMORY_DIR:-/root/.local/share/opencode/opencode-memory}" +HOOK="$MEMORY_DIR/.git/hooks/post-commit" +BRANCH="master" +EXPECTED_HOOK="#!/bin/bash +git push origin ${BRANCH} 2>/dev/null || true" -if [ -z "$MEMORY_DIR" ]; then - echo "ERROR: OPENCODE_MEMORY_DIR not set" +if [ -z "${OPENCODE_MEMORY_REMOTE:-}" ]; then + echo "ERROR: OPENCODE_MEMORY_REMOTE not set" >&2 exit 1 fi +REMOTE="${OPENCODE_MEMORY_REMOTE}" -echo "Setting up memory repo at: $MEMORY_DIR" +echo "memory setup: $MEMORY_DIR" -# 1. If .git doesn't exist — init + pull -if [ ! -d "$MEMORY_DIR/.git" ]; then - echo "Initializing new memory repo..." +# 1. MEMORY_DIR exists? → mkdir -p +if [ ! -d "$MEMORY_DIR" ]; then mkdir -p "$MEMORY_DIR" - git init "$MEMORY_DIR" - git -C "$MEMORY_DIR" remote add origin "$REMOTE" - git -C "$MEMORY_DIR" pull origin master 2>/dev/null || echo "No remote memory yet — starting fresh" + echo " [1/6] created directory: $MEMORY_DIR" else - echo "Memory repo already exists." - # 2. If remote not configured — add it - if ! git -C "$MEMORY_DIR" remote get-url origin 2>/dev/null; then - git -C "$MEMORY_DIR" remote add origin "$REMOTE" - fi - # 3. Pull latest changes - echo "Pulling latest memory..." - git -C "$MEMORY_DIR" pull --rebase origin master 2>/dev/null || echo "Pull failed — continuing with local state" + echo " [1/6] directory exists" fi -# 4. Install post-commit hook for auto-push -HOOK="$MEMORY_DIR/.git/hooks/post-commit" -echo "Installing post-commit hook for auto-push..." -cat > "$HOOK" << 'EOF' -#!/bin/bash -git push origin master 2>/dev/null || true -EOF -chmod +x "$HOOK" +# 2. .git exists? → clone (no) | pull --ff-only (yes) +if [ ! -d "$MEMORY_DIR/.git" ]; then + echo " [2/6] cloning remote: $REMOTE" + git clone --origin origin "$REMOTE" "$MEMORY_DIR" || { echo "ERROR: clone failed" >&2; exit 1; } + git -C "$MEMORY_DIR" checkout "$BRANCH" 2>/dev/null || true +else + echo " [2/6] pulling latest (ff-only)" + git -C "$MEMORY_DIR" pull --ff-only origin "$BRANCH" 2>/dev/null || \ + echo " pull skipped (no upstream or offline)" +fi +# 3. remote origin correct? → set-url (no) | noop (yes) +CURRENT_REMOTE="$(git -C "$MEMORY_DIR" remote get-url origin 2>/dev/null || echo "")" +if [ "$CURRENT_REMOTE" != "$REMOTE" ]; then + echo " [3/6] fixing remote: $CURRENT_REMOTE → $REMOTE" + git -C "$MEMORY_DIR" remote set-url origin "$REMOTE" +else + echo " [3/6] remote correct" +fi + +# 4. post-commit hook exists + content correct? → create/fix (no) | noop (yes) +NEEDS_HOOK=0 +if [ ! -f "$HOOK" ]; then + NEEDS_HOOK=1 +elif [ "$(cat "$HOOK")" != "$EXPECTED_HOOK" ]; then + NEEDS_HOOK=1 +fi +if [ "$NEEDS_HOOK" -eq 1 ]; then + echo " [4/6] installing post-commit hook (auto-push)" + mkdir -p "$(dirname "$HOOK")" + printf '%s\n' "$EXPECTED_HOOK" > "$HOOK" + chmod +x "$HOOK" +else + echo " [4/6] hook correct" +fi + +# 5. .rag index exists? → rag index (no) | noop (yes) +if command -v rag >/dev/null 2>&1; then + if [ ! -d "$MEMORY_DIR/.rag" ]; then + echo " [5/6] building RAG index" + (cd "$MEMORY_DIR" && rag index) 2>/dev/null || echo " rag index failed — continuing" + else + echo " [5/6] RAG index exists" + fi +else + echo " [5/6] rag CLI not installed — skipping index" +fi + +# 6. status +echo " [6/6] done" echo "" -echo "Memory repo configured successfully." -echo " Remote: $REMOTE" -echo " Auto-push: enabled (post-commit hook)" -echo "" -echo "memory_save() will now auto-push after each commit." +echo "memory: ready at $MEMORY_DIR" +echo " remote: $REMOTE" +echo " branch: $BRANCH" +echo " auto-push: enabled (post-commit hook)" \ No newline at end of file diff --git a/.opencode/tools/memory-setup.ts b/.opencode/tools/memory-setup.ts new file mode 100644 index 0000000..71ee7cd --- /dev/null +++ b/.opencode/tools/memory-setup.ts @@ -0,0 +1,19 @@ +import { spawnSync } from "child_process" +import path from "path" +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Initialize or sync opencode-memory. Clones remote repo, installs post-commit hook for auto-push, rebuilds RAG index. Idempotent — safe to run multiple times. No arguments needed.", + args: {}, + async execute(_args, context) { + const script = path.join(import.meta.dir, "..", "scripts", "setup-memory.sh") + const r = spawnSync("bash", [script], { + encoding: "utf-8", + cwd: context.worktree, + }) + if (r.status !== 0) { + return `⚠️ memory-setup failed (exit ${r.status}): ${r.stderr || r.stdout}` + } + return r.stdout.trim() + }, +}) \ No newline at end of file diff --git a/README.md b/README.md index 778e83b..65ea6f4 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Copy `.env.example` to `.env` and fill in: Memory uses `@mathew-cf/opencode-memory` plugin (hybrid search: ripgrep + local RAG). - `OPENCODE_MEMORY_DIR` env var points to memory directory (default: `app_data/opencode-memory/`) +- `OPENCODE_MEMORY_REMOTE` must point at your git remote — fork the upstream [`slaid098/opencode-memory`](https://github.com/slaid098/opencode-memory) repo and set the URL in `.env` - Run `.opencode/scripts/setup-memory.sh` to initialize memory repo ## License diff --git a/docker-compose.yml b/docker-compose.yml index fd3715a..bc729a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: environment: - DOCKER_HOST=tcp://docker-dind:2375 - OPENCODE_CONFIG_DIR=/root/.config/opencode - - OPENCODE_MEMORY_DIR=/root/workspace/app_data/opencode-memory + - OPENCODE_MEMORY_DIR=/root/.local/share/opencode/opencode-memory - DOCKER_CONTAINER=true - GIT_AUTHOR_NAME=opencode-agent - GIT_AUTHOR_EMAIL=agent@opencode.local diff --git a/docs/decisions/014-pr-36-memory-setup-tool.md b/docs/decisions/014-pr-36-memory-setup-tool.md new file mode 100644 index 0000000..8c4cc7e --- /dev/null +++ b/docs/decisions/014-pr-36-memory-setup-tool.md @@ -0,0 +1,28 @@ +# ADR-014: Memory setup tool with deterministic init + +## Статус +Accepted + +## Контекст +После миграции memory в отдельный репо `slaid098/opencode-memory` (PR#17/24) память стала невидима для агента в Docker-окружении. `docker-compose.yml` указывал `OPENCODE_MEMORY_DIR=/root/workspace/app_data/opencode-memory`, но mount `./app_data/workspaces:/root/workspace` делал путь несуществующим — memory физически лежала вне доступного контейнеру пути. Ручная инициализация (clone, hook, rag index) не воспроизводилась детерминированно: агент не должен импровизировать bash-команды, а raw скрипт `setup-memory.sh` (PR#24) не был idempotent и не покрывал edge-cases (wrong remote, missing hook, offline pull). + +Нужен tool, который агент вызывает из главного чата (pure-orchestrator model, ADR-010): tool делегирует в bash script, script детерминированно приводит memory в known-good state за 6 шагов. + +## Решение +TS tool `memory-setup.ts` (thin wrapper, 0 args, паттерн `tunnel.ts`) + bash script `setup-memory.sh` (детерминированный flow, `set -euo pipefail`): + +1. `mkdir -p MEMORY_DIR` — если директории нет +2. `git clone` (если нет `.git`) | `git pull --ff-only` (если есть) +3. `git remote set-url` — если origin ≠ REMOTE +4. post-commit hook install — если отсутствует или содержимое ≠ expected +5. `rag index` — если `.rag` нет (best-effort, rag CLI опционален) +6. `echo status` + +Idempotent: 3x прогона = одинаковый state. `OPENCODE_MEMORY_REMOTE` обязательна (exit 1 если не set) — `.env.example` предоставляет значение; отсутствие = config error, не silent fallback. `OPENCODE_MEMORY_DIR` имеет default `/root/.local/share/opencode/opencode-memory` (через существующий `./app_data:/root/.local/share/opencode` mount). + +post-commit hook: `#!/bin/bash\ngit push origin master 2>/dev/null || true` — auto-push после каждого `memory_save`, синхронизирует memory-репо с remote без ручного шага. + +## Альтернативы +- Raw bash (агент вызывает `setup-memory.sh` напрямую через bash permission) — отклонено: нарушает pure-orchestrator model (ADR-010), агент не должен запускать arbitrary bash из главного чата +- MCP plugin init hook (auto-init при старте opencode) — отклонено: plugin init не ставит post-commit hook и не перестраивает RAG index; нет явного control когда init происходит +- Default value для `OPENCODE_MEMORY_REMOTE` (silent fallback на `https://github.com/slaid098/opencode-memory.git`) — отклонено: спека требует exit 1 при отсутствии env var; silent fallback маскирует config errors (пользователь может случайно клонировать чужой репо) \ No newline at end of file diff --git a/docs/handoff/pr-36-memory-setup-tool.md b/docs/handoff/pr-36-memory-setup-tool.md new file mode 100644 index 0000000..7096d36 --- /dev/null +++ b/docs/handoff/pr-36-memory-setup-tool.md @@ -0,0 +1,35 @@ +--- +pr_number: 36 +title: Memory setup tool with deterministic init +--- + +# PR: Memory setup tool with deterministic init + +## Что сделано +- `.opencode/tools/memory-setup.ts` — TS tool wrapper (0 args, вызывает `bash .opencode/scripts/setup-memory.sh`, паттерн `tunnel.ts`) +- `.opencode/scripts/setup-memory.sh` — переписан: детерминированный flow (6 шагов: mkdir, clone/pull, remote check, hook, rag index, status), `set -euo pipefail`, idempotent +- `docker-compose.yml` — `OPENCODE_MEMORY_DIR` исправлен: `/root/workspace/app_data/opencode-memory` → `/root/.local/share/opencode/opencode-memory` (через существующий `./app_data:/root/.local/share/opencode` mount) +- `.env.example` — добавлены `OPENCODE_MEMORY_REMOTE` (обязательный) + `OPENCODE_MEMORY_DIR` (опциональный, default в script) +- `.gitignore` — добавлен `app_data/opencode-memory/` (memory — отдельный репо, clone target, не в config) +- `tests/test_setup_memory.py` — 6 pytest тестов с mock remote (`git init --bare`): fresh init, existing repo, wrong remote, missing hook, idempotent (3x), no remote env +- `tests/test_memory_setup_tool.ts` — 2 TS теста (документационные, по паттерну `test_pipeline_status_tool.ts`): success, failure +- `tests/test_memory_setup_tool.py` — 4 Python теста через `_ts_loader.mjs`: load, success, failure, cwd propagation +- ADR-014 + этот handoff + +## Почему +После миграции memory в отдельный репо (`slaid098/opencode-memory`) память стала невидима в Docker: `docker-compose.yml` указывал путь через mount `./app_data/workspaces:/root/workspace`, но memory лежала вне этого mount. Ручная инициализация не воспроизводилась детерминированно. Нужен tool для pure-orchestrator model (ADR-010): агент вызывает tool из главного чата, tool делегирует в script, script клонирует remote, ставит post-commit hook для auto-push, перестраивает RAG index. Idempotent — каждый запуск = одинаковый result, безопасно вызывать многократно. + +Спека issue содержала противоречие: одновременно указан default для `OPENCODE_MEMORY_REMOTE` (`https://github.com/slaid098/opencode-memory.git`) и требование "exit 1 если не set". Решено в пользу acceptance criteria + test spec: env var обязательна, default убран, `.env.example` предоставляет значение. Зафиксировано в ADR-014 (Альтернативы). + +## Pending +- `rag` CLI нет на CI runner — шаг 5 скрипта best-effort (warn + continue, не exit 1). Когда rag будет установлен в Dockerfile, index будет перестраиваться автоматически. Вне scope этого PR. +- `master` branch hardcoded в скрипте и hook. Memory remote использует `master` (не `main`). Если remote переедет на `main` — потребуется обновление `BRANCH` переменной. + +## Watch out +- `OPENCODE_MEMORY_REMOTE` обязателен — без него скрипт exit 1. Не добавляйте default обратно: silent fallback маскирует config errors (можно клонировать чужой репо). +- `OPENCODE_MEMORY_DIR` default = `/root/.local/share/opencode/opencode-memory` — соответствует mount `./app_data:/root/.local/share/opencode` в docker-compose.yml. Memory физически лежит в `opencode-config/app_data/opencode-memory/` на хосте (отдельный git репо, gitignored). +- post-commit hook выполняет `git push origin master 2>/dev/null || true` — silent failure при offline. Memory commits сохраняются локально, push произойдёт при следующем online-коммите. +- `git pull --ff-only` — если локальная история разошлась с remote (force-push или rebase), pull упадёт. Скрипт ловит это (`|| echo skipped`) и продолжает, но memory останется неактуальной. Ручное разрешение требуется. +- `rag index` шаг best-effort: если `rag` CLI не установлен, скрипт пропускает шаг (warn, не error). RAG index перестроится когда rag станет доступен. +- Tools auto-discovered через @opencode-ai/plugin — НЕ нужно регистрировать в opencode.json. +- TS-тест (`test_memory_setup_tool.ts`) документационный — CI гоняет Python-версию (`test_memory_setup_tool.py`) через `_ts_loader.mjs` (bun нет на runner). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 50cbdb9..0cd7605 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -40,6 +40,7 @@ opencode-config/ │ │ └── spec/SKILL.md # 9-phase spec generation │ ├── tools/ │ │ ├── merge-pr.ts # merge_pr tool wrapper (orchestrator-safe gh pr merge) — PR#30 +│ │ ├── memory-setup.ts # memory_setup tool wrapper (0 args, calls setup-memory.sh) — PR#36 │ │ ├── pipeline-status.ts # pipeline_status tool wrapper │ │ ├── spec-status.ts # spec_status tool wrapper │ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34 @@ -49,7 +50,7 @@ opencode-config/ │ │ ├── observability.py # OTel spans for tools │ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling) │ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs -│ │ ├── setup-memory.sh # opencode-memory bootstrap +│ │ ├── setup-memory.sh # opencode-memory bootstrap (deterministic 6-step flow, idempotent) — PR#36 │ │ ├── spec-status.py # 9-phase spec oracle │ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34 │ ├── opencode.json # MCP servers, providers, permissions, agents, plugins @@ -74,6 +75,8 @@ opencode-config/ │ ├── test_cli.py # src/memory/cli.py │ ├── test_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL) │ ├── test_index.py # src/memory/index.py +│ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36 +│ ├── test_memory_setup_tool.ts # TS wrapper test (mjs loader) — PR#36 │ ├── test_observability.py # .opencode/scripts/observability.py │ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching) │ ├── test_pipeline_status_adr.py @@ -81,6 +84,7 @@ opencode-config/ │ ├── test_pipeline_status_tool.py │ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader) │ ├── test_search.py # src/memory/search.py +│ ├── test_setup_memory.py # .opencode/scripts/setup-memory.sh (mock remote, idempotency) — PR#36 │ ├── test_spec_status.py # .opencode/scripts/spec-status.py │ └── test_spec_status_tool.py ├── pyproject.toml # Python project (uv, ruff, pytest config) @@ -88,8 +92,9 @@ opencode-config/ ├── .pre-commit-config.yaml # ruff + UV hooks ├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts — PR#24 ├── Dockerfile # node:20-slim + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared — PR#24, PR#34 -├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN) +├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR) ├── app_data/ +│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36 │ ├── workspaces/ # Agent working directory (.gitkeep) │ └── ssh/ # SSH keys, not in git (.gitkeep) ├── .editorconfig diff --git a/tests/test_memory_setup_tool.py b/tests/test_memory_setup_tool.py new file mode 100644 index 0000000..7b7bfd2 --- /dev/null +++ b/tests/test_memory_setup_tool.py @@ -0,0 +1,104 @@ +"""Tests for .opencode/tools/memory-setup.ts — the memory-setup custom tool. + +Mirrors ``tests/test_pipeline_status_tool.py`` / ``test_spec_status_tool.py``: +exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` +(a node CommonJS sandbox that strips TS-only syntax, stubs +``@opencode-ai/plugin``, and replaces ``import.meta.dir`` with the real +``.opencode/tools`` directory). + +The loader is parameterized via the ``TS_FILE`` env var so the same harness +works for every TS tool wrapper. These tests set +``TS_FILE=.opencode/tools/memory-setup.ts``. + +Modes used (same as the pipeline-status / spec-status tool tests): +- ``load`` — sanity-check that the tool loads and declares no args. +- ``exec_stub`` — call execute with a stubbed spawnSync to verify: + (a) success path: exit 0 → trimmed stdout returned, + (b) failure path: exit 1 → ``⚠️ memory-setup failed (exit 1): ``. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" +TS_FILE = REPO_ROOT / ".opencode" / "tools" / "memory-setup.ts" +TS_FILE_REL = ".opencode/tools/memory-setup.ts" + + +def _run_loader(*args: str) -> dict: + """Invoke the loader with ``TS_FILE`` env set to memory-setup.ts and parse JSON stdout.""" + env = {**os.environ, "TS_FILE": TS_FILE_REL} + proc = subprocess.run( + ["node", str(LOADER), *args], + capture_output=True, + text=True, + check=False, + cwd=str(REPO_ROOT), + timeout=60, + env=env, + ) + if proc.returncode != 0: + raise RuntimeError( + f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n" + f"stdout: {proc.stdout}\nstderr: {proc.stderr}" + ) + return json.loads(proc.stdout) + + +def test_loader_can_load_tool(): + """Sanity: memory-setup.ts loads and declares no args (toggle-style tool).""" + if not TS_FILE.exists(): + pytest.skip("memory-setup.ts not present") + out = _run_loader("load") + assert "description" in out + assert out["args"] == [], f"expected no args, got: {out['args']}" + + +def test_success(): + """execute() with exit 0 returns trimmed stdout. + + memory-setup.ts follows the tunnel.ts pattern: ``r.stdout.trim()`` on + success. Stub spawnSync to return ``" memory: ready \\n"`` and verify + the tool returns ``"memory: ready"`` (whitespace stripped). + """ + out = _run_loader("exec_stub", "_", "0", " memory: ready \n", "") + result = out["result"] + assert result == "memory: ready", f"expected trimmed stdout, got: {result!r}" + + +def test_failure(): + """execute() with exit 1 returns ``⚠️ memory-setup failed (exit 1): ``. + + The tool prepends ``⚠️ memory-setup failed (exit ): `` to the stderr + (falling back to stdout if stderr is empty). Stub spawnSync to return + exit 1 + a stderr string and verify the error prefix + stderr content. + """ + out = _run_loader("exec_stub", "_", "1", "", "ERROR: OPENCODE_MEMORY_REMOTE not set") + result = out["result"] + assert "⚠️ memory-setup failed" in result, f"missing error prefix: {result!r}" + assert "exit 1" in result, f"missing exit code: {result!r}" + assert "OPENCODE_MEMORY_REMOTE not set" in result, f"missing stderr: {result!r}" + + +def test_execute_uses_cwd_from_context(): + """execute passes ``cwd=context.worktree`` to spawnSync (ADR-023 pattern). + + Like pipeline-status.ts / spec-status.ts / tunnel.ts, the memory-setup + tool wrapper propagates ``context.worktree`` as ``cwd`` to spawnSync so + the bash script runs in the git worktree root (where .opencode/ lives), + not in the opencode process cwd. + """ + out = _run_loader("exec_stub", "_", "0", "ok", "") + calls = out["calls"] + assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" + opts = calls[0]["opts"] + assert opts is not None, "spawnSync called without opts — expected cwd kwarg" + assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" + assert opts["cwd"] == str(REPO_ROOT), ( + f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" + ) diff --git a/tests/test_memory_setup_tool.ts b/tests/test_memory_setup_tool.ts new file mode 100644 index 0000000..c94f0cc --- /dev/null +++ b/tests/test_memory_setup_tool.ts @@ -0,0 +1,56 @@ +/** + * Tests for .opencode/tools/memory-setup.ts — the memory-setup custom tool. + * + * Mirror of tests/test_pipeline_status_tool.ts / test_spec_status_tool.ts: + * the tool is a thin spawnSync wrapper around + * ``.opencode/scripts/setup-memory.sh`` with no args. + * + * Runtime note: opencode ships a standalone binary with Bun bundled inside; + * there is no separate ``bun`` CLI on the host (CI runner uses node + pytest). + * The CI runs the equivalent Python tests in + * ``tests/test_memory_setup_tool.py`` via the JS loader ``tests/_ts_loader.mjs``. + * This file documents the intended TS-side test cases and is runnable under + * ``bun test`` once a bun runtime is available on the host. + * + * Test cases (mirror tests/test_memory_setup_tool.py): + * - test_success — script exit 0 → returns trimmed stdout + * - test_failure — script exit 1 → returns ``⚠️ memory-setup failed (exit 1): `` + */ + +import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" } +import { spawnSync } from "child_process" +import path from "path" + +const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "memory-setup.ts") + +describe("memory-setup tool", () => { + test("test_success — script exit 0 returns trimmed stdout", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: " memory: ready \n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({}, { + sessionID: "t", messageID: "t", agent: "t", + directory: ".", worktree: ".", + abort: new AbortController().signal, + metadata() {}, async ask() {}, + }) + expect(result).toBe("memory: ready") + }) + + test("test_failure — script exit 1 returns error message", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 1, stdout: "", stderr: "ERROR: OPENCODE_MEMORY_REMOTE not set" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({}, { + sessionID: "t", messageID: "t", agent: "t", + directory: ".", worktree: ".", + abort: new AbortController().signal, + metadata() {}, async ask() {}, + }) + expect(result).toContain("⚠️ memory-setup failed") + expect(result).toContain("exit 1") + expect(result).toContain("OPENCODE_MEMORY_REMOTE not set") + }) +}) \ No newline at end of file diff --git a/tests/test_setup_memory.py b/tests/test_setup_memory.py new file mode 100644 index 0000000..67ff050 --- /dev/null +++ b/tests/test_setup_memory.py @@ -0,0 +1,219 @@ +"""Tests for .opencode/scripts/setup-memory.sh — deterministic memory init. + +Uses a local mock remote (``git init --bare``) instead of GitHub so the +tests are hermetic and offline-safe. ``tmp_path`` provides isolation. + +The script is invoked via ``subprocess`` with env vars pointing at the +mock remote + a tmp dir. Each test asserts one of the 6 deterministic +flow steps. + +Script contract (``setup-memory.sh``): + 1. mkdir -p MEMORY_DIR + 2. clone REMOTE | git pull --ff-only + 3. remote set-url if origin != REMOTE + 4. install post-commit hook (auto-push) if missing/wrong + 5. rag index if .rag missing (best-effort, rag optional) + 6. echo status + +Exit 1 when ``OPENCODE_MEMORY_REMOTE`` is unset (no default — the +``.env.example`` provides the value; absence is a config error). +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / ".opencode" / "scripts" / "setup-memory.sh" + +EXPECTED_HOOK = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n" + + +def _seed_remote(remote_dir: Path) -> None: + """Seed a bare remote with one commit on ``master``.""" + remote_dir.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "--bare", "-q", str(remote_dir)], check=True) + seed = remote_dir.parent / "seed" + seed.mkdir() + subprocess.run(["git", "init", "-q", str(seed)], check=True) + (seed / "README.md").write_text("# memory\n") + subprocess.run(["git", "-C", str(seed), "add", "README.md"], check=True) + subprocess.run( + [ + "git", + "-C", + str(seed), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "init", + ], + check=True, + ) + subprocess.run(["git", "-C", str(seed), "branch", "-M", "master"], check=True) + subprocess.run( + ["git", "-C", str(seed), "remote", "add", "origin", str(remote_dir)], + check=True, + ) + subprocess.run(["git", "-C", str(seed), "push", "-q", "origin", "master"], check=True) + + +def _run_script(memory_dir: Path, remote: str | None) -> subprocess.CompletedProcess: + """Run setup-memory.sh with env pointing at tmp paths. + + When ``remote`` is None, OPENCODE_MEMORY_REMOTE is removed from env + (simulating the "no remote env" error path). + """ + env = { + **os.environ, + "OPENCODE_MEMORY_DIR": str(memory_dir), + "GIT_TERMINAL_PROMPT": "0", + } + if remote is not None: + env["OPENCODE_MEMORY_REMOTE"] = remote + else: + env.pop("OPENCODE_MEMORY_REMOTE", None) + return subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + check=False, + env=env, + ) + + +def test_fresh_init(tmp_path: Path) -> None: + """Empty dir → run → clone, hook, remote, files present.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + r = _run_script(mem, str(remote)) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + assert (mem / ".git").is_dir(), "repo not cloned" + assert (mem / "README.md").exists(), "file not pulled" + assert (mem / ".git" / "hooks" / "post-commit").exists(), "hook missing" + got = subprocess.run( + ["git", "-C", str(mem), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert got == str(remote), f"remote mismatch: {got}" + + +def test_existing_repo(tmp_path: Path) -> None: + """.git exists → run → pull --ff-only, no destructive changes.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + assert _run_script(mem, str(remote)).returncode == 0 + # capture state before second run + head_before = subprocess.run( + ["git", "-C", str(mem), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + r = _run_script(mem, str(remote)) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + head_after = subprocess.run( + ["git", "-C", str(mem), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert head_before == head_after, "pull --ff-only changed HEAD unexpectedly" + assert "pulling latest" in r.stdout + + +def test_wrong_remote(tmp_path: Path) -> None: + """remote ≠ expected → run → set-url corrects the remote.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + assert _run_script(mem, str(remote)).returncode == 0 + # corrupt remote URL (tmp path — avoids S108 insecure-tmp warning) + wrong = tmp_path / "wrong-remote" + subprocess.run( + ["git", "-C", str(mem), "remote", "set-url", "origin", str(wrong)], + check=True, + ) + r = _run_script(mem, str(remote)) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + assert "fixing remote" in r.stdout + got = subprocess.run( + ["git", "-C", str(mem), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert got == str(remote), f"remote not fixed: {got}" + + +def test_missing_hook(tmp_path: Path) -> None: + """hook missing → run → hook created with correct content.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + assert _run_script(mem, str(remote)).returncode == 0 + hook = mem / ".git" / "hooks" / "post-commit" + hook.unlink() + assert not hook.exists() + r = _run_script(mem, str(remote)) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + assert hook.exists(), "hook not recreated" + assert hook.read_text() == EXPECTED_HOOK, f"hook content wrong: {hook.read_text()!r}" + assert "installing post-commit hook" in r.stdout + + +def test_idempotent(tmp_path: Path) -> None: + """run 3x → state identical after run 1 and run 3.""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + + def _snapshot() -> dict: + head = subprocess.run( + ["git", "-C", str(mem), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + hook = mem / ".git" / "hooks" / "post-commit" + return { + "head": head, + "hook": hook.read_text() if hook.exists() else None, + "remote": subprocess.run( + ["git", "-C", str(mem), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ).stdout.strip(), + } + + assert _run_script(mem, str(remote)).returncode == 0 + snap1 = _snapshot() + assert _run_script(mem, str(remote)).returncode == 0 + assert _run_script(mem, str(remote)).returncode == 0 + snap3 = _snapshot() + assert snap1 == snap3, f"non-idempotent:\n run1={snap1}\n run3={snap3}" + + +def test_no_remote_env(tmp_path: Path) -> None: + """OPENCODE_MEMORY_REMOTE unset → exit 1, error message to stderr/stdout.""" + mem = tmp_path / "mem" + r = _run_script(mem, remote=None) + assert r.returncode == 1, f"expected exit 1, got {r.returncode}" + combined = r.stderr + r.stdout + assert "OPENCODE_MEMORY_REMOTE" in combined, f"missing error msg: {combined!r}" + assert "ERROR" in combined or "not set" in combined + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))