feat: GNAP sync engine for git-native task persistence (#396)
Implements Phase 1 of GNAP (Git-Native Agent Protocol) integration:
- SQLite stays primary, GNAP repo is an optional sync target
- Push task create/update/delete to .gnap/tasks/{id}.json with git commits
- Status/priority mapping between MC and GNAP formats
- Management API at /api/gnap (status, init, manual sync)
- GNAP badge in task board header with click-to-sync
- 15 unit tests covering mapping, init, push, remove, pull, and status
Enable with GNAP_ENABLED=true in .env. Follows the same fire-and-forget
pattern as the existing GitHub sync engine.
Closes #374
Supersedes #389
This commit is contained in:
parent
d87307a4f8
commit
85b4184aa9
|
|
@ -0,0 +1,70 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireRole } from '@/lib/auth'
|
||||
import { config } from '@/lib/config'
|
||||
import { logger } from '@/lib/logger'
|
||||
import {
|
||||
initGnapRepo,
|
||||
syncGnap,
|
||||
getGnapStatus,
|
||||
} from '@/lib/gnap-sync'
|
||||
|
||||
/**
|
||||
* GET /api/gnap — GNAP sync status
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = requireRole(request, 'operator')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
const gnapConfig = config.gnap
|
||||
if (!gnapConfig.enabled) {
|
||||
return NextResponse.json({ enabled: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const status = getGnapStatus(gnapConfig.repoPath)
|
||||
return NextResponse.json({
|
||||
enabled: true,
|
||||
repoPath: gnapConfig.repoPath,
|
||||
autoSync: gnapConfig.autoSync,
|
||||
...status,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'GET /api/gnap error')
|
||||
return NextResponse.json({ error: 'Failed to get GNAP status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/gnap?action=init|sync — GNAP management
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = requireRole(request, 'operator')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
const gnapConfig = config.gnap
|
||||
if (!gnapConfig.enabled) {
|
||||
return NextResponse.json({ error: 'GNAP is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'init': {
|
||||
initGnapRepo(gnapConfig.repoPath)
|
||||
const status = getGnapStatus(gnapConfig.repoPath)
|
||||
return NextResponse.json({ success: true, ...status })
|
||||
}
|
||||
case 'sync': {
|
||||
const result = syncGnap(gnapConfig.repoPath)
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, action }, 'POST /api/gnap error')
|
||||
return NextResponse.json({ error: 'GNAP operation failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import { validateBody, updateTaskSchema } from '@/lib/validation';
|
|||
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||
import { normalizeTaskUpdateStatus } from '@/lib/task-status';
|
||||
import { pushTaskToGitHub } from '@/lib/github-sync-engine';
|
||||
import { pushTaskToGnap, removeTaskFromGnap } from '@/lib/gnap-sync';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
||||
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
||||
|
|
@ -402,6 +404,12 @@ export async function PUT(
|
|||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget GNAP sync for task updates
|
||||
if (config.gnap.enabled && config.gnap.autoSync && changes.length > 0) {
|
||||
try { pushTaskToGnap(updatedTask as any, config.gnap.repoPath) }
|
||||
catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task update') }
|
||||
}
|
||||
|
||||
// Broadcast to SSE clients
|
||||
eventBus.broadcast('task.updated', parsedTask);
|
||||
|
||||
|
|
@ -463,6 +471,12 @@ export async function DELETE(
|
|||
workspaceId
|
||||
);
|
||||
|
||||
// Remove from GNAP repo
|
||||
if (config.gnap.enabled && config.gnap.autoSync) {
|
||||
try { removeTaskFromGnap(taskId, config.gnap.repoPath) }
|
||||
catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task deletion') }
|
||||
}
|
||||
|
||||
// Broadcast to SSE clients
|
||||
eventBus.broadcast('task.deleted', { id: taskId, title: task.title });
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/li
|
|||
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||
import { normalizeTaskCreateStatus } from '@/lib/task-status';
|
||||
import { pushTaskToGitHub } from '@/lib/github-sync-engine';
|
||||
import { pushTaskToGnap } from '@/lib/gnap-sync';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
||||
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
||||
|
|
@ -314,6 +316,12 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget GNAP sync for new tasks
|
||||
if (config.gnap.enabled && config.gnap.autoSync) {
|
||||
try { pushTaskToGnap(parsedTask as any, config.gnap.repoPath) }
|
||||
catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for new task') }
|
||||
}
|
||||
|
||||
// Broadcast to SSE clients
|
||||
eventBus.broadcast('task.created', parsedTask);
|
||||
|
||||
|
|
|
|||
|
|
@ -323,6 +323,8 @@ export function TaskBoardPanel() {
|
|||
timeoutSeconds: 300
|
||||
})
|
||||
const [isSpawning, setIsSpawning] = useState(false)
|
||||
const [gnapStatus, setGnapStatus] = useState<{ enabled: boolean; taskCount?: number; lastSync?: string } | null>(null)
|
||||
const [gnapSyncing, setGnapSyncing] = useState(false)
|
||||
const isLocal = dashboardMode === 'local'
|
||||
const dragCounter = useRef(0)
|
||||
const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
|
||||
|
|
@ -412,6 +414,26 @@ export function TaskBoardPanel() {
|
|||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Fetch GNAP status
|
||||
useEffect(() => {
|
||||
fetch('/api/gnap')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setGnapStatus(data) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleGnapSync = useCallback(async () => {
|
||||
setGnapSyncing(true)
|
||||
try {
|
||||
const res = await fetch('/api/gnap?action=sync', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setGnapStatus(prev => prev ? { ...prev, taskCount: data.pushed, lastSync: data.lastSync } : prev)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setGnapSyncing(false) }
|
||||
}, [])
|
||||
|
||||
// Sync global activeProject into local projectFilter
|
||||
useEffect(() => {
|
||||
const newFilter = activeProject ? String(activeProject.id) : 'all'
|
||||
|
|
@ -663,6 +685,24 @@ export function TaskBoardPanel() {
|
|||
<div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-bold text-foreground">{t('title')}</h2>
|
||||
{gnapStatus?.enabled && (
|
||||
<button
|
||||
onClick={handleGnapSync}
|
||||
disabled={gnapSyncing}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
title={gnapStatus.lastSync ? `Last sync: ${gnapStatus.lastSync}` : 'Click to sync'}
|
||||
>
|
||||
GNAP
|
||||
{gnapStatus.taskCount != null && (
|
||||
<span className="text-emerald-400/70">{gnapStatus.taskCount}</span>
|
||||
)}
|
||||
{gnapSyncing && (
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M8 1.5a6.5 6.5 0 1 1-4.5 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={projectFilter}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
mcStatusToGnap,
|
||||
gnapStatusToMc,
|
||||
mcPriorityToGnap,
|
||||
initGnapRepo,
|
||||
pushTaskToGnap,
|
||||
removeTaskFromGnap,
|
||||
pullTasksFromGnap,
|
||||
getGnapStatus,
|
||||
type McTask,
|
||||
} from '../gnap-sync'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gnap-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('status mapping', () => {
|
||||
it('maps MC statuses to GNAP states', () => {
|
||||
expect(mcStatusToGnap('pending')).toBe('backlog')
|
||||
expect(mcStatusToGnap('inbox')).toBe('backlog')
|
||||
expect(mcStatusToGnap('in_progress')).toBe('in_progress')
|
||||
expect(mcStatusToGnap('done')).toBe('done')
|
||||
expect(mcStatusToGnap('review')).toBe('review')
|
||||
expect(mcStatusToGnap('blocked')).toBe('blocked')
|
||||
expect(mcStatusToGnap('cancelled')).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('maps GNAP states back to MC statuses', () => {
|
||||
expect(gnapStatusToMc('backlog')).toBe('inbox')
|
||||
expect(gnapStatusToMc('in_progress')).toBe('in_progress')
|
||||
expect(gnapStatusToMc('done')).toBe('done')
|
||||
expect(gnapStatusToMc('review')).toBe('review')
|
||||
})
|
||||
|
||||
it('falls back for unknown values', () => {
|
||||
expect(mcStatusToGnap('unknown_status')).toBe('backlog')
|
||||
expect(gnapStatusToMc('unknown_state')).toBe('inbox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('priority mapping', () => {
|
||||
it('maps MC priorities to GNAP priorities', () => {
|
||||
expect(mcPriorityToGnap('low')).toBe('low')
|
||||
expect(mcPriorityToGnap('medium')).toBe('medium')
|
||||
expect(mcPriorityToGnap('high')).toBe('high')
|
||||
expect(mcPriorityToGnap('critical')).toBe('critical')
|
||||
expect(mcPriorityToGnap('urgent')).toBe('critical')
|
||||
})
|
||||
|
||||
it('falls back to medium for unknown priorities', () => {
|
||||
expect(mcPriorityToGnap('unknown')).toBe('medium')
|
||||
})
|
||||
})
|
||||
|
||||
describe('initGnapRepo', () => {
|
||||
it('creates directory structure and initializes git', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
|
||||
expect(fs.existsSync(path.join(repoPath, 'version'))).toBe(true)
|
||||
expect(fs.existsSync(path.join(repoPath, 'agents.json'))).toBe(true)
|
||||
expect(fs.existsSync(path.join(repoPath, 'tasks'))).toBe(true)
|
||||
expect(fs.existsSync(path.join(repoPath, '.git'))).toBe(true)
|
||||
|
||||
expect(fs.readFileSync(path.join(repoPath, 'version'), 'utf-8').trim()).toBe('1')
|
||||
})
|
||||
|
||||
it('is idempotent — re-running does not error', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
initGnapRepo(repoPath)
|
||||
|
||||
expect(fs.existsSync(path.join(repoPath, '.git'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushTaskToGnap', () => {
|
||||
it('writes task JSON and commits', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
|
||||
const task: McTask = {
|
||||
id: 42,
|
||||
title: 'Test task',
|
||||
description: 'A test',
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assigned_to: 'agent-claude',
|
||||
tags: ['auth', 'sprint-1'],
|
||||
created_at: 1710500000,
|
||||
updated_at: 1710510000,
|
||||
project_id: 1,
|
||||
}
|
||||
|
||||
pushTaskToGnap(task, repoPath)
|
||||
|
||||
const filePath = path.join(repoPath, 'tasks', 'mc-42.json')
|
||||
expect(fs.existsSync(filePath)).toBe(true)
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
expect(content.id).toBe('mc-42')
|
||||
expect(content.title).toBe('Test task')
|
||||
expect(content.state).toBe('in_progress')
|
||||
expect(content.priority).toBe('high')
|
||||
expect(content.assignee).toBe('agent-claude')
|
||||
expect(content.tags).toEqual(['auth', 'sprint-1'])
|
||||
expect(content.mc_id).toBe(42)
|
||||
expect(content.mc_project_id).toBe(1)
|
||||
|
||||
// Verify it was committed
|
||||
const log = execFileSync('git', ['log', '--oneline'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
expect(log).toContain('Update task mc-42')
|
||||
})
|
||||
|
||||
it('handles string tags (JSON serialized)', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
|
||||
const task: McTask = {
|
||||
id: 1,
|
||||
title: 'String tags task',
|
||||
status: 'pending',
|
||||
priority: 'low',
|
||||
tags: '["bug","fix"]',
|
||||
}
|
||||
|
||||
pushTaskToGnap(task, repoPath)
|
||||
|
||||
const content = JSON.parse(
|
||||
fs.readFileSync(path.join(repoPath, 'tasks', 'mc-1.json'), 'utf-8')
|
||||
)
|
||||
expect(content.tags).toEqual(['bug', 'fix'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeTaskFromGnap', () => {
|
||||
it('removes the task file and commits', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
|
||||
const task: McTask = {
|
||||
id: 7,
|
||||
title: 'To be removed',
|
||||
status: 'done',
|
||||
priority: 'low',
|
||||
}
|
||||
pushTaskToGnap(task, repoPath)
|
||||
expect(fs.existsSync(path.join(repoPath, 'tasks', 'mc-7.json'))).toBe(true)
|
||||
|
||||
removeTaskFromGnap(7, repoPath)
|
||||
expect(fs.existsSync(path.join(repoPath, 'tasks', 'mc-7.json'))).toBe(false)
|
||||
|
||||
const log = execFileSync('git', ['log', '--oneline'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
expect(log).toContain('Remove task mc-7')
|
||||
})
|
||||
|
||||
it('does nothing when task does not exist', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
// Should not throw
|
||||
removeTaskFromGnap(999, repoPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pullTasksFromGnap', () => {
|
||||
it('reads all task files from the repo', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
|
||||
pushTaskToGnap({ id: 1, title: 'Task A', status: 'pending', priority: 'low' }, repoPath)
|
||||
pushTaskToGnap({ id: 2, title: 'Task B', status: 'done', priority: 'high' }, repoPath)
|
||||
|
||||
const tasks = pullTasksFromGnap(repoPath)
|
||||
expect(tasks).toHaveLength(2)
|
||||
|
||||
const ids = tasks.map(t => t.id).sort()
|
||||
expect(ids).toEqual(['mc-1', 'mc-2'])
|
||||
})
|
||||
|
||||
it('returns empty array for non-existent directory', () => {
|
||||
const tasks = pullTasksFromGnap(path.join(tmpDir, 'nonexistent'))
|
||||
expect(tasks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGnapStatus', () => {
|
||||
it('reports uninitialized for empty directory', () => {
|
||||
const status = getGnapStatus(path.join(tmpDir, 'empty'))
|
||||
expect(status.initialized).toBe(false)
|
||||
expect(status.taskCount).toBe(0)
|
||||
expect(status.hasRemote).toBe(false)
|
||||
})
|
||||
|
||||
it('reports correct status after init and push', () => {
|
||||
const repoPath = path.join(tmpDir, 'gnap-repo')
|
||||
initGnapRepo(repoPath)
|
||||
pushTaskToGnap({ id: 1, title: 'Task', status: 'pending', priority: 'medium' }, repoPath)
|
||||
|
||||
const status = getGnapStatus(repoPath)
|
||||
expect(status.initialized).toBe(true)
|
||||
expect(status.taskCount).toBe(1)
|
||||
expect(status.hasRemote).toBe(false)
|
||||
expect(status.remoteUrl).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
@ -63,6 +63,9 @@ const defaultMemoryDir = (() => {
|
|||
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
|
||||
})()
|
||||
|
||||
const resolvedGnapRepoPath =
|
||||
process.env.GNAP_REPO_PATH || path.join(configuredDataDir, '.gnap')
|
||||
|
||||
export const config = {
|
||||
claudeHome:
|
||||
process.env.MC_CLAUDE_HOME ||
|
||||
|
|
@ -91,6 +94,12 @@ export const config = {
|
|||
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
||||
homeDir: os.homedir(),
|
||||
gnap: {
|
||||
enabled: process.env.GNAP_ENABLED === 'true',
|
||||
repoPath: resolvedGnapRepoPath,
|
||||
autoSync: process.env.GNAP_AUTO_SYNC !== 'false',
|
||||
remoteUrl: process.env.GNAP_REMOTE_URL || '',
|
||||
},
|
||||
// Data retention (days). 0 = keep forever. Negative values are clamped to 0.
|
||||
retention: {
|
||||
activities: clampInt(Number(process.env.MC_RETAIN_ACTIVITIES_DAYS || '90'), 0, 3650, 90),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
/**
|
||||
* GNAP Sync Engine — push MC tasks to a Git-Native Agent Protocol repo.
|
||||
*
|
||||
* SQLite remains the primary store. The GNAP repo is an optional sync target
|
||||
* following the same pattern as `github-sync-engine.ts`.
|
||||
*
|
||||
* Phase 1: MC → GNAP only (push). Pull/bidirectional sync is Phase 2.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
// ── Status / priority mapping ──────────────────────────────────
|
||||
|
||||
const MC_TO_GNAP_STATUS: Record<string, string> = {
|
||||
pending: 'backlog',
|
||||
inbox: 'backlog',
|
||||
assigned: 'ready',
|
||||
ready: 'ready',
|
||||
in_progress: 'in_progress',
|
||||
review: 'review',
|
||||
quality_review: 'review',
|
||||
completed: 'done',
|
||||
done: 'done',
|
||||
blocked: 'blocked',
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
|
||||
const GNAP_TO_MC_STATUS: Record<string, string> = {
|
||||
backlog: 'inbox',
|
||||
ready: 'assigned',
|
||||
in_progress: 'in_progress',
|
||||
review: 'review',
|
||||
done: 'done',
|
||||
blocked: 'blocked',
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
|
||||
const MC_TO_GNAP_PRIORITY: Record<string, string> = {
|
||||
low: 'low',
|
||||
medium: 'medium',
|
||||
high: 'high',
|
||||
critical: 'critical',
|
||||
urgent: 'critical',
|
||||
}
|
||||
|
||||
export function mcStatusToGnap(status: string): string {
|
||||
return MC_TO_GNAP_STATUS[status] || 'backlog'
|
||||
}
|
||||
|
||||
export function gnapStatusToMc(state: string): string {
|
||||
return GNAP_TO_MC_STATUS[state] || 'inbox'
|
||||
}
|
||||
|
||||
export function mcPriorityToGnap(priority: string): string {
|
||||
return MC_TO_GNAP_PRIORITY[priority] || 'medium'
|
||||
}
|
||||
|
||||
// ── GNAP task JSON type ────────────────────────────────────────
|
||||
|
||||
export interface GnapTask {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
state: string
|
||||
assignee: string
|
||||
priority: string
|
||||
tags: string[]
|
||||
created: string
|
||||
updated: string
|
||||
mc_id: number
|
||||
mc_project_id: number | null
|
||||
}
|
||||
|
||||
// ── Git helpers ────────────────────────────────────────────────
|
||||
|
||||
function git(repoPath: string, args: string[]): string {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
} catch (err: any) {
|
||||
const stderr = err.stderr?.toString?.() || ''
|
||||
throw new Error(`git ${args[0]} failed: ${stderr || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function hasRemote(repoPath: string): boolean {
|
||||
try {
|
||||
const remotes = git(repoPath, ['remote'])
|
||||
return remotes.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasChanges(repoPath: string): boolean {
|
||||
try {
|
||||
const status = git(repoPath, ['status', '--porcelain'])
|
||||
return status.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core functions ─────────────────────────────────────────────
|
||||
|
||||
export function initGnapRepo(repoPath: string): void {
|
||||
fs.mkdirSync(path.join(repoPath, 'tasks'), { recursive: true })
|
||||
|
||||
const versionFile = path.join(repoPath, 'version')
|
||||
if (!fs.existsSync(versionFile)) {
|
||||
fs.writeFileSync(versionFile, '1\n')
|
||||
}
|
||||
|
||||
const agentsFile = path.join(repoPath, 'agents.json')
|
||||
if (!fs.existsSync(agentsFile)) {
|
||||
fs.writeFileSync(agentsFile, JSON.stringify({ agents: [] }, null, 2) + '\n')
|
||||
}
|
||||
|
||||
// Init git if not already a repo
|
||||
const gitDir = path.join(repoPath, '.git')
|
||||
if (!fs.existsSync(gitDir)) {
|
||||
git(repoPath, ['init'])
|
||||
git(repoPath, ['add', '.'])
|
||||
git(repoPath, ['commit', '-m', 'Initialize GNAP repository'])
|
||||
}
|
||||
|
||||
logger.info({ repoPath }, 'GNAP repo initialized')
|
||||
}
|
||||
|
||||
export interface McTask {
|
||||
id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
status: string
|
||||
priority: string
|
||||
assigned_to?: string | null
|
||||
tags?: string[] | string | null
|
||||
created_at?: number | null
|
||||
updated_at?: number | null
|
||||
project_id?: number | null
|
||||
}
|
||||
|
||||
function taskToGnapJson(task: McTask): GnapTask {
|
||||
const tags = Array.isArray(task.tags)
|
||||
? task.tags
|
||||
: (typeof task.tags === 'string' ? JSON.parse(task.tags || '[]') : [])
|
||||
|
||||
return {
|
||||
id: `mc-${task.id}`,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
state: mcStatusToGnap(task.status),
|
||||
assignee: task.assigned_to || '',
|
||||
priority: mcPriorityToGnap(task.priority),
|
||||
tags,
|
||||
created: task.created_at
|
||||
? new Date(task.created_at * 1000).toISOString()
|
||||
: new Date().toISOString(),
|
||||
updated: task.updated_at
|
||||
? new Date(task.updated_at * 1000).toISOString()
|
||||
: new Date().toISOString(),
|
||||
mc_id: task.id,
|
||||
mc_project_id: task.project_id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function pushTaskToGnap(task: McTask, repoPath: string): void {
|
||||
const tasksDir = path.join(repoPath, 'tasks')
|
||||
fs.mkdirSync(tasksDir, { recursive: true })
|
||||
|
||||
const gnapTask = taskToGnapJson(task)
|
||||
const filePath = path.join(tasksDir, `${gnapTask.id}.json`)
|
||||
fs.writeFileSync(filePath, JSON.stringify(gnapTask, null, 2) + '\n')
|
||||
|
||||
git(repoPath, ['add', path.relative(repoPath, filePath)])
|
||||
|
||||
if (hasChanges(repoPath)) {
|
||||
git(repoPath, ['commit', '-m', `Update task ${gnapTask.id}: ${task.title}`])
|
||||
}
|
||||
|
||||
if (hasRemote(repoPath)) {
|
||||
try {
|
||||
git(repoPath, ['push'])
|
||||
} catch (err) {
|
||||
logger.warn({ err, repoPath }, 'GNAP push to remote failed (continuing)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeTaskFromGnap(taskId: number, repoPath: string): void {
|
||||
const filePath = path.join(repoPath, 'tasks', `mc-${taskId}.json`)
|
||||
|
||||
if (!fs.existsSync(filePath)) return
|
||||
|
||||
git(repoPath, ['rm', path.relative(repoPath, filePath)])
|
||||
|
||||
if (hasChanges(repoPath)) {
|
||||
git(repoPath, ['commit', '-m', `Remove task mc-${taskId}`])
|
||||
}
|
||||
|
||||
if (hasRemote(repoPath)) {
|
||||
try {
|
||||
git(repoPath, ['push'])
|
||||
} catch (err) {
|
||||
logger.warn({ err, repoPath }, 'GNAP push to remote failed (continuing)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pullTasksFromGnap(repoPath: string): GnapTask[] {
|
||||
const tasksDir = path.join(repoPath, 'tasks')
|
||||
if (!fs.existsSync(tasksDir)) return []
|
||||
|
||||
// Pull remote changes first if available
|
||||
if (hasRemote(repoPath)) {
|
||||
try {
|
||||
git(repoPath, ['pull', '--rebase'])
|
||||
} catch (err) {
|
||||
logger.warn({ err, repoPath }, 'GNAP pull from remote failed (using local)')
|
||||
}
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'))
|
||||
const tasks: GnapTask[] = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(tasksDir, file), 'utf-8')
|
||||
tasks.push(JSON.parse(content))
|
||||
} catch (err) {
|
||||
logger.warn({ err, file }, 'Failed to parse GNAP task file')
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
pushed: number
|
||||
pulled: number
|
||||
errors: string[]
|
||||
lastSync: string
|
||||
}
|
||||
|
||||
export function syncGnap(repoPath: string): SyncResult {
|
||||
const result: SyncResult = {
|
||||
pushed: 0,
|
||||
pulled: 0,
|
||||
errors: [],
|
||||
lastSync: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Pull remote if available
|
||||
if (hasRemote(repoPath)) {
|
||||
try {
|
||||
git(repoPath, ['pull', '--rebase'])
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Pull failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Count local tasks
|
||||
const tasksDir = path.join(repoPath, 'tasks')
|
||||
if (fs.existsSync(tasksDir)) {
|
||||
result.pushed = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')).length
|
||||
}
|
||||
|
||||
// Push if remote available
|
||||
if (hasRemote(repoPath) && hasChanges(repoPath)) {
|
||||
try {
|
||||
git(repoPath, ['add', '.'])
|
||||
git(repoPath, ['commit', '-m', `Sync from Mission Control at ${result.lastSync}`])
|
||||
git(repoPath, ['push'])
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Push failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getGnapStatus(repoPath: string): {
|
||||
initialized: boolean
|
||||
taskCount: number
|
||||
hasRemote: boolean
|
||||
remoteUrl: string
|
||||
} {
|
||||
const tasksDir = path.join(repoPath, 'tasks')
|
||||
const initialized = fs.existsSync(path.join(repoPath, 'version'))
|
||||
const taskCount = initialized && fs.existsSync(tasksDir)
|
||||
? fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')).length
|
||||
: 0
|
||||
|
||||
let remote = false
|
||||
let remoteUrl = ''
|
||||
if (initialized) {
|
||||
try {
|
||||
remote = hasRemote(repoPath)
|
||||
if (remote) {
|
||||
remoteUrl = git(repoPath, ['remote', 'get-url', 'origin'])
|
||||
}
|
||||
} catch { /* no remote */ }
|
||||
}
|
||||
|
||||
return { initialized, taskCount, hasRemote: remote, remoteUrl }
|
||||
}
|
||||
Loading…
Reference in New Issue