From b6d2958a1abf2458825637fa6bec6f4712a93e93 Mon Sep 17 00:00:00 2001 From: Vincent <0426vincent@gmail.com> Date: Sat, 21 Feb 2026 13:25:35 -0800 Subject: [PATCH 1/5] feat: add git-backed version control for app mutations Auto-commit to a git repo in ~/.vellum/apps/ after every app create, update, delete, file write, and file edit. Commits are fire-and-forget so they never block the caller. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/app-git-service.test.ts | 173 ++++++++++++++++++ assistant/src/memory/app-git-service.ts | 82 +++++++++ assistant/src/memory/app-store.ts | 21 +++ 3 files changed, 276 insertions(+) create mode 100644 assistant/src/__tests__/app-git-service.test.ts create mode 100644 assistant/src/memory/app-git-service.ts diff --git a/assistant/src/__tests__/app-git-service.test.ts b/assistant/src/__tests__/app-git-service.test.ts new file mode 100644 index 00000000000..349d0fc2fb0 --- /dev/null +++ b/assistant/src/__tests__/app-git-service.test.ts @@ -0,0 +1,173 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { _resetGitServiceRegistry } from '../workspace/git-service.js'; +import { commitAppChange, _resetAppGitState } from '../memory/app-git-service.js'; + +// Mock getDataDir to use a temp directory +let testDataDir: string; + +mock.module('../util/platform.js', () => ({ + getDataDir: () => testDataDir, + getProjectDir: () => testDataDir, +})); + +// Re-import app-store after mocking so it uses our temp dir +const { createApp, updateApp, deleteApp, writeAppFile, editAppFile, getAppsDir } = await import('../memory/app-store.js'); + +describe('App Git Service', () => { + beforeEach(() => { + testDataDir = join(tmpdir(), `vellum-app-git-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(testDataDir, 'apps'), { recursive: true }); + _resetGitServiceRegistry(); + _resetAppGitState(); + }); + + afterEach(() => { + if (existsSync(testDataDir)) { + rmSync(testDataDir, { recursive: true, force: true }); + } + }); + + function getGitLog(dir: string): string[] { + try { + const output = execFileSync('git', ['log', '--oneline', '--format=%s'], { + cwd: dir, + encoding: 'utf-8', + }); + return output.trim().split('\n').filter(Boolean); + } catch { + return []; + } + } + + test('initializes git repo in apps directory on first commit', async () => { + const appsDir = getAppsDir(); + expect(existsSync(join(appsDir, '.git'))).toBe(false); + + await commitAppChange('test commit'); + + expect(existsSync(join(appsDir, '.git'))).toBe(true); + }); + + test('.gitignore excludes preview files and records', async () => { + const appsDir = getAppsDir(); + await commitAppChange('test commit'); + + const gitignore = readFileSync(join(appsDir, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('*.preview'); + expect(gitignore).toContain('*/records/'); + }); + + test('createApp produces a commit', async () => { + const app = createApp({ + name: 'Test App', + schemaJson: '{}', + htmlDefinition: '

Hello

