314 lines
8.3 KiB
TypeScript
314 lines
8.3 KiB
TypeScript
/**
|
|
* 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 }
|
|
}
|