File size: 8,488 Bytes
1f1a802
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
/**
 * 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 }
}