', + }); + + // Wait for fire-and-forget commit + await commitAppChange.__proto__; // noop — the real wait is below + // Give the fire-and-forget commit time to complete + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Create app: Test App'))).toBe(true); + }); + + test('updateApp produces a commit with changed fields', async () => { + const app = createApp({ + name: 'My App', + schemaJson: '{}', + htmlDefinition: '

v1

', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + updateApp(app.id, { name: 'My App v2', htmlDefinition: '

v2

' }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Update app: My App v2'))).toBe(true); + }); + + test('deleteApp produces a commit with app name', async () => { + const app = createApp({ + name: 'Doomed App', + schemaJson: '{}', + htmlDefinition: '

bye

', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + deleteApp(app.id); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Delete app: Doomed App'))).toBe(true); + }); + + test('writeAppFile produces a commit', async () => { + const app = createApp({ + name: 'File App', + schemaJson: '{}', + htmlDefinition: '

hi

', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + writeAppFile(app.id, 'styles.css', 'body { color: red; }'); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Write styles.css in app'))).toBe(true); + }); + + test('editAppFile produces a commit on success', async () => { + const app = createApp({ + name: 'Edit App', + schemaJson: '{}', + htmlDefinition: '

old text

', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const result = editAppFile(app.id, 'index.html', 'old text', 'new text'); + expect(result.ok).toBe(true); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Edit index.html in app'))).toBe(true); + }); + + test('editAppFile does not commit on failure', async () => { + const app = createApp({ + name: 'No Edit App', + schemaJson: '{}', + htmlDefinition: '

content

', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const commitsBefore = getGitLog(getAppsDir()); + + const result = editAppFile(app.id, 'index.html', 'nonexistent string', 'replacement'); + expect(result.ok).toBe(false); + await new Promise(resolve => setTimeout(resolve, 500)); + + const commitsAfter = getGitLog(getAppsDir()); + // No new commits should have been created for the failed edit + expect(commitsAfter.length).toBe(commitsBefore.length); + }); + + test('commitAppChange swallows errors gracefully', async () => { + // Point to a non-existent directory to force an error + const origGetAppsDir = getAppsDir; + _resetAppGitState(); + + // This should not throw + await commitAppChange('test'); + }); +}); diff --git a/assistant/src/memory/app-git-service.ts b/assistant/src/memory/app-git-service.ts new file mode 100644 index 00000000000..584feb3caad --- /dev/null +++ b/assistant/src/memory/app-git-service.ts @@ -0,0 +1,82 @@ +/** + * Git-backed version control for user-defined apps. + * + * Initializes a git repository in the apps directory (~/.vellum/apps/) and + * commits after every app mutation (create, update, delete, file write/edit). + * Commits are fire-and-forget — they never block the caller. + * + * Reuses WorkspaceGitService for all git operations (mutex, circuit breaker, + * lazy init, etc.). + */ + +import { getWorkspaceGitService } from '../workspace/git-service.js'; +import { getAppsDir } from './app-store.js'; +import { getLogger } from '../util/logger.js'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const log = getLogger('app-git'); + +/** + * Patterns excluded from app version tracking. + * - *.preview — large base64 preview images + * - records directories — user data (form submissions), not app code + */ +const APP_GITIGNORE_RULES = [ + '*.preview', + '*/records/', +]; + +/** + * Ensure the apps directory .gitignore contains app-specific exclusion rules. + * Idempotent: only appends rules that are missing. + */ +function ensureAppGitignoreRules(appsDir: string): void { + const gitignorePath = join(appsDir, '.gitignore'); + let content = ''; + if (existsSync(gitignorePath)) { + content = readFileSync(gitignorePath, 'utf-8'); + } + + const missingRules = APP_GITIGNORE_RULES.filter(rule => !content.includes(rule)); + if (missingRules.length > 0) { + if (content && !content.endsWith('\n')) { + content += '\n'; + } + content += missingRules.join('\n') + '\n'; + writeFileSync(gitignorePath, content, 'utf-8'); + } +} + +let gitignoreEnsured = false; + +/** + * Commit app changes to the apps git repository. + * + * This is fire-and-forget: errors are logged but never thrown. + * The caller should not await the returned promise unless it needs + * to guarantee the commit completed (e.g. in tests). + */ +export async function commitAppChange(message: string): Promise { + try { + const appsDir = getAppsDir(); + + // Ensure .gitignore rules on first call + if (!gitignoreEnsured) { + ensureAppGitignoreRules(appsDir); + gitignoreEnsured = true; + } + + const gitService = getWorkspaceGitService(appsDir); + await gitService.commitChanges(message); + } catch (err) { + log.error({ err, message }, 'Failed to commit app change'); + } +} + +/** + * @internal Test-only: reset the gitignore-ensured flag. + */ +export function _resetAppGitState(): void { + gitignoreEnsured = false; +} diff --git a/assistant/src/memory/app-store.ts b/assistant/src/memory/app-store.ts index 2aa10f2b991..7a0582426cb 100644 --- a/assistant/src/memory/app-store.ts +++ b/assistant/src/memory/app-store.ts @@ -29,6 +29,7 @@ import { isPrebuiltHomeBaseApp, validatePrebuiltHomeBaseHtml, } from '../home-base/prebuilt-home-base-updater.js'; +import { commitAppChange } from './app-git-service.js'; export interface AppDefinition { id: string; @@ -210,6 +211,8 @@ export function createApp(params: { app.pages = params.pages; } + void commitAppChange(`Create app: ${params.name}`); + return app; } @@ -372,14 +375,27 @@ export function updateApp( updated.pages = loadedPages; } + const changedFields = Object.keys(updates).filter(k => updates[k as keyof typeof updates] !== undefined); + void commitAppChange(`Update app: ${updated.name}\n\nChanged: ${changedFields.join(', ')}`); + return updated; } export function deleteApp(id: string): void { validateId(id); const dir = getAppsDir(); + + // Read app name before deleting for the commit message + let appName = id; const filePath = join(dir, `${id}.json`); if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, 'utf-8'); + const app = JSON.parse(raw); + if (app.name) appName = app.name; + } catch { + // fall back to id + } unlinkSync(filePath); } const previewPath = join(dir, `${id}.preview`); @@ -388,6 +404,8 @@ export function deleteApp(id: string): void { } const appDir = join(dir, id); rmSync(appDir, { recursive: true, force: true }); + + void commitAppChange(`Delete app: ${appName}`); } export function createAppRecord(appId: string, data: Record): AppRecord { @@ -527,6 +545,8 @@ export function writeAppFile(appId: string, path: string, content: string): void const dir = join(resolved, '..'); mkdirSync(dir, { recursive: true }); writeFileSync(resolved, content, 'utf-8'); + + void commitAppChange(`Write ${path} in app ${appId}`); } /** @@ -549,6 +569,7 @@ export function editAppFile( const result = applyEdit(content, oldString, newString, replaceAll ?? false); if (result.ok) { writeFileSync(resolved, result.updatedContent, 'utf-8'); + void commitAppChange(`Edit ${path} in app ${appId}`); } return result; } From 34942373321f0f5c47b636039571ee14f09f1e3a Mon Sep 17 00:00:00 2001 From: Vincent <0426vincent@gmail.com> Date: Sat, 21 Feb 2026 13:44:10 -0800 Subject: [PATCH 2/5] fix: remove invalid __proto__ access in test --- assistant/src/__tests__/app-git-service.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/assistant/src/__tests__/app-git-service.test.ts b/assistant/src/__tests__/app-git-service.test.ts index 349d0fc2fb0..3a93108082d 100644 --- a/assistant/src/__tests__/app-git-service.test.ts +++ b/assistant/src/__tests__/app-git-service.test.ts @@ -68,8 +68,6 @@ describe('App Git Service', () => { htmlDefinition: '

Hello

', }); - // Wait for fire-and-forget commit - await commitAppChange.__proto__; // noop — the real wait is below // Give the fire-and-forget commit time to complete await new Promise(resolve => setTimeout(resolve, 500)); From 578d35539a7481bcba6b360e85dc4684a6d03878 Mon Sep 17 00:00:00 2001 From: Vincent <0426vincent@gmail.com> Date: Sat, 21 Feb 2026 13:45:39 -0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20PR=20feedback=20=E2=80=94?= =?UTF-8?q?=20eager=20init=20and=20robust=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add initAppGit() and call it during daemon startup so the bootstrap 'Initial commit' is created before any app mutations write files, preventing the first mutation from being absorbed into the initial commit. - Remove gitignoreEnsured flag — check .gitignore rules on every commit so apps dir recreation mid-process is handled correctly. --- assistant/src/daemon/lifecycle.ts | 5 ++++ assistant/src/memory/app-git-service.ts | 32 ++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/assistant/src/daemon/lifecycle.ts b/assistant/src/daemon/lifecycle.ts index 8a22d5a0fe4..5420d443c0e 100644 --- a/assistant/src/daemon/lifecycle.ts +++ b/assistant/src/daemon/lifecycle.ts @@ -48,6 +48,7 @@ import { installTemplates } from '../hooks/templates.js'; import { HeartbeatService } from '../workspace/heartbeat-service.js'; import { AgentHeartbeatService } from '../agent-heartbeat/agent-heartbeat-service.js'; import { getEnrichmentService } from '../workspace/commit-message-enrichment-service.js'; +import { initAppGit } from '../memory/app-git-service.js'; import { reconcileCallsOnStartup } from '../calls/call-recovery.js'; import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js'; @@ -482,6 +483,10 @@ export async function runDaemon(): Promise { } } + // Eagerly initialize the app git repo so that the bootstrap "Initial + // commit" is created before any app mutations write files. + void initAppGit(); + // Start workspace heartbeat service. This periodically checks all // tracked workspaces for uncommitted changes and auto-commits when // thresholds are exceeded (age > 5 min OR > 20 files changed). diff --git a/assistant/src/memory/app-git-service.ts b/assistant/src/memory/app-git-service.ts index 584feb3caad..2115e95692a 100644 --- a/assistant/src/memory/app-git-service.ts +++ b/assistant/src/memory/app-git-service.ts @@ -48,7 +48,25 @@ function ensureAppGitignoreRules(appsDir: string): void { } } -let gitignoreEnsured = false; +/** + * Eagerly initialize the app git repo so that the "Initial commit" is + * created before any app files are written. Without this, the first + * mutation's files get absorbed into WorkspaceGitService's bootstrap + * commit and the "Create app: ..." commit ends up empty. + * + * Safe to call multiple times — ensureInitialized() is idempotent. + * Fire-and-forget: errors are logged but never thrown. + */ +export async function initAppGit(): Promise { + try { + const appsDir = getAppsDir(); + ensureAppGitignoreRules(appsDir); + const gitService = getWorkspaceGitService(appsDir); + await gitService.ensureInitialized(); + } catch (err) { + log.error({ err }, 'Failed to initialize app git repo'); + } +} /** * Commit app changes to the apps git repository. @@ -61,11 +79,9 @@ export async function commitAppChange(message: string): Promise { try { const appsDir = getAppsDir(); - // Ensure .gitignore rules on first call - if (!gitignoreEnsured) { - ensureAppGitignoreRules(appsDir); - gitignoreEnsured = true; - } + // Re-check .gitignore rules every call in case the apps dir was + // recreated while the process was running. + ensureAppGitignoreRules(appsDir); const gitService = getWorkspaceGitService(appsDir); await gitService.commitChanges(message); @@ -75,8 +91,8 @@ export async function commitAppChange(message: string): Promise { } /** - * @internal Test-only: reset the gitignore-ensured flag. + * @internal Test-only: reset module state. */ export function _resetAppGitState(): void { - gitignoreEnsured = false; + // no-op — kept for test API compatibility } From 454c06f3dd827cf5e5a984e4fdec3749065d4088 Mon Sep 17 00:00:00 2001 From: Vincent <0426vincent@gmail.com> Date: Sat, 21 Feb 2026 13:50:16 -0800 Subject: [PATCH 4/5] fix: remove unused vars in test to pass lint --- assistant/src/__tests__/app-git-service.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/assistant/src/__tests__/app-git-service.test.ts b/assistant/src/__tests__/app-git-service.test.ts index 3a93108082d..20c3423057a 100644 --- a/assistant/src/__tests__/app-git-service.test.ts +++ b/assistant/src/__tests__/app-git-service.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'; -import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -62,7 +62,7 @@ describe('App Git Service', () => { }); test('createApp produces a commit', async () => { - const app = createApp({ + createApp({ name: 'Test App', schemaJson: '{}', htmlDefinition: '

Hello

', @@ -161,8 +161,6 @@ describe('App Git Service', () => { }); test('commitAppChange swallows errors gracefully', async () => { - // Point to a non-existent directory to force an error - const origGetAppsDir = getAppsDir; _resetAppGitState(); // This should not throw From 4bee47ffb78049214c3d2c50d93d7fb4925f9d36 Mon Sep 17 00:00:00 2001 From: Vincent <0426vincent@gmail.com> Date: Sat, 21 Feb 2026 13:51:53 -0800 Subject: [PATCH 5/5] fix: remove eager initAppGit from daemon startup The app git repo should initialize lazily on first app mutation, not on every daemon start. Most sessions never create apps. --- assistant/src/daemon/lifecycle.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/assistant/src/daemon/lifecycle.ts b/assistant/src/daemon/lifecycle.ts index 5420d443c0e..8a22d5a0fe4 100644 --- a/assistant/src/daemon/lifecycle.ts +++ b/assistant/src/daemon/lifecycle.ts @@ -48,7 +48,6 @@ import { installTemplates } from '../hooks/templates.js'; import { HeartbeatService } from '../workspace/heartbeat-service.js'; import { AgentHeartbeatService } from '../agent-heartbeat/agent-heartbeat-service.js'; import { getEnrichmentService } from '../workspace/commit-message-enrichment-service.js'; -import { initAppGit } from '../memory/app-git-service.js'; import { reconcileCallsOnStartup } from '../calls/call-recovery.js'; import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js'; @@ -483,10 +482,6 @@ export async function runDaemon(): Promise { } } - // Eagerly initialize the app git repo so that the bootstrap "Initial - // commit" is created before any app mutations write files. - void initAppGit(); - // Start workspace heartbeat service. This periodically checks all // tracked workspaces for uncommitted changes and auto-commits when // thresholds are exceeded (age > 5 min OR > 20 files changed).