diff --git a/.github/release-notes/v1.0.7.md b/.github/release-notes/v1.0.7.md new file mode 100644 index 00000000..b5c1f39b --- /dev/null +++ b/.github/release-notes/v1.0.7.md @@ -0,0 +1,27 @@ +# Memmy v1.0.7 + +## Highlights + +- Added long-running **Goals** that persist across turns, continue automatically, and expose clear pause, resume, edit, and clear controls with blocked and quota-limited states. +- Rebuilt model configuration around a multi-model workspace. You can add provider connections and models, assign models by capability, choose a conversation model per session, test connections, and switch away from unavailable configurations. +- Added built-in **DeepSeek Harness** integration, including local session discovery, history import, and installation of the Memmy Memory plugin and Skill. + +## Agent and integration improvements + +- Unified GUI and TUI work around the same session queue. Queued requests are visible in chat, labeled by source, removable, and can steer the active turn without losing their place. +- Improved Goal and queue reliability so capped turns finalize cleanly, queued follow-ups continue, and active steering remains consistent as turns change. +- Added an account-model image-to-text fallback when the main model cannot accept image input, with clearer errors when visual analysis or the selected model is unavailable. +- Improved model error handling so stale or removed selections are rejected before sending and users can open model configuration directly from the error state. + +## Desktop and Memory improvements + +- Expanded custom-model management with provider logos, persistent connection-test status, clearer default-model actions, safer deletion rules, and better handling of long model names and dense layouts. +- Moved detailed token usage inline in Settings, with breakdowns for platform and custom models, capabilities, input/output tokens, and individual models. +- Reduced irrelevant Memory recall by filtering low-confidence matches and preserving accurate relevance scores when rewritten-query results are merged. +- Improved large history imports and Memory dashboard reads with bounded processing batches and more efficient statistics queries. +- Added platform-specific microphone-permission guidance and fixed Markdown rendering so single tildes remain intact. + +## Packaging and reliability + +- Fixed Windows packages to include the local API contracts and migration modules required by the bundled Memory runtime. +- Added versioned upgrade migrations for existing model configurations and Goal state, reducing configuration loss and inconsistent state after updating. diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 916476b9..2e4fed4b 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -280,6 +280,97 @@ jobs: fi echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + - name: Build complete release change snapshot + if: ${{ steps.release.outputs.preflight_level == 'full' }} + env: + TARGET_SHA: ${{ steps.release.outputs.target_sha }} + PREVIOUS_TAG: ${{ steps.previous.outputs.previous_tag }} + run: | + set -euo pipefail + mkdir -p release-assets + + compare_base="$PREVIOUS_TAG" + if [[ -z "$compare_base" ]]; then + compare_base="$(git rev-list --max-parents=0 "$TARGET_SHA" | head -n 1)" + fi + test -n "$compare_base" + base_sha="$(git rev-parse "$compare_base^{commit}")" + + if ! git merge-base --is-ancestor "$base_sha" "$TARGET_SHA"; then + echo "::error title=Release history is not linear::$compare_base ($base_sha) is not an ancestor of $TARGET_SHA. Manual recovery: integrate the previous stable release into the release branch before retrying." >&2 + exit 1 + fi + + if ! gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/compare/${base_sha}...${TARGET_SHA}?per_page=1&page=1" \ + > release-assets/COMPARE_METADATA.json; then + echo "::error title=Release compare metadata failed::Could not validate ${base_sha}...${TARGET_SHA} against GitHub. Manual recovery: confirm both commits are reachable in the repository, then retry." >&2 + exit 1 + fi + + node scripts/build-release-compare.mjs \ + --base "$base_sha" \ + --target "$TARGET_SHA" \ + --repository "$GITHUB_REPOSITORY" \ + --output release-assets/COMPARE.json + + api_base="$(jq -r '.base_commit.sha // empty' release-assets/COMPARE_METADATA.json)" + api_merge_base="$(jq -r '.merge_base_commit.sha // empty' release-assets/COMPARE_METADATA.json)" + api_head="$(jq -r '.head_commit.sha // empty' release-assets/COMPARE_METADATA.json)" + api_status="$(jq -r '.status // empty' release-assets/COMPARE_METADATA.json)" + api_ahead="$(jq -r '.ahead_by // -1' release-assets/COMPARE_METADATA.json)" + api_behind="$(jq -r '.behind_by // -1' release-assets/COMPARE_METADATA.json)" + api_total="$(jq -r '.total_commits // -1' release-assets/COMPARE_METADATA.json)" + local_total="$(jq -r '.commits | length' release-assets/COMPARE.json)" + local_files="$(jq -r '.files | length' release-assets/COMPARE.json)" + local_head="$(jq -r '.head_commit.sha // empty' release-assets/COMPARE.json)" + + if [[ "$api_base" != "$base_sha" || "$api_merge_base" != "$base_sha" || "$api_head" != "$TARGET_SHA" || "$local_head" != "$TARGET_SHA" ]]; then + echo "::error title=Release comparison identity mismatch::GitHub and the trusted checkout do not agree on the base, merge base, or target. Manual recovery: refresh the target checkout and retry without changing the tag." >&2 + exit 1 + fi + if [[ "$api_status" != "ahead" || "$api_behind" != "0" || "$api_ahead" != "$api_total" || "$local_total" != "$api_total" ]]; then + echo "::error title=Release comparison count mismatch::GitHub reports status=$api_status ahead=$api_ahead behind=$api_behind total=$api_total, while the trusted checkout contains $local_total commits. Manual recovery: inspect branch ancestry and API consistency before retrying." >&2 + exit 1 + fi + + jq \ + --argjson apiTotalCommits "$api_total" \ + --argjson changedFileCount "$local_files" \ + '.snapshot.remoteMetadataValidated = true + | .snapshot.apiTotalCommits = $apiTotalCommits + | .snapshot.changedFileCount = $changedFileCount' \ + release-assets/COMPARE.json > release-assets/COMPARE.complete.json + mv release-assets/COMPARE.complete.json release-assets/COMPARE.json + + : > release-assets/PULL_REQUESTS.jsonl + while IFS= read -r commit_sha; do + if ! gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls?per_page=100" \ + --jq '.[] | { + number, + title, + htmlUrl: .html_url, + mergedAt: .merged_at, + baseRef: .base.ref, + headRef: .head.ref + }' >> release-assets/PULL_REQUESTS.jsonl; then + echo "::error title=Pull request evidence failed::Could not fetch associated PRs for commit $commit_sha. Manual recovery: re-run after GitHub API recovers or inspect the commit manually." >&2 + exit 1 + fi + done < <(jq -r '.commits[].sha' release-assets/COMPARE.json) + jq -s 'unique_by(.number) | sort_by(.number)' \ + release-assets/PULL_REQUESTS.jsonl > release-assets/PULL_REQUESTS.json + + { + echo "## Complete release change snapshot" + echo + echo "- Commits: $local_total (cross-checked with GitHub)" + echo "- Changed files: $local_files (from the trusted local Git graph)" + echo "- Pull requests: $(jq 'length' release-assets/PULL_REQUESTS.json)" + } >> "$GITHUB_STEP_SUMMARY" + - name: Download and verify OSS artifacts if: ${{ steps.release.outputs.preflight_level == 'full' }} env: @@ -364,22 +455,13 @@ jobs: '{ok: true, needs_review: false, source: $source, manual_file: $file, warnings: []}' \ > release-assets/QUALITY_REPORT.json else - compare_base="$PREVIOUS_TAG" - if [[ -z "$compare_base" ]]; then - compare_base="$(git rev-list --max-parents=0 "$TARGET_SHA" | head -n 1)" - fi - test -n "$compare_base" - - if ! gh api "repos/${GITHUB_REPOSITORY}/compare/${compare_base}...${TARGET_SHA}" \ - > release-assets/DRAFT_COMPARE.json; then - echo "::error title=Draft evidence compare failed::Could not compare ${compare_base}...${TARGET_SHA}. Manual recovery: confirm previous tag and target SHA are reachable, then re-run." >&2 - exit 1 - fi - total_commits="$(jq -r '.total_commits // (.commits | length)' release-assets/DRAFT_COMPARE.json)" + cp release-assets/COMPARE.json release-assets/DRAFT_COMPARE.json + cp release-assets/PULL_REQUESTS.json release-assets/DRAFT_PULL_REQUESTS.json + total_commits="$(jq -r '.total_commits' release-assets/DRAFT_COMPARE.json)" received_commits="$(jq -r '.commits | length' release-assets/DRAFT_COMPARE.json)" compare_head="$(jq -r '.head_commit.sha // empty' release-assets/DRAFT_COMPARE.json)" if [[ "$total_commits" != "$received_commits" ]]; then - echo "::error title=Draft evidence is truncated::GitHub returned $received_commits of $total_commits commits. Manual recovery: reduce release scope or inspect compare evidence manually before retrying." >&2 + echo "::error title=Draft evidence snapshot is incomplete::The trusted snapshot records $received_commits of $total_commits commits. Manual recovery: rebuild the complete local release change snapshot before retrying." >&2 exit 1 fi if [[ "$compare_head" != "$TARGET_SHA" ]]; then @@ -387,26 +469,6 @@ jobs: exit 1 fi - : > release-assets/DRAFT_PULL_REQUESTS.jsonl - while IFS= read -r commit_sha; do - if ! gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls" \ - --jq '.[] | { - number, - title, - htmlUrl: .html_url, - mergedAt: .merged_at, - baseRef: .base.ref, - headRef: .head.ref - }' >> release-assets/DRAFT_PULL_REQUESTS.jsonl; then - echo "::error title=Draft PR evidence failed::Could not fetch associated PRs for commit $commit_sha. Manual recovery: re-run after GitHub API recovers or inspect the commit manually." >&2 - exit 1 - fi - done < <(jq -r '.commits[].sha' release-assets/DRAFT_COMPARE.json) - jq -s 'unique_by(.number) | sort_by(.number)' \ - release-assets/DRAFT_PULL_REQUESTS.jsonl > release-assets/DRAFT_PULL_REQUESTS.json - jq -Rn ' [inputs | select(length > 0) @@ -668,22 +730,17 @@ jobs: fi test -n "$compare_base" - if ! gh api "repos/${GITHUB_REPOSITORY}/compare/${compare_base}...${TARGET_SHA}" \ - > release-assets/COMPARE.json; then - echo "::error title=Release compare failed::Could not compare ${compare_base}...${TARGET_SHA}. Manual recovery: confirm previous tag and target SHA are reachable, then re-run." >&2 + if [[ ! -s release-assets/COMPARE.json || ! -s release-assets/PULL_REQUESTS.json ]]; then + echo "::error title=Complete release snapshot is missing::The shared comparison or pull-request evidence was not produced. Manual recovery: rebuild the complete release change snapshot before retrying." >&2 exit 1 fi - total_commits="$(jq -r '.total_commits // (.commits | length)' release-assets/COMPARE.json)" + total_commits="$(jq -r '.total_commits' release-assets/COMPARE.json)" received_commits="$(jq -r '.commits | length' release-assets/COMPARE.json)" changed_file_count="$(jq -r '.files | length' release-assets/COMPARE.json)" compare_head="$(jq -r '.head_commit.sha // empty' release-assets/COMPARE.json)" if [[ "$total_commits" != "$received_commits" ]]; then - echo "::error title=Release compare is truncated::GitHub returned $received_commits of $total_commits commits. Manual recovery: reduce release scope or inspect compare evidence manually before retrying." >&2 - exit 1 - fi - if [[ "$changed_file_count" -ge 300 ]]; then - echo "::error title=Release diff is too large::Compare contains $changed_file_count changed files. Manual recovery: split the release or perform manual evidence review." >&2 + echo "::error title=Release snapshot is incomplete::The trusted snapshot records $received_commits of $total_commits commits. Manual recovery: rebuild the complete local release change snapshot before retrying." >&2 exit 1 fi if [[ "$compare_head" != "$TARGET_SHA" ]]; then @@ -691,26 +748,6 @@ jobs: exit 1 fi - : > release-assets/PULL_REQUESTS.jsonl - while IFS= read -r commit_sha; do - if ! gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls" \ - --jq '.[] | { - number, - title, - htmlUrl: .html_url, - mergedAt: .merged_at, - baseRef: .base.ref, - headRef: .head.ref - }' >> release-assets/PULL_REQUESTS.jsonl; then - echo "::error title=Pull request evidence failed::Could not fetch associated PRs for commit $commit_sha. Manual recovery: re-run after GitHub API recovers or inspect the commit manually." >&2 - exit 1 - fi - done < <(jq -r '.commits[].sha' release-assets/COMPARE.json) - jq -s 'unique_by(.number) | sort_by(.number)' \ - release-assets/PULL_REQUESTS.jsonl > release-assets/PULL_REQUESTS.json - jq -Rn ' [inputs | select(length > 0) @@ -761,7 +798,8 @@ jobs: status: $compare[0].status, aheadBy: $compare[0].ahead_by, behindBy: $compare[0].behind_by, - totalCommits: $compare[0].total_commits + totalCommits: $compare[0].total_commits, + snapshot: $compare[0].snapshot }, commits: ($compare[0].commits | map({ sha, @@ -814,6 +852,9 @@ jobs: release-assets/RELEASE_NOTES.md release-assets/RELEASE_NOTES_SOURCE.json release-assets/QUALITY_REPORT.json + release-assets/COMPARE_METADATA.json + release-assets/COMPARE.json + release-assets/PULL_REQUESTS.json release-assets/RELEASE_EVIDENCE.json release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index a7daef9c..863b1897 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.0.5"; +export const MEMMY_VERSION = "1.0.7"; diff --git a/App/backend/src/tests/project-version.test.ts b/App/backend/src/tests/project-version.test.ts new file mode 100644 index 00000000..fd5395d5 --- /dev/null +++ b/App/backend/src/tests/project-version.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { MEMMY_VERSION } from "../project-version.js"; + +describe("project version", () => { + it("matches the root release manifest", () => { + const rootManifest = JSON.parse( + readFileSync(resolve(import.meta.dirname, "../../../../package.json"), "utf8"), + ); + + expect(MEMMY_VERSION).toBe(rootManifest.version); + expect(MEMMY_VERSION).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); + }); +}); diff --git a/App/memmy-agent/package-lock.json b/App/memmy-agent/package-lock.json index 39085cfc..75eb1895 100644 --- a/App/memmy-agent/package-lock.json +++ b/App/memmy-agent/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@anthropic-ai/sdk": "^0.100.1", "@aws-sdk/client-bedrock-runtime": "^3.1061.0", diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index 09b8dd8f..16ebd119 100644 --- a/App/memmy-agent/package.json +++ b/App/memmy-agent/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.0.6", + "version": "1.0.7", "description": "TypeScript refactor of memmy's agent runtime.", "type": "module", "main": "./dist/index.js", diff --git a/App/memmy-agent/tests/integrations/channels/mochat-channel.test.ts b/App/memmy-agent/tests/integrations/channels/mochat-channel.test.ts index 82a98786..fef66b8a 100644 --- a/App/memmy-agent/tests/integrations/channels/mochat-channel.test.ts +++ b/App/memmy-agent/tests/integrations/channels/mochat-channel.test.ts @@ -57,11 +57,11 @@ function channel(config: Record, bus = new MessageBus()): MochatCha } async function waitForSocket(): Promise { - for (let i = 0; i < 50; i += 1) { - if (socketMocks.sockets[0]) return socketMocks.sockets[0]; - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error("socket.io client was not created"); + await vi.waitFor( + () => expect(socketMocks.sockets[0]).toBeDefined(), + { timeout: 5000, interval: 10 }, + ); + return socketMocks.sockets[0]; } afterEach(() => { diff --git a/App/shell/desktop/package.json b/App/shell/desktop/package.json index 423749e2..1d96a3f8 100644 --- a/App/shell/desktop/package.json +++ b/App/shell/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@memmy/desktop", - "version": "1.0.6", + "version": "1.0.7", "private": true, "type": "module", "description": "Memmy desktop client.", diff --git a/Memory/package.json b/Memory/package.json index 411dc4de..6234208b 100644 --- a/Memory/package.json +++ b/Memory/package.json @@ -1,6 +1,6 @@ { "name": "@memmy/memory", - "version": "1.0.6", + "version": "1.0.7", "private": true, "type": "module", "main": "./dist/src/index.js", diff --git a/Memory/src/cli/npm/package.json b/Memory/src/cli/npm/package.json index 070da64f..a50de662 100644 --- a/Memory/src/cli/npm/package.json +++ b/Memory/src/cli/npm/package.json @@ -1,6 +1,6 @@ { "name": "@memtensor/memmy-memory-cli", - "version": "1.0.6", + "version": "1.0.7", "description": "Memmy Memory CLI for local agent memory.", "type": "module", "bin": { diff --git a/package-lock.json b/package-lock.json index 30fe62af..e5695926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.0.6", + "version": "1.0.7", "workspaces": [ "Migrations", "Memory", @@ -87,7 +87,7 @@ }, "App/shell/desktop": { "name": "@memmy/desktop", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@memmy/backend": "0.0.0", "@memmy/desktop-interface": "0.0.0", @@ -224,7 +224,7 @@ }, "Memory": { "name": "@memmy/memory", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@huggingface/transformers": "^3.8.0", "@memmy/local-api-contracts": "0.0.0", diff --git a/package.json b/package.json index 13b27d99..f8349124 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.0.6", + "version": "1.0.7", "private": true, "type": "module", "description": "Local-first agent memory substrate with desktop and CLI surfaces.", diff --git a/scripts/build-release-compare.mjs b/scripts/build-release-compare.mjs new file mode 100644 index 00000000..929acb67 --- /dev/null +++ b/scripts/build-release-compare.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const options = parseArgs(process.argv.slice(2)); +const baseSha = gitText(["rev-parse", "--verify", `${options.base}^{commit}`]).trim(); +const targetSha = gitText(["rev-parse", "--verify", `${options.target}^{commit}`]).trim(); + +try { + gitText(["merge-base", "--is-ancestor", baseSha, targetSha]); +} catch { + throw new Error( + `Release comparison base ${baseSha} is not an ancestor of target ${targetSha}`, + ); +} + +const commits = readCommits(baseSha, targetSha, options.repository); +const changedFiles = readChangedFiles(baseSha, targetSha); +const outputPath = resolve(options.output); + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync( + outputPath, + `${JSON.stringify( + { + html_url: `https://github.com/${options.repository}/compare/${baseSha}...${targetSha}`, + status: "ahead", + ahead_by: commits.length, + behind_by: 0, + total_commits: commits.length, + base_commit: { sha: baseSha }, + merge_base_commit: { sha: baseSha }, + head_commit: { sha: targetSha }, + commits, + files: changedFiles, + snapshot: { + source: "local-git", + complete: true, + baseSha, + targetSha, + commitCount: commits.length, + changedFileCount: changedFiles.length, + }, + }, + null, + 2, + )}\n`, + "utf8", +); + +console.log( + `Recorded complete release comparison with ${commits.length} commits and ${changedFiles.length} changed files`, +); + +function parseArgs(args) { + const parsed = {}; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flag?.startsWith("--") || value === undefined) { + throw new Error( + "Usage: build-release-compare.mjs --base --target --repository --output ", + ); + } + const key = flag.slice(2); + if (!["base", "target", "repository", "output"].includes(key) || parsed[key]) { + throw new Error(`Unknown or duplicate option: ${flag}`); + } + parsed[key] = value; + } + + for (const key of ["base", "target", "repository", "output"]) { + if (!parsed[key]) throw new Error(`Missing --${key}`); + } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(parsed.repository)) { + throw new Error("Repository must use the owner/name form"); + } + return parsed; +} + +function gitText(args) { + return execFileSync("git", args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); +} + +function readCommits(baseSha, targetSha, repository) { + const fields = splitNul( + gitText([ + "log", + "--reverse", + "-z", + "--format=%H%x00%B", + `${baseSha}..${targetSha}`, + ]), + ); + if (fields.length % 2 !== 0) { + throw new Error("Unexpected NUL-delimited git log output"); + } + + const commits = []; + for (let index = 0; index < fields.length; index += 2) { + const sha = fields[index]; + const message = fields[index + 1].replace(/\n+$/u, ""); + commits.push({ + sha, + html_url: `https://github.com/${repository}/commit/${sha}`, + commit: { message }, + }); + } + return commits; +} + +function readChangedFiles(baseSha, targetSha) { + const statusFields = splitNul( + gitText(["diff", "--name-status", "-z", "--find-renames", baseSha, targetSha]), + ); + const stats = readNumstat(baseSha, targetSha); + const consumedStats = new Set(); + const files = []; + + for (let index = 0; index < statusFields.length; ) { + const rawStatus = statusFields[index++]; + const statusCode = rawStatus[0]; + const previousPath = statusCode === "R" || statusCode === "C" ? statusFields[index++] : null; + const path = statusFields[index++]; + if (!rawStatus || !path) throw new Error("Unexpected NUL-delimited git status output"); + + const fileStats = stats.get(path); + if (!fileStats) throw new Error(`Missing numstat entry for changed path: ${path}`); + consumedStats.add(path); + files.push({ + filename: path, + ...(previousPath ? { previous_filename: previousPath } : {}), + status: statusName(statusCode), + additions: fileStats.additions, + deletions: fileStats.deletions, + changes: fileStats.additions + fileStats.deletions, + }); + } + if (consumedStats.size !== stats.size) { + const unmatched = [...stats.keys()].filter((path) => !consumedStats.has(path)); + throw new Error(`Numstat contains unmatched paths: ${unmatched.join(", ")}`); + } + return files; +} + +function readNumstat(baseSha, targetSha) { + const fields = splitNul( + gitText(["diff", "--numstat", "-z", "--find-renames", baseSha, targetSha]), + ); + const stats = new Map(); + + for (let index = 0; index < fields.length; ) { + const record = fields[index++]; + const firstTab = record.indexOf("\t"); + const secondTab = record.indexOf("\t", firstTab + 1); + if (firstTab < 0 || secondTab < 0) { + throw new Error("Unexpected NUL-delimited git numstat output"); + } + + const additions = parseStat(record.slice(0, firstTab)); + const deletions = parseStat(record.slice(firstTab + 1, secondTab)); + let path = record.slice(secondTab + 1); + if (!path) { + index += 1; + path = fields[index++]; + } + if (!path) throw new Error("Missing path in git numstat output"); + stats.set(path, { additions, deletions }); + } + return stats; +} + +function parseStat(value) { + if (value === "-") return 0; + if (!/^\d+$/.test(value)) throw new Error(`Invalid git numstat value: ${value}`); + return Number(value); +} + +function splitNul(value) { + const fields = value.split("\0"); + if (fields.at(-1) === "") fields.pop(); + return fields; +} + +function statusName(code) { + const status = { + A: "added", + C: "copied", + D: "removed", + M: "modified", + R: "renamed", + T: "changed", + }[code]; + if (!status) throw new Error(`Unsupported git diff status: ${code}`); + return status; +} diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 6b40d935..18202cd9 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -8,7 +8,9 @@ import YAML from "yaml"; const repoRoot = resolve(import.meta.dirname, ".."); const legacyWorkflowPath = resolve(repoRoot, ".github/workflows/github-release.yml"); const draftWorkflowPath = resolve(repoRoot, ".github/workflows/github-draft-release-v2.yml"); +const releaseCompareScriptPath = resolve(repoRoot, "scripts/build-release-compare.mjs"); const draftSource = readFileSync(draftWorkflowPath, "utf8"); +const releaseCompareSource = readFileSync(releaseCompareScriptPath, "utf8"); const draftWorkflow = YAML.parse(draftSource); const draftJob = draftWorkflow.jobs.release; const draftSteps = draftJob.steps as Array>; @@ -112,6 +114,74 @@ describe("Memmy release workflow metadata", () => { }); describe("GitHub Draft Release v2 workflow", () => { + it("builds an uncapped, NUL-safe comparison from the trusted local Git graph", () => { + const tempDir = mkdtempSync(resolve(tmpdir(), "memmy-release-compare-")); + const git = (args: string[]) => + spawnSync("git", args, { cwd: tempDir, encoding: "utf8" }); + + expect(git(["init", "--initial-branch=main"]).status).toBe(0); + expect(git(["config", "user.name", "Release Test"]).status).toBe(0); + expect(git(["config", "user.email", "release-test@example.invalid"]).status).toBe(0); + writeFileSync(resolve(tempDir, "base file.txt"), "base\n"); + expect(git(["add", "."]).status).toBe(0); + expect(git(["commit", "-m", "base"]).status).toBe(0); + const baseSha = git(["rev-parse", "HEAD"]).stdout.trim(); + + for (let index = 0; index < 301; index += 1) { + writeFileSync(resolve(tempDir, `bulk-${String(index).padStart(3, "0")}.txt`), `${index}\n`); + } + expect(git(["add", "."]).status).toBe(0); + expect(git(["commit", "-m", "add more than 300 files"]).status).toBe(0); + expect(git(["mv", "base file.txt", "renamed file.txt"]).status).toBe(0); + writeFileSync(resolve(tempDir, "bulk-000.txt"), "updated\n"); + expect(git(["add", "."]).status).toBe(0); + expect(git(["commit", "-m", "rename and update"]).status).toBe(0); + const targetSha = git(["rev-parse", "HEAD"]).stdout.trim(); + const outputPath = resolve(tempDir, "compare.json"); + + const result = spawnSync( + "node", + [ + releaseCompareScriptPath, + "--base", + baseSha, + "--target", + targetSha, + "--repository", + "MemTensor/memmy-agent", + "--output", + outputPath, + ], + { cwd: tempDir, encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + + const comparison = JSON.parse(readFileSync(outputPath, "utf8")); + expect(comparison.total_commits).toBe(2); + expect(comparison.commits).toHaveLength(2); + expect(comparison.files).toHaveLength(302); + expect(comparison.files).toContainEqual( + expect.objectContaining({ + filename: "renamed file.txt", + previous_filename: "base file.txt", + status: "renamed", + }), + ); + expect(comparison.snapshot).toEqual({ + source: "local-git", + complete: true, + baseSha, + targetSha, + commitCount: 2, + changedFileCount: 302, + }); + + expect(releaseCompareSource).toContain('"--reverse"'); + expect(releaseCompareSource).toContain('"--name-status", "-z"'); + expect(releaseCompareSource).toContain('"--numstat", "-z"'); + expect(releaseCompareSource).toContain('"merge-base", "--is-ancestor"'); + }); + it("keeps every shell block syntactically valid", () => { const tempDir = mkdtempSync(resolve(tmpdir(), "memmy-release-workflow-")); @@ -291,6 +361,7 @@ describe("GitHub Draft Release v2 workflow", () => { it("records independently auditable commits, PRs, files, versions, and assets", () => { for (const stepName of [ + "Build complete release change snapshot", "Build release notes", "Build auditable release evidence", ]) { @@ -332,14 +403,19 @@ describe("GitHub Draft Release v2 workflow", () => { expect(releaseNotes).toContain("QUALITY_REPORT.json"); expect(releaseNotes).not.toContain("releases/generate-notes"); expect(releaseNotes).not.toContain("github-generated"); + const snapshot = draftScript("Build complete release change snapshot"); + expect(snapshot).toContain("git merge-base --is-ancestor"); + expect(snapshot).toContain("scripts/build-release-compare.mjs"); + expect(snapshot).toContain("COMPARE_METADATA.json"); + expect(snapshot).toContain("api_merge_base"); + expect(snapshot).toContain("api_total"); + expect(snapshot).toContain("gh api --paginate"); + expect(snapshot).toContain("?per_page=100"); + expect(snapshot).toContain("remoteMetadataValidated"); const evidence = draftScript("Build auditable release evidence"); - expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); - expect(evidence).toContain("commits/${commit_sha}/pulls"); - expect(evidence).toContain("Release compare failed"); - expect(evidence).toContain("Release compare is truncated"); - expect(evidence).toContain("Release diff is too large"); + expect(evidence).toContain("Complete release snapshot is missing"); + expect(evidence).toContain("Release snapshot is incomplete"); expect(evidence).toContain("Release compare target mismatch"); - expect(evidence).toContain("Pull request evidence failed"); expect(evidence).toContain("memmy.release.evidence.v2"); expect(evidence).toContain("changedFiles"); expect(evidence).toContain("versionFiles"); @@ -355,6 +431,9 @@ describe("GitHub Draft Release v2 workflow", () => { expect(JSON.stringify(uploadAudit)).toContain("RELEASE_NOTES.md"); expect(JSON.stringify(uploadAudit)).toContain("RELEASE_NOTES_SOURCE.json"); expect(JSON.stringify(uploadAudit)).toContain("QUALITY_REPORT.json"); + expect(JSON.stringify(uploadAudit)).toContain("COMPARE_METADATA.json"); + expect(JSON.stringify(uploadAudit)).toContain("COMPARE.json"); + expect(JSON.stringify(uploadAudit)).toContain("PULL_REQUESTS.json"); expect(draftScript("Create draft release and upload every asset")).toContain( "RELEASE_EVIDENCE.json", );