feat(memory): add memory-setup tool with deterministic init (#36)
* feat(memory): add memory-setup tool and rewrite script * fix(memory): correct OPENCODE_MEMORY_DIR in docker-compose * test(memory): add setup-memory tests with mock remote * docs(memory): add ADR and handoff * docs(handoff): set PR number * fix(docs): correct handoff and ADR filenames to PR number * docs: update project map + handoff + ADR * fix(memory): address code review comments --------- Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
parent
efc0827226
commit
2b9ee23b49
12 changed files with 546 additions and 37 deletions
|
|
@ -26,4 +26,9 @@ TUNNEL_DOMAIN=
|
|||
# Antidetect Browser MCP
|
||||
# Local: http://localhost:8765/mcp
|
||||
# Remote: http://<your-server-ip>:8765/mcp
|
||||
ANTIDETECT_BROWSER_MCP_URL=http://localhost:8765/mcp
|
||||
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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
19
.opencode/tools/memory-setup.ts
Normal file
19
.opencode/tools/memory-setup.ts
Normal file
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
docs/decisions/014-pr-36-memory-setup-tool.md
Normal file
28
docs/decisions/014-pr-36-memory-setup-tool.md
Normal file
|
|
@ -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 (пользователь может случайно клонировать чужой репо)
|
||||
35
docs/handoff/pr-36-memory-setup-tool.md
Normal file
35
docs/handoff/pr-36-memory-setup-tool.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
104
tests/test_memory_setup_tool.py
Normal file
104
tests/test_memory_setup_tool.py
Normal file
|
|
@ -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): <stderr>``.
|
||||
"""
|
||||
|
||||
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): <stderr>``.
|
||||
|
||||
The tool prepends ``⚠️ memory-setup failed (exit <N>): `` 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}"
|
||||
)
|
||||
56
tests/test_memory_setup_tool.ts
Normal file
56
tests/test_memory_setup_tool.ts
Normal file
|
|
@ -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): <stderr>``
|
||||
*/
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
219
tests/test_setup_memory.py
Normal file
219
tests/test_setup_memory.py
Normal file
|
|
@ -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"]))
|
||||
Loading…
Add table
Reference in a new issue