diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9aebe2b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,134 @@ +name: Release + +# Triggers on version tags (e.g. v0.12.0). Builds installers on every +# target platform in parallel and attaches them to the GitHub Release. +# Signing is conditional on the matching secret being present — missing +# secrets skip signing rather than failing the build, so this workflow +# works for internal-test releases before code-signing certs are in place. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag name (e.g. v0.12.0) — used to name artifacts when running manually' + required: false + +jobs: + # ────────────────────────────────────────────────────────────────── + # macOS — produces AgentMark--macos.pkg + # ────────────────────────────────────────────────────────────────── + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node (build-time) + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Build .pkg + env: + # Optional signing — workflow runs without these too. + APPLE_DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} + APPLE_APP_NOTARIZATION_USER: ${{ secrets.APPLE_APP_NOTARIZATION_USER }} + APPLE_APP_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_APP_NOTARIZATION_TEAM_ID }} + APPLE_APP_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_APP_NOTARIZATION_PASSWORD }} + run: | + # Import code-signing cert into a temporary keychain if provided. + # The .p12 is base64-encoded in the secret. + if [[ -n "${{ secrets.APPLE_CERT_P12_BASE64 }}" ]]; then + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + KEYCHAIN_PASSWORD=$(uuidgen) + CERT_PATH=$RUNNER_TEMP/codesign.p12 + echo -n "${{ secrets.APPLE_CERT_P12_BASE64 }}" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -P "${{ secrets.APPLE_CERT_P12_PASSWORD }}" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychain -d user | xargs) + rm "$CERT_PATH" + fi + + chmod +x packaging/scripts/*.sh + packaging/scripts/build-macos.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: agentmark-macos + path: dist/installer/*.pkg + if-no-files-found: error + + # ────────────────────────────────────────────────────────────────── + # Windows — produces AgentMark--windows.msi + # ────────────────────────────────────────────────────────────────── + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node (build-time) + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Build .msi + env: + WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} + WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }} + shell: pwsh + run: | + .\packaging\scripts\build-windows.ps1 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: agentmark-windows + path: dist/installer/*.msi + if-no-files-found: error + + # ────────────────────────────────────────────────────────────────── + # Attach artifacts to the GitHub Release (only on tag pushes). + # ────────────────────────────────────────────────────────────────── + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-macos, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Publish to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/agentmark-macos/*.pkg + artifacts/agentmark-windows/*.msi + draft: true + fail_on_unmatched_files: true diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..41424a2 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,96 @@ +# AgentMark Installers + +This directory builds **production installers** for the agentmark MCP +server + bridges, bundled with a pinned Node runtime so end users +don't need to install Node themselves. + +## What ships + +Each installer drops these files onto the target machine: + +| Platform | Install path | Contents | +|---|---|---| +| macOS | `/opt/thinkfleet/agentmark/` | `node` (arm64 + x64 universal), `agentmark/` (npm package), `bridges/agentmark-bridge-macos` (Swift AXAPI binary), `bin/agentmark-mcp` launcher script | +| Windows | `C:\Program Files\ThinkFleet\AgentMark\` | `node.exe`, `agentmark\` (npm package), `bridges\agentmark-bridge-windows.exe` (.NET UIA), `agentmark-mcp.cmd` launcher (added to PATH) | + +Both installers add an `agentmark-mcp` command to the user's PATH that +runs the bundled Node against the bundled agentmark package. + +## What's NOT bundled + +- **Playwright Chromium.** Adds ~150MB and most users won't need the + browser-driving plugin. The installer wires up a post-install step + that runs `playwright install chromium` only if the user opted in. +- **Activepieces backend.** That's a separate service the user + configures via env vars when they want the cloud-backed Memory. + +## Building + +### macOS + +Requires: macOS host, Xcode CLI tools, Swift 5.9+. + +```sh +./packaging/scripts/build-macos.sh +# Produces: dist/installer/AgentMark--macos.pkg +``` + +### Windows + +Requires: Windows host (or GitHub Actions windows-latest), .NET 8 SDK, +WiX Toolset 4. + +```ps1 +.\packaging\scripts\build-windows.ps1 +# Produces: dist\installer\AgentMark--windows.msi +``` + +### Linux (future) + +AppImage scaffolding lives in a follow-up PR. + +## Code signing + +Signing is **optional** in the build scripts — they detect required +secrets and run signing steps only when present. The full release +flow is meant to run in GitHub Actions, which injects certs from +encrypted secrets. + +**macOS:** requires an Apple Developer ID Application certificate +imported into the runner's keychain. Set these GitHub secrets: + +- `APPLE_DEVELOPER_ID` — Common Name of the cert (e.g. + `Developer ID Application: ThinkFleet AI, Inc. (TEAMID)`) +- `APPLE_APP_NOTARIZATION_USER` — your App Store Connect Apple ID +- `APPLE_APP_NOTARIZATION_TEAM_ID` — 10-char team ID +- `APPLE_APP_NOTARIZATION_PASSWORD` — app-specific password +- `APPLE_CERT_P12_BASE64` — base64 of the .p12 file (export from + Keychain Access) +- `APPLE_CERT_P12_PASSWORD` — password protecting the .p12 + +**Windows:** requires an Authenticode code-signing certificate. + +- `WINDOWS_CERT_PFX_BASE64` — base64 of the .pfx +- `WINDOWS_CERT_PFX_PASSWORD` — password protecting the .pfx + +Without these secrets, the build scripts skip signing and produce +unsigned artifacts (suitable for internal testing; macOS Gatekeeper + +Windows SmartScreen will warn end users). + +## Release flow + +Tag a release (e.g. `v0.12.0`). GitHub Actions builds all platforms +in parallel and uploads artifacts to the GitHub Release. See +`.github/workflows/release.yml`. + +## Pinned versions + +| Component | Version | Where | +|---|---|---| +| Node | 22.11.0 (LTS) | `packaging/scripts/common.sh:NODE_VERSION` | +| .NET | 8.0 | bridge `csproj` | +| Swift | 5.9+ | bridge `Package.swift` | +| WiX | 4.x | installed via `dotnet tool install --global wix` in the workflow | + +Bumping Node: update `NODE_VERSION` in `common.sh`, then rebuild on +each platform. diff --git a/packaging/scripts/build-macos.sh b/packaging/scripts/build-macos.sh new file mode 100755 index 0000000..c19b35b --- /dev/null +++ b/packaging/scripts/build-macos.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Build the macOS .pkg installer. +# +# Pipeline: +# 1. Build the npm package (pnpm build). +# 2. Build the Swift AXAPI bridge in release mode (universal arm64+x64). +# 3. Download the pinned Node binary (arm64 + x64 if needed). +# 4. Assemble the install staging directory. +# 5. Run pkgbuild + productbuild to produce the .pkg. +# 6. (Optional) Sign + notarise if Apple secrets are present. +# +# Output: dist/installer/AgentMark--macos.pkg +# +# Designed to run BOTH on a developer's Mac and in GitHub Actions +# macos-latest. Signing is conditional on env vars; missing certs are +# logged but don't fail the build. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=./common.sh +source "$SCRIPT_DIR/common.sh" + +VERSION="$(read_pkg_version "$REPO_ROOT/package.json")" +BUILD_DIR="$REPO_ROOT/dist/installer-build/macos" +OUT_DIR="$REPO_ROOT/dist/installer" +STAGE_DIR="$BUILD_DIR/stage/opt/thinkfleet/agentmark" +PKG_OUT="$OUT_DIR/AgentMark-${VERSION}-macos.pkg" + +mkdir -p "$BUILD_DIR" "$OUT_DIR" +rm -rf "$BUILD_DIR/stage" +mkdir -p "$STAGE_DIR" + +# ────────────────────────────────────────────────────────────────────── +# 1. Build the npm package +# ────────────────────────────────────────────────────────────────────── +section "Build npm package" +cd "$REPO_ROOT" +pnpm install --frozen-lockfile +pnpm build + +# ────────────────────────────────────────────────────────────────────── +# 2. Build the Swift bridge (release, universal) +# ────────────────────────────────────────────────────────────────────── +section "Build macOS AXAPI bridge" +cd "$REPO_ROOT/apps/agent-runner/bridges/macos" +swift build -c release --arch arm64 --arch x86_64 +BRIDGE_BIN="$REPO_ROOT/apps/agent-runner/bridges/macos/.build/apple/Products/Release/agentmark-bridge-macos" +[[ -f "$BRIDGE_BIN" ]] || die "Swift build did not produce expected binary at $BRIDGE_BIN" + +# ────────────────────────────────────────────────────────────────────── +# 3. Download pinned Node +# ────────────────────────────────────────────────────────────────────── +section "Download Node v${NODE_VERSION}" +HOST_ARCH="$(uname -m)" +NODE_TARGET="darwin-arm64" +if [[ "$HOST_ARCH" != "arm64" ]]; then + NODE_TARGET="darwin-x64" +fi +NODE_ARCHIVE="$(download_node "$NODE_TARGET" "$BUILD_DIR/node-download")" +NODE_EXTRACT_DIR="$BUILD_DIR/node-extract" +mkdir -p "$NODE_EXTRACT_DIR" +tar -xJf "$NODE_ARCHIVE" -C "$NODE_EXTRACT_DIR" +NODE_BIN="$NODE_EXTRACT_DIR/node-v${NODE_VERSION}-${NODE_TARGET}/bin/node" +[[ -x "$NODE_BIN" ]] || die "Extracted Node binary not found at $NODE_BIN" + +# ────────────────────────────────────────────────────────────────────── +# 4. Assemble staging tree +# ────────────────────────────────────────────────────────────────────── +section "Assemble installer staging" +mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/bridges" + +# Node runtime +cp "$NODE_BIN" "$STAGE_DIR/node" +chmod +x "$STAGE_DIR/node" + +# npm package: copy dist/ + schema/ + package.json + production deps +mkdir -p "$STAGE_DIR/agentmark" +cp -R "$REPO_ROOT/dist" "$STAGE_DIR/agentmark/dist" +cp -R "$REPO_ROOT/schema" "$STAGE_DIR/agentmark/schema" +cp "$REPO_ROOT/package.json" "$STAGE_DIR/agentmark/package.json" + +# Install production deps into the staging dir. --prod skips devDeps +# (vitest, eslint, etc.). Playwright Chromium is *not* downloaded here; +# the agent-side `agentmark-mcp install-browsers` step handles that +# when the web plugin is used. +( cd "$STAGE_DIR/agentmark" && \ + PNPM_DEPLOY_NO_FROZEN_LOCKFILE=true \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + pnpm install --prod --ignore-scripts ) + +# Bridge +cp "$BRIDGE_BIN" "$STAGE_DIR/bridges/agentmark-bridge-macos" +chmod +x "$STAGE_DIR/bridges/agentmark-bridge-macos" + +# Launcher script +cp "$SCRIPT_DIR/../templates/launcher.sh" "$STAGE_DIR/bin/agentmark-mcp" +chmod +x "$STAGE_DIR/bin/agentmark-mcp" + +# ────────────────────────────────────────────────────────────────────── +# 5. Build the .pkg +# ────────────────────────────────────────────────────────────────────── +section "Build .pkg" +COMPONENT_PKG="$BUILD_DIR/agentmark-component.pkg" + +# pkgbuild creates a single-component package. We pair it with +# productbuild so we can attach a Distribution.xml (which controls the +# installer UX — license screen, post-install symlink to /usr/local/bin). +pkgbuild \ + --root "$BUILD_DIR/stage" \ + --identifier "$BUNDLE_ID" \ + --version "$VERSION" \ + --install-location "/" \ + --scripts "$SCRIPT_DIR/../templates/macos-pkg-scripts" \ + "$COMPONENT_PKG" + +DISTRIBUTION_XML="$BUILD_DIR/distribution.xml" +sed "s/__VERSION__/$VERSION/g; s/__BUNDLE_ID__/$BUNDLE_ID/g; s/__DISPLAY_NAME__/$DISPLAY_NAME/g" \ + "$SCRIPT_DIR/../templates/distribution.xml" > "$DISTRIBUTION_XML" + +productbuild \ + --distribution "$DISTRIBUTION_XML" \ + --package-path "$BUILD_DIR" \ + --version "$VERSION" \ + "$PKG_OUT" + +# ────────────────────────────────────────────────────────────────────── +# 6. Sign + notarise (optional — only when Apple secrets are present) +# ────────────────────────────────────────────────────────────────────── +if [[ -n "${APPLE_DEVELOPER_ID:-}" ]]; then + section "Sign .pkg" + SIGNED_PKG="$BUILD_DIR/AgentMark-${VERSION}-macos-signed.pkg" + productsign \ + --sign "$APPLE_DEVELOPER_ID" \ + "$PKG_OUT" \ + "$SIGNED_PKG" + mv "$SIGNED_PKG" "$PKG_OUT" + + if [[ -n "${APPLE_APP_NOTARIZATION_USER:-}" && -n "${APPLE_APP_NOTARIZATION_TEAM_ID:-}" && -n "${APPLE_APP_NOTARIZATION_PASSWORD:-}" ]]; then + section "Notarise .pkg" + xcrun notarytool submit "$PKG_OUT" \ + --apple-id "$APPLE_APP_NOTARIZATION_USER" \ + --team-id "$APPLE_APP_NOTARIZATION_TEAM_ID" \ + --password "$APPLE_APP_NOTARIZATION_PASSWORD" \ + --wait + xcrun stapler staple "$PKG_OUT" + else + echo "Notarisation env vars not set; skipping. Apple Gatekeeper will warn end users." + fi +else + echo "APPLE_DEVELOPER_ID not set; skipping signing. The .pkg works but Gatekeeper will warn." +fi + +section "Done" +echo "Installer: $PKG_OUT" +ls -lh "$PKG_OUT" diff --git a/packaging/scripts/build-windows.ps1 b/packaging/scripts/build-windows.ps1 new file mode 100644 index 0000000..518a44d --- /dev/null +++ b/packaging/scripts/build-windows.ps1 @@ -0,0 +1,154 @@ +<# + Build the Windows .msi installer for the AgentMark MCP server + + Windows UIA bridge. + + Pipeline: + 1. Build the npm package (pnpm build) — usually already done in CI. + 2. Build the .NET UIA bridge (dotnet publish, self-contained). + 3. Download the pinned Node binary (win-x64). + 4. Assemble the install staging directory. + 5. Run WiX 4 (`dotnet wix build`) to produce the .msi. + 6. Sign with signtool if WINDOWS_CERT_PFX_BASE64 is set. + + Output: dist\installer\AgentMark--windows.msi + + Designed for windows-latest GitHub Actions runners; works on a + local Windows dev box too. Signing is conditional. +#> + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# ────────────────────────────────────────────────────────────────────── +# Pinned versions +# ────────────────────────────────────────────────────────────────────── +$NodeVersion = if ($env:NODE_VERSION) { $env:NODE_VERSION } else { '22.11.0' } +$BundleId = if ($env:BUNDLE_ID) { $env:BUNDLE_ID } else { 'ai.thinkfleet.agentmark' } +$DisplayName = if ($env:DISPLAY_NAME) { $env:DISPLAY_NAME } else { 'ThinkFleet AgentMark' } + +# Paths +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir '..\..') +$BuildDir = Join-Path $RepoRoot 'dist\installer-build\windows' +$StageDir = Join-Path $BuildDir 'stage' +$OutDir = Join-Path $RepoRoot 'dist\installer' + +$PkgVersion = (node -e "console.log(require('$($RepoRoot.Path.Replace('\','/'))/package.json').version)") +$MsiOut = Join-Path $OutDir "AgentMark-$PkgVersion-windows.msi" + +New-Item -ItemType Directory -Force -Path $BuildDir, $OutDir | Out-Null +if (Test-Path $StageDir) { Remove-Item -Recurse -Force $StageDir } +New-Item -ItemType Directory -Force -Path $StageDir, "$StageDir\agentmark", "$StageDir\bridges" | Out-Null + +function Write-Section($msg) { + Write-Host '' + Write-Host "=== $msg ===" +} + +# ────────────────────────────────────────────────────────────────────── +# 1. Build the npm package +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Build npm package' +Set-Location $RepoRoot +pnpm install --frozen-lockfile +pnpm build + +# ────────────────────────────────────────────────────────────────────── +# 2. Build the .NET UIA bridge (self-contained, win-x64) +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Build Windows UIA bridge' +$BridgeProj = Join-Path $RepoRoot 'apps\agent-runner\bridges\windows\AgentMark.Bridge.Windows.csproj' +$BridgePublish = Join-Path $BuildDir 'bridge-publish' +dotnet publish $BridgeProj -c Release -r win-x64 --self-contained true -o $BridgePublish +$BridgeBin = Join-Path $BridgePublish 'agentmark-bridge-windows.exe' +if (-not (Test-Path $BridgeBin)) { + throw "dotnet publish did not produce $BridgeBin" +} + +# ────────────────────────────────────────────────────────────────────── +# 3. Download pinned Node +# ────────────────────────────────────────────────────────────────────── +Write-Section "Download Node v$NodeVersion" +$NodeZip = Join-Path $BuildDir 'node.zip' +$NodeUrl = "https://nodejs.org/dist/v$NodeVersion/node-v$NodeVersion-win-x64.zip" +Invoke-WebRequest -Uri $NodeUrl -OutFile $NodeZip +$NodeExtract = Join-Path $BuildDir 'node-extract' +if (Test-Path $NodeExtract) { Remove-Item -Recurse -Force $NodeExtract } +Expand-Archive -Path $NodeZip -DestinationPath $NodeExtract +$NodeExe = Join-Path $NodeExtract "node-v$NodeVersion-win-x64\node.exe" +if (-not (Test-Path $NodeExe)) { + throw "Extracted Node binary not found at $NodeExe" +} + +# ────────────────────────────────────────────────────────────────────── +# 4. Assemble staging tree +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Assemble installer staging' + +Copy-Item $NodeExe (Join-Path $StageDir 'node.exe') +Copy-Item $BridgeBin (Join-Path $StageDir 'bridges\agentmark-bridge-windows.exe') + +# Bridge has companion DLLs (FlaUI, etc.) — copy the whole publish dir. +Get-ChildItem $BridgePublish -File | Where-Object { $_.Name -ne 'agentmark-bridge-windows.exe' } | ForEach-Object { + Copy-Item $_.FullName (Join-Path $StageDir 'bridges') +} + +# npm package +Copy-Item -Recurse (Join-Path $RepoRoot 'dist') (Join-Path $StageDir 'agentmark\dist') +Copy-Item -Recurse (Join-Path $RepoRoot 'schema') (Join-Path $StageDir 'agentmark\schema') +Copy-Item (Join-Path $RepoRoot 'package.json') (Join-Path $StageDir 'agentmark\package.json') + +# Install production deps into staging. --ignore-scripts skips +# Playwright's Chromium download; we'll prompt for that post-install. +Set-Location (Join-Path $StageDir 'agentmark') +$env:PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = '1' +pnpm install --prod --ignore-scripts +Set-Location $RepoRoot + +# Launcher batch file (added to PATH by the MSI). +Copy-Item (Join-Path $ScriptDir '..\templates\launcher.cmd') (Join-Path $StageDir 'agentmark-mcp.cmd') + +# ────────────────────────────────────────────────────────────────────── +# 5. Build the .msi with WiX 4 +# ────────────────────────────────────────────────────────────────────── +Write-Section 'Build MSI' +# Install wix as a dotnet tool if not already present. +$wixCheck = dotnet tool list --global | Select-String 'wix\s' +if (-not $wixCheck) { + dotnet tool install --global wix + $env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH" +} + +# Stamp version into the WiX manifest. +$WxsTemplate = Join-Path $ScriptDir '..\templates\AgentMark.wxs' +$WxsStamped = Join-Path $BuildDir 'AgentMark.wxs' +(Get-Content $WxsTemplate -Raw) ` + -replace '__VERSION__', $PkgVersion ` + -replace '__BUNDLE_ID__', $BundleId ` + -replace '__DISPLAY_NAME__', $DisplayName ` + -replace '__STAGE_DIR__', ($StageDir -replace '\\', '\\\\') | + Set-Content $WxsStamped + +wix build -arch x64 -out $MsiOut $WxsStamped + +# ────────────────────────────────────────────────────────────────────── +# 6. Sign with signtool (optional) +# ────────────────────────────────────────────────────────────────────── +if ($env:WINDOWS_CERT_PFX_BASE64 -and $env:WINDOWS_CERT_PFX_PASSWORD) { + Write-Section 'Sign MSI' + $PfxPath = Join-Path $BuildDir 'codesign.pfx' + [System.IO.File]::WriteAllBytes($PfxPath, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64)) + + # signtool ships with the Windows SDK; in CI the windows-latest image has it on PATH. + & signtool sign /f $PfxPath /p $env:WINDOWS_CERT_PFX_PASSWORD ` + /fd SHA256 /tr 'http://timestamp.digicert.com' /td SHA256 ` + $MsiOut + + Remove-Item $PfxPath -Force +} else { + Write-Host 'WINDOWS_CERT_PFX_BASE64 not set; skipping signing. SmartScreen will warn end users.' +} + +Write-Section 'Done' +Write-Host "Installer: $MsiOut" +Get-Item $MsiOut | Select-Object Name, Length diff --git a/packaging/scripts/common.sh b/packaging/scripts/common.sh new file mode 100755 index 0000000..40e7c54 --- /dev/null +++ b/packaging/scripts/common.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Shared helpers + pinned versions for the installer build scripts. +# Sourced by build-macos.sh and (indirectly via env vars) the +# Windows script. + +# Pinned Node runtime. Bump when needed — every platform installer +# downloads from the official Node.js distribution. +NODE_VERSION="${NODE_VERSION:-22.11.0}" + +# Bundle identifier (macOS) + Microsoft Product ID semantics (Windows). +BUNDLE_ID="${BUNDLE_ID:-ai.thinkfleet.agentmark}" + +# Human-friendly display name. +DISPLAY_NAME="${DISPLAY_NAME:-ThinkFleet AgentMark}" + +# Read the package.json version. Single source of truth so installers +# match the npm version. +read_pkg_version() { + local pkg="$1" + node -e "console.log(require('$pkg').version)" +} + +# Download a Node binary tarball/zip for the requested platform/arch. +# Args: $1=platform (darwin-arm64 / darwin-x64 / win-x64), $2=output dir. +download_node() { + local target="$1" + local out_dir="$2" + local archive_ext="tar.xz" + if [[ "$target" == win-* ]]; then + archive_ext="zip" + fi + local url="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${target}.${archive_ext}" + local archive="$out_dir/node-${target}.${archive_ext}" + + mkdir -p "$out_dir" + echo "Downloading Node v${NODE_VERSION} for ${target}…" + curl --fail --location --silent --show-error --output "$archive" "$url" + echo "$archive" +} + +# Print a uniform header so the build log is scannable. +section() { + echo + echo "=== $1 ===" +} + +# Fail loudly with a clear message. +die() { + echo "ERROR: $*" >&2 + exit 1 +} diff --git a/packaging/templates/AgentMark.wxs b/packaging/templates/AgentMark.wxs new file mode 100644 index 0000000..673223a --- /dev/null +++ b/packaging/templates/AgentMark.wxs @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/templates/distribution.xml b/packaging/templates/distribution.xml new file mode 100644 index 0000000..52a83cb --- /dev/null +++ b/packaging/templates/distribution.xml @@ -0,0 +1,34 @@ + + + + __DISPLAY_NAME__ + ai.thinkfleet + + + + + + + + + + + + + + + + + + + + + agentmark-component.pkg + diff --git a/packaging/templates/launcher.cmd b/packaging/templates/launcher.cmd new file mode 100644 index 0000000..c8b5a40 --- /dev/null +++ b/packaging/templates/launcher.cmd @@ -0,0 +1,21 @@ +@echo off +REM agentmark-mcp launcher (Windows). +REM +REM Installed at C:\Program Files\ThinkFleet\AgentMark\agentmark-mcp.cmd +REM (added to PATH by the MSI). Resolves the bundle's Node + agentmark +REM dist relative to its own path so the launcher is relocatable. + +setlocal + +set "INSTALL_ROOT=%~dp0" +REM Strip trailing backslash for cleaner output paths. +if "%INSTALL_ROOT:~-1%"=="\" set "INSTALL_ROOT=%INSTALL_ROOT:~0,-1%" + +REM Point the desktop plugin at the bundled bridge unless overridden. +if "%AGENTMARK_BRIDGE_PATH%"=="" ( + set "AGENTMARK_BRIDGE_PATH=%INSTALL_ROOT%\bridges\agentmark-bridge-windows.exe" +) + +"%INSTALL_ROOT%\node.exe" "%INSTALL_ROOT%\agentmark\dist\src\mcp\cli.js" %* + +endlocal diff --git a/packaging/templates/launcher.sh b/packaging/templates/launcher.sh new file mode 100755 index 0000000..7cbc029 --- /dev/null +++ b/packaging/templates/launcher.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# agentmark-mcp launcher (POSIX). +# +# Installed at /opt/thinkfleet/agentmark/bin/agentmark-mcp; symlinked +# into /usr/local/bin by the .pkg post-install step. Resolves the +# bundle's Node + agentmark dist relative to its own path so the +# launcher is relocatable. + +set -e + +# Resolve the directory this script lives in, following symlinks. +SCRIPT_PATH="$0" +while [[ -L "$SCRIPT_PATH" ]]; do + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + SCRIPT_PATH="$(readlink "$SCRIPT_PATH")" + [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH" +done +BIN_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +INSTALL_ROOT="$(cd "$BIN_DIR/.." && pwd)" + +NODE="$INSTALL_ROOT/node" +ENTRY="$INSTALL_ROOT/agentmark/dist/src/mcp/cli.js" + +# Tell the agentmark Desktop plugin where the bridge lives unless the +# operator overrode the path explicitly. +if [[ -z "${AGENTMARK_BRIDGE_PATH:-}" ]]; then + export AGENTMARK_BRIDGE_PATH="$INSTALL_ROOT/bridges/agentmark-bridge-macos" +fi + +exec "$NODE" "$ENTRY" "$@" diff --git a/packaging/templates/macos-pkg-scripts/postinstall b/packaging/templates/macos-pkg-scripts/postinstall new file mode 100755 index 0000000..981134a --- /dev/null +++ b/packaging/templates/macos-pkg-scripts/postinstall @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# pkgbuild post-install script. +# +# Runs as root after the installer extracts files to /opt/thinkfleet/agentmark/. +# Job: drop a symlink in /usr/local/bin so `agentmark-mcp` is on the +# user's PATH out of the box. + +set -e + +INSTALL_ROOT="/opt/thinkfleet/agentmark" +LAUNCHER="$INSTALL_ROOT/bin/agentmark-mcp" +SYMLINK="/usr/local/bin/agentmark-mcp" + +# /usr/local/bin must exist before we can symlink into it. On a fresh +# macOS install without Homebrew it sometimes doesn't. +mkdir -p /usr/local/bin + +# Atomic-ish: write to a temp name, then move into place. +ln -sf "$LAUNCHER" "$SYMLINK.tmp" +mv -f "$SYMLINK.tmp" "$SYMLINK" + +# Make the bridge executable bit survives the tar extraction. +chmod +x "$INSTALL_ROOT/bridges/agentmark-bridge-macos" +chmod +x "$INSTALL_ROOT/node" +chmod +x "$INSTALL_ROOT/bin/agentmark-mcp" + +exit 0 diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c3b85c6..ddf31bd 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -102,11 +102,11 @@ export type { } from '../plugins/system' // Recipes Pack — durable named playbooks the agent learns once + replays. +// Local-file backend only for now; Activepieces recipes endpoints don't +// exist yet so we don't ship a speculative HTTP backend. export { createRecipesPlugin, LocalFileRecipeBackend, - RemoteRecipeBackend, - RemoteRecipeError, RecipeStore, RECIPES_TOOLS, } from '../plugins/recipes' @@ -119,17 +119,18 @@ export type { RecipeBackend, RecipeBackendDescription, LocalFileRecipeBackendConfig, - RemoteRecipeBackendConfig, } from '../plugins/recipes' // Memory Pack — hierarchical persistent memory for AI agents. Opt-in; // targeted at IDE coding-assistant integrations where per-session -// amnesia is the dominant UX limitation. +// amnesia is the dominant UX limitation. Two production-ready backends: +// LocalFile (default, single-user) and Activepieces (multi-user, hits +// the live ThinkFleet agent-memory API). export { createMemoryPlugin, LocalFileMemoryBackend, - RemoteMemoryBackend, - RemoteMemoryError, + ActivepiecesMemoryBackend, + ActivepiecesMemoryError, MemoryStore, MEMORY_TOOLS, } from '../plugins/memory' @@ -143,7 +144,7 @@ export type { MemoryBackendDescription, MemorySetInput, LocalFileMemoryBackendConfig, - RemoteMemoryBackendConfig, + ActivepiecesMemoryBackendConfig, } from '../plugins/memory' // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the diff --git a/src/plugins/memory/activepieces-backend.ts b/src/plugins/memory/activepieces-backend.ts new file mode 100644 index 0000000..c16c6fa --- /dev/null +++ b/src/plugins/memory/activepieces-backend.ts @@ -0,0 +1,361 @@ +/** + * Activepieces-backed memory backend. + * + * Wires the agentmark Memory plugin to the production Activepieces + * agent-memory API. Same `MemoryBackend` interface as the disk-backed + * store — the plugin layer + tool handlers don't know or care which + * backend they're talking to. + * + * The user's ThinkFleet stack already has a rich memory system + * (bi-temporal facts, hybrid vector+BM25 search, scope-aware + * confirmation workflow, knowledge graph). The simple key/value + * interface agentmark exposes to AI agents maps onto a subset of + * that system; this class is the bridge. + * + * Mapping (agentmark K/V → Activepieces rich shape): + * - agentmark `key` → Activepieces `metadata.agentmark_key` + * (Activepieces stores free-form `content`, not key/value pairs; + * we round-trip the key through metadata so get/deleteByKey can + * locate records.) + * - agentmark `value` → Activepieces `content` (string-coerced) + * + `metadata.raw_value` (preserves type) + * - agentmark `scope` → Activepieces `scope` (same five-level enum) + * - agentmark `scope.id` → `metadata.scope_id` + * - agentmark `tags` → `metadata.tags` (string[]) + * + * Auth: `Authorization: Bearer sk-` against the Activepieces + * Service-principal flow. The backend is scoped to one project; pass + * a `chatbotId` to target chatbot-scoped routes (richer create/search + * semantics), otherwise omit and the project-scoped routes are used. + */ +import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' +import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' + +export interface ActivepiecesMemoryBackendConfig { + /** Base URL of the Activepieces API (no trailing slash). */ + baseUrl: string + /** API key (must start with `sk-`). Service principal. */ + apiKey: string + /** Project id this backend operates against. Required. */ + projectId: string + /** Optional chatbot id for chatbot-scoped routes. */ + chatbotId?: string + /** Injectable fetch (for tests). Default: globalThis.fetch. */ + fetch?: typeof fetch + /** Per-request timeout in ms. Default: 15000. */ + timeoutMs?: number + /** Activepieces `source` value to stamp on writes. Defaults to + * 'agentmark' so records this backend creates are filterable. */ + source?: string +} + +export class ActivepiecesMemoryError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: unknown, + ) { + super(message) + this.name = 'ActivepiecesMemoryError' + } +} + +/** Shape of one item in the Activepieces clawdbot_memory_item table. */ +interface ApMemoryItem { + id: string + platformId: string + projectId: string | null + chatbotId: string | null + type: string + content: string + category: string | null + importance: number + source: string | null + sessionKey: string | null + chatIdentityId: string | null + metadata: Record | null + scope: string + status: string + confidence: number + impact: string | null + confirmedAt: string | null + validAt: string + invalidAt: string | null + created: string + updated: string + similarity?: number +} + +const AP_KEY_FIELD = 'agentmark_key' +const AP_RAW_VALUE_FIELD = 'raw_value' +const AP_TAGS_FIELD = 'tags' + +export class ActivepiecesMemoryBackend implements MemoryBackend { + readonly baseUrl: string + readonly projectId: string + readonly chatbotId?: string + readonly source: string + private readonly apiKey: string + private readonly fetcher: typeof fetch + private readonly timeoutMs: number + + constructor(config: ActivepiecesMemoryBackendConfig) { + if (!config.baseUrl) throw new Error('ActivepiecesMemoryBackend: baseUrl is required.') + if (!config.apiKey) throw new Error('ActivepiecesMemoryBackend: apiKey is required.') + if (!config.apiKey.startsWith('sk-')) { + throw new Error('ActivepiecesMemoryBackend: apiKey must start with "sk-".') + } + if (!config.projectId) throw new Error('ActivepiecesMemoryBackend: projectId is required.') + + this.baseUrl = config.baseUrl.replace(/\/+$/, '') + this.apiKey = config.apiKey + this.projectId = config.projectId + this.chatbotId = config.chatbotId + this.source = config.source ?? 'agentmark' + this.fetcher = config.fetch ?? globalThis.fetch + this.timeoutMs = config.timeoutMs ?? 15_000 + } + + async set(input: MemorySetInput): Promise { + const scope = input.scope ?? { type: 'platform' as const } + const body = { + type: 'fact', + content: stringifyValue(input.value), + scope: scope.type, + source: this.source, + metadata: { + [AP_KEY_FIELD]: input.key, + [AP_RAW_VALUE_FIELD]: input.value, + ...(input.tags ? { [AP_TAGS_FIELD]: input.tags } : {}), + ...(scope.id ? { scope_id: scope.id } : {}), + }, + } + + // Match agentmark's "set replaces same key+scope" semantics by + // looking up the existing record and deleting it before create. + const existing = await this.findByKey(input.key, scope) + if (existing) await this.deleteById(existing.id) + + const created = await this.request('POST', this.memoryRoot(), body) + return apToAgentmark(created) + } + + async get(input: { key: string; scopes?: MemoryScope[] }): Promise { + const scopes = input.scopes ?? [{ type: 'platform' }] + for (const scope of scopes) { + const found = await this.findByKey(input.key, scope) + if (found) return apToAgentmark(found) + } + return null + } + + async deleteById(recordId: string): Promise { + try { + await this.request('DELETE', `${this.memoryRoot()}/${encodeURIComponent(recordId)}`) + return true + } catch (err) { + if (err instanceof ActivepiecesMemoryError && err.status === 404) return false + throw err + } + } + + async deleteByKey(key: string, scope: MemoryScope): Promise { + const found = await this.findByKey(key, scope) + if (!found) return false + return this.deleteById(found.id) + } + + async search(query: MemorySearchQuery): Promise { + // Activepieces' /memory/search requires a non-empty query for + // hybrid (vector + BM25) ranking. When agentmark callers didn't + // pass a query, fall back to a scope-filtered list so the + // MemoryBackend contract is preserved. + if (!query.query) return this.listFallback(query) + + const body: Record = { query: query.query, limit: query.limit ?? 10 } + if (query.scope) body.scope = query.scope.type + + const results = await this.request( + 'POST', + `${this.memoryRoot()}/search`, + body, + ) + + let records = results.map(apToAgentmark) + if (query.tags && query.tags.length > 0) { + records = records.filter((r) => + r.tags && r.tags.some((t) => query.tags!.includes(t)), + ) + } + // Activepieces' hybrid search already orders by similarity; let + // explicit sort_by override. + if (query.sort_by === 'recency') { + records.sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + } else if (query.sort_by === 'access_count') { + records.sort((a, b) => b.access_count - a.access_count) + } else if (query.sort_by === 'created') { + records.sort((a, b) => b.created_at.localeCompare(a.created_at)) + } + return records + } + + async list(scope?: MemoryScope, prefix?: string, limit = 200): Promise { + const params = new URLSearchParams() + if (scope?.type) params.set('scope', scope.type) + params.set('source', this.source) + params.set('limit', String(Math.min(limit, 100))) // server caps at 100/page + + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + let records = items.map(apToAgentmark) + if (prefix) records = records.filter((r) => r.key.startsWith(prefix)) + return records.slice(0, limit) + } + + async clear(): Promise { + // No bulk-delete endpoint; iterate with source filter so we only + // touch records this backend wrote. Bounded at 10 pages × 100 = + // 1000 records to avoid runaway loops on misconfigured servers. + const params = new URLSearchParams({ source: this.source, limit: '100' }) + for (let page = 0; page < 10; page++) { + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + if (items.length === 0) break + for (const item of items) await this.deleteById(item.id) + if (items.length < 100) break + } + } + + async describe(): Promise { + return { + kind: 'activepieces', + base_url: this.baseUrl, + project_id: this.projectId, + chatbot_id: this.chatbotId, + source: this.source, + } + } + + // ────────────────────────────────────────────────────────────────── + // Internals + // ────────────────────────────────────────────────────────────────── + + private memoryRoot(): string { + if (this.chatbotId) { + return `/v1/projects/${encodeURIComponent(this.projectId)}/chatbots/${encodeURIComponent(this.chatbotId)}/memory` + } + return `/v1/projects/${encodeURIComponent(this.projectId)}/memory` + } + + private async findByKey(key: string, scope: MemoryScope): Promise { + // List by scope + source (cheap server-side filter), then match + // key client-side. source=agentmark scopes us to records THIS + // backend wrote so we don't collide with manually-created + // Activepieces memories. + const params = new URLSearchParams({ + scope: scope.type, + source: this.source, + limit: '100', + }) + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + for (const item of items) { + const md = (item.metadata ?? {}) as Record + if (md[AP_KEY_FIELD] !== key) continue + // Strict scope_id match: distinguishes two project-scoped + // memories with the same key from different repos. + if (scope.id !== undefined && md.scope_id !== scope.id) continue + return item + } + return null + } + + private async listFallback(query: MemorySearchQuery): Promise { + const records = await this.list(query.scope, undefined, query.limit ?? 50) + if (query.tags && query.tags.length > 0) { + return records.filter((r) => + r.tags && r.tags.some((t) => query.tags!.includes(t)), + ) + } + return records + } + + private async request(method: string, p: string, body?: unknown): Promise { + const headers: Record = { + authorization: `Bearer ${this.apiKey}`, + } + if (body !== undefined) headers['content-type'] = 'application/json' + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + + let response: Response + try { + response = await this.fetcher(`${this.baseUrl}${p}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + + const text = await response.text() + let parsed: unknown = undefined + if (text.length > 0) { + try { parsed = JSON.parse(text) } catch { parsed = text } + } + + if (!response.ok) { + throw new ActivepiecesMemoryError( + `Activepieces memory ${method} ${p} failed: ${response.status} ${response.statusText}`, + response.status, + parsed, + ) + } + return parsed as T + } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === undefined || value === null) return '' + return JSON.stringify(value) +} + +function apToAgentmark(item: ApMemoryItem): MemoryRecord { + const metadata = (item.metadata ?? {}) as Record + const storedKey = typeof metadata[AP_KEY_FIELD] === 'string' + ? metadata[AP_KEY_FIELD] as string + : item.id + const rawValue = AP_RAW_VALUE_FIELD in metadata + ? metadata[AP_RAW_VALUE_FIELD] + : item.content + const tags = Array.isArray(metadata[AP_TAGS_FIELD]) + ? metadata[AP_TAGS_FIELD] as string[] + : undefined + const scopeId = typeof metadata.scope_id === 'string' ? metadata.scope_id as string : undefined + + return { + record_id: item.id, + key: storedKey, + value: rawValue, + scope: { + type: item.scope as MemoryScope['type'], + id: scopeId, + }, + tags, + expires_at: item.invalidAt ? Date.parse(item.invalidAt) : undefined, + created_at: item.created, + updated_at: item.updated, + access_count: 0, + last_accessed_at: undefined, + } +} diff --git a/src/plugins/memory/index.ts b/src/plugins/memory/index.ts index 2775c76..e5d42d3 100644 --- a/src/plugins/memory/index.ts +++ b/src/plugins/memory/index.ts @@ -169,8 +169,8 @@ function optionalStringArray(v: unknown): string[] | undefined { export { LocalFileMemoryBackend, MemoryStore } from './store' export type { LocalFileMemoryBackendConfig, MemoryStoreConfig } from './store' -export { RemoteMemoryBackend, RemoteMemoryError } from './remote-backend' -export type { RemoteMemoryBackendConfig } from './remote-backend' +export { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from './activepieces-backend' +export type { ActivepiecesMemoryBackendConfig } from './activepieces-backend' export type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' export { MEMORY_TOOLS } from './tool-defs' export type { diff --git a/src/plugins/memory/remote-backend.ts b/src/plugins/memory/remote-backend.ts deleted file mode 100644 index a389592..0000000 --- a/src/plugins/memory/remote-backend.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Remote (HTTP) memory backend. - * - * Hits a ThinkFleet memory service over HTTPS. Same agentmark binary - * works against on-prem deployments (internal URL, internal auth) and - * cloud deployments (api.thinkfleet.ai) — only the `baseUrl` + `token` - * differ. - * - * Auth: Bearer token. Optional `X-Workspace-Id` header scopes calls to - * a specific team/org so a single user can be a member of multiple - * workspaces. - * - * Endpoint contract (v1 proposal — subject to change before the - * service ships): - * - * POST /v1/memory/records { key, value, scope, tags?, ttl_seconds? } → MemoryRecord - * POST /v1/memory/records/get { key, scopes? } → MemoryRecord | null - * DELETE /v1/memory/records/:record_id → { deleted: boolean } - * DELETE /v1/memory/records { key, scope } → { deleted: boolean } - * POST /v1/memory/search MemorySearchQuery → MemoryRecord[] - * GET /v1/memory/records?scope_type=&scope_id=&prefix=&limit= → MemoryRecord[] - * DELETE /v1/memory/records/all → { cleared: true } - * GET /v1/memory/describe → MemoryBackendDescription - * - * Errors: non-2xx responses are surfaced as RemoteMemoryError with the - * status code + parsed JSON body (when JSON). Auth-related 401s also - * include the workspace header for easier debugging. - */ -import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' -import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' - -export interface RemoteMemoryBackendConfig { - /** Base URL of the memory service (no trailing slash). */ - baseUrl: string - /** Bearer token. Required for all non-anonymous deployments. */ - token?: string - /** Optional workspace / team id sent as `X-Workspace-Id`. */ - workspaceId?: string - /** Injectable fetch (defaults to globalThis.fetch). Used by tests. */ - fetch?: typeof fetch - /** Per-request timeout in ms. Default: 15_000. */ - timeoutMs?: number -} - -export class RemoteMemoryError extends Error { - constructor( - message: string, - readonly status: number, - readonly body?: unknown, - ) { - super(message) - this.name = 'RemoteMemoryError' - } -} - -export class RemoteMemoryBackend implements MemoryBackend { - readonly baseUrl: string - readonly workspaceId?: string - private readonly token?: string - private readonly fetcher: typeof fetch - private readonly timeoutMs: number - - constructor(config: RemoteMemoryBackendConfig) { - if (!config.baseUrl) throw new Error('RemoteMemoryBackend: baseUrl is required.') - this.baseUrl = config.baseUrl.replace(/\/+$/, '') - this.token = config.token - this.workspaceId = config.workspaceId - this.fetcher = config.fetch ?? globalThis.fetch - this.timeoutMs = config.timeoutMs ?? 15_000 - } - - async set(input: MemorySetInput): Promise { - return await this.request('POST', '/v1/memory/records', { - key: input.key, - value: input.value, - scope: input.scope ?? { type: 'platform' }, - tags: input.tags, - ttl_seconds: input.ttlSeconds, - }) - } - - async get(input: { key: string; scopes?: MemoryScope[] }): Promise { - const result = await this.request('POST', '/v1/memory/records/get', input) - return result ?? null - } - - async deleteById(recordId: string): Promise { - const r = await this.request<{ deleted: boolean }>( - 'DELETE', - `/v1/memory/records/${encodeURIComponent(recordId)}`, - ) - return r.deleted === true - } - - async deleteByKey(key: string, scope: MemoryScope): Promise { - const r = await this.request<{ deleted: boolean }>('DELETE', '/v1/memory/records', { key, scope }) - return r.deleted === true - } - - async search(query: MemorySearchQuery): Promise { - return await this.request('POST', '/v1/memory/search', query) - } - - async list(scope?: MemoryScope, prefix?: string, limit?: number): Promise { - const params = new URLSearchParams() - if (scope?.type) params.set('scope_type', scope.type) - if (scope?.id) params.set('scope_id', scope.id) - if (prefix) params.set('prefix', prefix) - if (typeof limit === 'number') params.set('limit', String(limit)) - const qs = params.toString() - return await this.request( - 'GET', - `/v1/memory/records${qs ? '?' + qs : ''}`, - ) - } - - async clear(): Promise { - await this.request<{ cleared: true }>('DELETE', '/v1/memory/records/all') - } - - async describe(): Promise { - const remote = await this.request( - 'GET', - '/v1/memory/describe', - ).catch((): undefined => undefined) - return { - kind: 'remote-http', - base_url: this.baseUrl, - workspace_id: this.workspaceId, - ...(remote ?? {}), - } - } - - private async request(method: string, path: string, body?: unknown): Promise { - const headers: Record = {} - if (this.token) headers.authorization = `Bearer ${this.token}` - if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId - if (body !== undefined) headers['content-type'] = 'application/json' - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), this.timeoutMs) - - let response: Response - try { - response = await this.fetcher(`${this.baseUrl}${path}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } - - const text = await response.text() - let parsed: unknown = undefined - if (text.length > 0) { - try { parsed = JSON.parse(text) } catch { parsed = text } - } - - if (!response.ok) { - throw new RemoteMemoryError( - `Memory service ${method} ${path} failed: ${response.status} ${response.statusText}`, - response.status, - parsed, - ) - } - return parsed as T - } -} diff --git a/src/plugins/recipes/index.ts b/src/plugins/recipes/index.ts index 6c43bb3..5f7ca9d 100644 --- a/src/plugins/recipes/index.ts +++ b/src/plugins/recipes/index.ts @@ -149,8 +149,6 @@ function recipeSummary(r: Recipe): Record { export { LocalFileRecipeBackend, RecipeStore } from './store' export type { LocalFileRecipeBackendConfig, RecipeStoreConfig } from './store' -export { RemoteRecipeBackend, RemoteRecipeError } from './remote-backend' -export type { RemoteRecipeBackendConfig } from './remote-backend' export type { RecipeBackend, RecipeBackendDescription } from './backend' export { resolveRecipe, substitute, applyParameterSchema } from './substitute' export { RECIPES_TOOLS } from './tool-defs' diff --git a/src/plugins/recipes/remote-backend.ts b/src/plugins/recipes/remote-backend.ts deleted file mode 100644 index 5c29fc8..0000000 --- a/src/plugins/recipes/remote-backend.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Remote (HTTP) recipe backend. - * - * Same auth + workspace pattern as RemoteMemoryBackend. The desktop - * app instantiates this when the user/org is configured for on-prem - * or cloud sync — recipes saved by one teammate become available to - * everyone in the workspace. - * - * Endpoint contract (v1 proposal): - * - * POST /v1/recipes Recipe → Recipe - * GET /v1/recipes/:name → Recipe | null - * GET /v1/recipes?target_app=... → Recipe[] - * DELETE /v1/recipes/:name → { deleted: boolean } - * DELETE /v1/recipes/all → { cleared: true } - * GET /v1/recipes/describe → RecipeBackendDescription - * - * Save semantics: server controls version + timestamps; the on_conflict - * flag is passed as a query param. - */ -import type { RecipeBackend, RecipeBackendDescription } from './backend' -import type { Recipe } from './types' - -export interface RemoteRecipeBackendConfig { - /** Base URL of the recipe service (no trailing slash). */ - baseUrl: string - /** Bearer token. */ - token?: string - /** Optional workspace / team id sent as `X-Workspace-Id`. */ - workspaceId?: string - /** Injectable fetch (defaults to globalThis.fetch). */ - fetch?: typeof fetch - /** Per-request timeout in ms. Default: 15_000. */ - timeoutMs?: number -} - -export class RemoteRecipeError extends Error { - constructor( - message: string, - readonly status: number, - readonly body?: unknown, - ) { - super(message) - this.name = 'RemoteRecipeError' - } -} - -export class RemoteRecipeBackend implements RecipeBackend { - readonly baseUrl: string - readonly workspaceId?: string - private readonly token?: string - private readonly fetcher: typeof fetch - private readonly timeoutMs: number - - constructor(config: RemoteRecipeBackendConfig) { - if (!config.baseUrl) throw new Error('RemoteRecipeBackend: baseUrl is required.') - this.baseUrl = config.baseUrl.replace(/\/+$/, '') - this.token = config.token - this.workspaceId = config.workspaceId - this.fetcher = config.fetch ?? globalThis.fetch - this.timeoutMs = config.timeoutMs ?? 15_000 - } - - async get(name: string): Promise { - try { - return await this.request('GET', `/v1/recipes/${encodeURIComponent(name)}`) - } catch (err) { - if (err instanceof RemoteRecipeError && err.status === 404) return null - throw err - } - } - - async list(filter?: { target_app?: string }): Promise { - const qs = filter?.target_app ? `?target_app=${encodeURIComponent(filter.target_app)}` : '' - return await this.request('GET', `/v1/recipes${qs}`) - } - - async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise { - const onConflict = options.on_conflict ?? 'fail' - return await this.request( - 'POST', - `/v1/recipes?on_conflict=${onConflict}`, - recipe, - ) - } - - async delete(name: string): Promise { - try { - const r = await this.request<{ deleted: boolean }>( - 'DELETE', - `/v1/recipes/${encodeURIComponent(name)}`, - ) - return r.deleted === true - } catch (err) { - if (err instanceof RemoteRecipeError && err.status === 404) return false - throw err - } - } - - async clear(): Promise { - await this.request<{ cleared: true }>('DELETE', '/v1/recipes/all') - } - - async describe(): Promise { - const remote = await this.request( - 'GET', - '/v1/recipes/describe', - ).catch((): undefined => undefined) - return { - kind: 'remote-http', - base_url: this.baseUrl, - workspace_id: this.workspaceId, - ...(remote ?? {}), - } - } - - private async request(method: string, p: string, body?: unknown): Promise { - const headers: Record = {} - if (this.token) headers.authorization = `Bearer ${this.token}` - if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId - if (body !== undefined) headers['content-type'] = 'application/json' - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), this.timeoutMs) - - let response: Response - try { - response = await this.fetcher(`${this.baseUrl}${p}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } - - const text = await response.text() - let parsed: unknown = undefined - if (text.length > 0) { - try { parsed = JSON.parse(text) } catch { parsed = text } - } - - if (!response.ok) { - throw new RemoteRecipeError( - `Recipe service ${method} ${p} failed: ${response.status} ${response.statusText}`, - response.status, - parsed, - ) - } - return parsed as T - } -} diff --git a/test/memory/activepieces-backend.test.ts b/test/memory/activepieces-backend.test.ts new file mode 100644 index 0000000..92a2131 --- /dev/null +++ b/test/memory/activepieces-backend.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for ActivepiecesMemoryBackend — request shape + response mapping. + * Mocks globalThis.fetch; no real network calls. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from '../../src/plugins/memory' + +let originalFetch: typeof globalThis.fetch +let requests: Array<{ url: string; init?: RequestInit }> + +beforeEach(() => { + originalFetch = globalThis.fetch + requests = [] +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + requests.push({ url, init }) + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function apItem(overrides: Record = {}): Record { + return { + id: 'mem_default', + platformId: 'plat_1', + projectId: 'proj_1', + chatbotId: null, + type: 'fact', + content: 'pnpm build', + category: null, + importance: 5, + source: 'agentmark', + sessionKey: null, + chatIdentityId: null, + metadata: { + agentmark_key: 'build_command', + raw_value: 'pnpm build', + scope_id: 'proj_1', + }, + scope: 'project', + status: 'confirmed', + confidence: 1.0, + impact: null, + confirmedAt: null, + validAt: '2026-05-12T00:00:00Z', + invalidAt: null, + created: '2026-05-12T00:00:00Z', + updated: '2026-05-12T00:00:00Z', + ...overrides, + } +} + +const baseConfig = { + baseUrl: 'https://app.example.com', + apiKey: 'sk-test-1234', + projectId: 'proj_1', +} + +describe('ActivepiecesMemoryBackend — construction', () => { + it('requires baseUrl, sk-prefixed apiKey, and projectId', () => { + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, baseUrl: '' })).toThrow(/baseUrl/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, apiKey: '' })).toThrow(/apiKey/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, apiKey: 'not-sk-key' })).toThrow(/sk-/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, projectId: '' })).toThrow(/projectId/) + }) + + it('strips trailing slashes from baseUrl', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, baseUrl: 'https://x.com///' }) + await backend.list({ type: 'platform' }) + expect(requests[0].url.startsWith('https://x.com/v1/projects/')).toBe(true) + }) +}) + +describe('ActivepiecesMemoryBackend — auth + path shape', () => { + it('attaches Authorization Bearer header on every request', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list() + const headers = requests[0].init?.headers as Record + expect(headers.authorization).toBe('Bearer sk-test-1234') + }) + + it('targets project-scoped routes when chatbotId is omitted', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/memory') + expect(requests[0].url).not.toContain('/chatbots/') + }) + + it('targets chatbot-scoped routes when chatbotId is supplied', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, chatbotId: 'cb_42' }) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/chatbots/cb_42/memory') + }) +}) + +describe('ActivepiecesMemoryBackend — set', () => { + it('looks for existing record + deletes it before creating new one', async () => { + mockFetch((url, init) => { + if (init?.method === 'GET') return jsonResponse([apItem({ id: 'mem_existing' })]) + if (init?.method === 'DELETE') return jsonResponse({}) + return jsonResponse(apItem({ + id: 'mem_new', + content: 'pnpm test', + metadata: { agentmark_key: 'build_command', raw_value: 'pnpm test', scope_id: 'proj_1' }, + })) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.set({ + key: 'build_command', + value: 'pnpm test', + scope: { type: 'project', id: 'proj_1' }, + }) + + expect(requests.map((r) => r.init?.method)).toEqual(['GET', 'DELETE', 'POST']) + const postBody = JSON.parse(requests[2].init?.body as string) + expect(postBody.type).toBe('fact') + expect(postBody.scope).toBe('project') + expect(postBody.source).toBe('agentmark') + expect(postBody.metadata.agentmark_key).toBe('build_command') + expect(postBody.metadata.raw_value).toBe('pnpm test') + expect(postBody.metadata.scope_id).toBe('proj_1') + expect(record.record_id).toBe('mem_new') + expect(record.value).toBe('pnpm test') + }) + + it('JSON-stringifies non-string values; preserves raw value in metadata', async () => { + const captured: Array<{ body: unknown }> = [] + mockFetch((_url, init) => { + if (init?.method === 'GET') return jsonResponse([]) + if (init?.method === 'POST') { + captured.push({ body: JSON.parse(init.body as string) }) + return jsonResponse(apItem({ content: '{"x":1}' })) + } + return jsonResponse({}) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.set({ key: 'cfg', value: { x: 1 } }) + + const sent = captured[0]?.body as { content: string; metadata: Record } + expect(sent.content).toBe('{"x":1}') + expect(sent.metadata.raw_value).toEqual({ x: 1 }) + }) +}) + +describe('ActivepiecesMemoryBackend — get + deleteByKey', () => { + it('walks scopes in order; returns first match', async () => { + let call = 0 + mockFetch(() => { + call += 1 + if (call === 1) return jsonResponse([]) + return jsonResponse([apItem({ id: 'mem_platform' })]) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ + key: 'build_command', + scopes: [{ type: 'project', id: 'proj_1' }, { type: 'platform' }], + }) + expect(record?.record_id).toBe('mem_platform') + }) + + it('returns null when no scope has the key', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ key: 'missing', scopes: [{ type: 'platform' }] }) + expect(record).toBeNull() + }) + + it('disambiguates by scope_id when scope.id is supplied', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'mem_other', metadata: { agentmark_key: 'build_command', scope_id: '/repo/b' } }), + apItem({ id: 'mem_mine', metadata: { agentmark_key: 'build_command', scope_id: '/repo/a' } }), + ])) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ + key: 'build_command', + scopes: [{ type: 'project', id: '/repo/a' }], + }) + expect(record?.record_id).toBe('mem_mine') + }) + + it('deleteByKey looks up + deletes', async () => { + mockFetch((_url, init) => { + if (init?.method === 'GET') return jsonResponse([apItem({ id: 'mem_to_delete' })]) + return jsonResponse({}) + }) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const ok = await backend.deleteByKey('build_command', { type: 'platform' }) + expect(ok).toBe(true) + expect(requests[1].init?.method).toBe('DELETE') + expect(requests[1].url).toContain('/memory/mem_to_delete') + }) + + it('deleteByKey returns false when no match', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + expect(await backend.deleteByKey('missing', { type: 'platform' })).toBe(false) + }) +}) + +describe('ActivepiecesMemoryBackend — search', () => { + it('routes text queries to /memory/search with scope filter', async () => { + let captured: { body: unknown } | null = null + mockFetch((_url, init) => { + captured = { body: JSON.parse(init?.body as string) } + return jsonResponse([apItem({ id: 'mem_hit', similarity: 0.91 })]) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.search({ + query: 'how do I build', + scope: { type: 'project' }, + limit: 25, + }) + expect(requests[0].url).toContain('/memory/search') + expect(captured?.body).toEqual({ query: 'how do I build', scope: 'project', limit: 25 }) + expect(results[0].record_id).toBe('mem_hit') + }) + + it('falls back to listing when no query is supplied', async () => { + mockFetch(() => jsonResponse([apItem()])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.search({ scope: { type: 'platform' }, limit: 10 }) + expect(requests[0].init?.method).toBe('GET') + expect(requests[0].url).toContain('/memory?') + }) + + it('filters search results client-side by tags', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'mem_with', metadata: { agentmark_key: 'k1', tags: ['build', 'fast'] } }), + apItem({ id: 'mem_without', metadata: { agentmark_key: 'k2' } }), + ])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.search({ query: 'x', tags: ['build'] }) + expect(results).toHaveLength(1) + expect(results[0].record_id).toBe('mem_with') + }) +}) + +describe('ActivepiecesMemoryBackend — list', () => { + it('passes scope + source + limit; caps limit at 100', async () => { + mockFetch(() => jsonResponse([apItem()])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list({ type: 'project', id: '/repo' }, undefined, 999) + const url = requests[0].url + expect(url).toContain('scope=project') + expect(url).toContain('source=agentmark') + expect(url).toContain('limit=100') + }) + + it('filters by prefix client-side', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'a', metadata: { agentmark_key: 'build_a' } }), + apItem({ id: 'b', metadata: { agentmark_key: 'test_b' } }), + apItem({ id: 'c', metadata: { agentmark_key: 'build_c' } }), + ])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.list(undefined, 'build_') + expect(results.map((r) => r.key).sort()).toEqual(['build_a', 'build_c']) + }) +}) + +describe('ActivepiecesMemoryBackend — errors + describe', () => { + it('throws ActivepiecesMemoryError on non-2xx with status + body', async () => { + mockFetch(() => jsonResponse({ error: 'forbidden' }, 403)) + const backend = new ActivepiecesMemoryBackend(baseConfig) + try { + await backend.list() + throw new Error('should not reach') + } catch (err) { + expect(err).toBeInstanceOf(ActivepiecesMemoryError) + expect((err as ActivepiecesMemoryError).status).toBe(403) + expect((err as ActivepiecesMemoryError).body).toEqual({ error: 'forbidden' }) + } + }) + + it('deleteById returns false on 404 instead of throwing', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new ActivepiecesMemoryBackend(baseConfig) + expect(await backend.deleteById('mem_missing')).toBe(false) + }) + + it('describe reports kind=activepieces + project + chatbot + source', async () => { + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, chatbotId: 'cb_x' }) + const desc = await backend.describe() + expect(desc).toEqual({ + kind: 'activepieces', + base_url: 'https://app.example.com', + project_id: 'proj_1', + chatbot_id: 'cb_x', + source: 'agentmark', + }) + }) +}) diff --git a/test/memory/remote-backend.test.ts b/test/memory/remote-backend.test.ts deleted file mode 100644 index 2799437..0000000 --- a/test/memory/remote-backend.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Tests for RemoteMemoryBackend — HTTP shape + auth + error handling. - * fetch is mocked; no real network calls. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { RemoteMemoryBackend, RemoteMemoryError } from '../../src/plugins/memory' - -let originalFetch: typeof globalThis.fetch -let requests: Array<{ url: string; init?: RequestInit }> - -beforeEach(() => { - originalFetch = globalThis.fetch - requests = [] -}) - -afterEach(() => { - globalThis.fetch = originalFetch - vi.restoreAllMocks() -}) - -function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as URL | Request).toString() - requests.push({ url, init }) - return Promise.resolve(responder(url, init)) - }) as typeof globalThis.fetch -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -describe('RemoteMemoryBackend — request shape', () => { - it('attaches Bearer token + X-Workspace-Id when configured', async () => { - mockFetch(() => jsonResponse({ record_id: 'mem_x', key: 'k', value: 'v', scope: { type: 'platform' }, version: 1, created_at: '', updated_at: '', access_count: 0 })) - - const backend = new RemoteMemoryBackend({ - baseUrl: 'https://memory.example.com', - token: 'secret', - workspaceId: 'ws_123', - }) - await backend.set({ key: 'k', value: 'v' }) - - expect(requests).toHaveLength(1) - const headers = requests[0].init?.headers as Record - expect(headers.authorization).toBe('Bearer secret') - expect(headers['x-workspace-id']).toBe('ws_123') - expect(headers['content-type']).toBe('application/json') - }) - - it('strips trailing slashes from baseUrl', async () => { - mockFetch(() => jsonResponse(null)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://memory.example.com///' }) - await backend.get({ key: 'k' }) - expect(requests[0].url).toBe('https://memory.example.com/v1/memory/records/get') - }) - - it('throws if baseUrl is missing', () => { - expect(() => new RemoteMemoryBackend({ baseUrl: '' })).toThrow(/baseUrl is required/) - }) -}) - -describe('RemoteMemoryBackend — verb mapping', () => { - it('set → POST /v1/memory/records', async () => { - mockFetch(() => jsonResponse({ record_id: 'r', key: 'k', value: 'v', scope: { type: 'platform' }, created_at: '', updated_at: '', access_count: 0 })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.set({ key: 'k', value: 'v', tags: ['t'], ttlSeconds: 60 }) - - expect(requests[0].url).toBe('https://x.com/v1/memory/records') - expect(requests[0].init?.method).toBe('POST') - const body = JSON.parse(requests[0].init?.body as string) - expect(body).toEqual({ - key: 'k', - value: 'v', - scope: { type: 'platform' }, - tags: ['t'], - ttl_seconds: 60, - }) - }) - - it('get → POST /v1/memory/records/get', async () => { - mockFetch(() => jsonResponse(null)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const result = await backend.get({ key: 'k' }) - expect(result).toBeNull() - expect(requests[0].url).toBe('https://x.com/v1/memory/records/get') - expect(requests[0].init?.method).toBe('POST') - }) - - it('deleteById → DELETE /v1/memory/records/:id', async () => { - mockFetch(() => jsonResponse({ deleted: true })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const ok = await backend.deleteById('mem_abc') - expect(ok).toBe(true) - expect(requests[0].url).toBe('https://x.com/v1/memory/records/mem_abc') - expect(requests[0].init?.method).toBe('DELETE') - }) - - it('list builds the correct query string', async () => { - mockFetch(() => jsonResponse([])) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.list({ type: 'project', id: '/repo' }, 'build_', 25) - expect(requests[0].url).toBe('https://x.com/v1/memory/records?scope_type=project&scope_id=%2Frepo&prefix=build_&limit=25') - expect(requests[0].init?.method).toBe('GET') - }) - - it('search → POST /v1/memory/search', async () => { - mockFetch(() => jsonResponse([])) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.search({ query: 'foo', tags: ['build'] }) - expect(requests[0].url).toBe('https://x.com/v1/memory/search') - expect(JSON.parse(requests[0].init?.body as string)).toEqual({ query: 'foo', tags: ['build'] }) - }) -}) - -describe('RemoteMemoryBackend — error handling', () => { - it('throws RemoteMemoryError with status + body on non-2xx', async () => { - mockFetch(() => jsonResponse({ error: 'unauthorized' }, 401)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', token: 'bad' }) - try { - await backend.set({ key: 'k', value: 'v' }) - throw new Error('should not reach') - } catch (err) { - expect(err).toBeInstanceOf(RemoteMemoryError) - expect((err as RemoteMemoryError).status).toBe(401) - expect((err as RemoteMemoryError).body).toEqual({ error: 'unauthorized' }) - } - }) - - it('describe() returns "remote-http" kind even when the service has no /describe endpoint', async () => { - mockFetch(() => new Response('', { status: 404 })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', workspaceId: 'ws_1' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.base_url).toBe('https://x.com') - expect(desc.workspace_id).toBe('ws_1') - }) - - it('describe() merges remote server-supplied fields when available', async () => { - mockFetch(() => jsonResponse({ total_records: 42, region: 'us-east-1' })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.total_records).toBe(42) - expect(desc.region).toBe('us-east-1') - }) -}) diff --git a/test/recipes/remote-backend.test.ts b/test/recipes/remote-backend.test.ts deleted file mode 100644 index 9db5761..0000000 --- a/test/recipes/remote-backend.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Tests for RemoteRecipeBackend — HTTP shape + 404 handling. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { RemoteRecipeBackend, RemoteRecipeError } from '../../src/plugins/recipes' -import type { Recipe } from '../../src/plugins/recipes' - -let originalFetch: typeof globalThis.fetch -let requests: Array<{ url: string; init?: RequestInit }> - -beforeEach(() => { - originalFetch = globalThis.fetch - requests = [] -}) - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as URL | Request).toString() - requests.push({ url, init }) - return Promise.resolve(responder(url, init)) - }) as typeof globalThis.fetch -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -const SAMPLE: Recipe = { - name: 'test-recipe', - steps: [{ tool: 'agentmark_desktop_execute', args: {} }], - version: 1, - created_at: '', - updated_at: '', -} - -describe('RemoteRecipeBackend — verb mapping', () => { - it('save → POST /v1/recipes with on_conflict query param', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.save(SAMPLE, { on_conflict: 'replace' }) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=replace') - expect(requests[0].init?.method).toBe('POST') - }) - - it('save without on_conflict defaults to "fail"', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.save(SAMPLE) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=fail') - }) - - it('get → GET /v1/recipes/:name', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const r = await backend.get('test-recipe') - expect(r?.name).toBe('test-recipe') - expect(requests[0].url).toBe('https://r.example.com/v1/recipes/test-recipe') - expect(requests[0].init?.method).toBe('GET') - }) - - it('list with target_app builds the query string', async () => { - mockFetch(() => jsonResponse([SAMPLE])) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.list({ target_app: 'excel' }) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?target_app=excel') - }) - - it('delete → DELETE /v1/recipes/:name', async () => { - mockFetch(() => jsonResponse({ deleted: true })) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const ok = await backend.delete('to-go') - expect(ok).toBe(true) - expect(requests[0].init?.method).toBe('DELETE') - }) -}) - -describe('RemoteRecipeBackend — 404 handling', () => { - it('get returns null on 404 (instead of throwing)', async () => { - mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const r = await backend.get('missing') - expect(r).toBeNull() - }) - - it('delete returns false on 404 (instead of throwing)', async () => { - mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const ok = await backend.delete('missing') - expect(ok).toBe(false) - }) - - it('other 4xx/5xx throw RemoteRecipeError', async () => { - mockFetch(() => jsonResponse({ error: 'server_error' }, 500)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - try { - await backend.get('boom') - throw new Error('should not reach') - } catch (err) { - expect(err).toBeInstanceOf(RemoteRecipeError) - expect((err as RemoteRecipeError).status).toBe(500) - } - }) -}) - -describe('RemoteRecipeBackend — describe', () => { - it('returns kind=remote-http with base_url + workspace_id', async () => { - mockFetch(() => new Response('', { status: 404 })) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com', workspaceId: 'ws_1' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.base_url).toBe('https://r.example.com') - expect(desc.workspace_id).toBe('ws_1') - }) -}) - -describe('Backend injection — plugin acceptance', () => { - it('createRecipesPlugin accepts a custom backend', async () => { - const { createRecipesPlugin } = await import('../../src/plugins/recipes') - const plugin = createRecipesPlugin({ - backend: new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }), - }) - expect(plugin.name).toBe('recipes') - // describeSessions reports "custom" when a non-LocalFile backend is used. - const info = plugin.describeSessions?.() - expect((info as { recipes: { kind: string } }).recipes.kind).toBe('custom') - }) -})