trekoon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.agents/skills/trekoon/SKILL.md +91 -0
  2. package/AGENTS.md +54 -0
  3. package/CONTRIBUTING.md +18 -0
  4. package/README.md +151 -0
  5. package/bin/trekoon +5 -0
  6. package/bun.lock +28 -0
  7. package/package.json +24 -0
  8. package/src/commands/arg-parser.ts +93 -0
  9. package/src/commands/dep.ts +105 -0
  10. package/src/commands/epic.ts +539 -0
  11. package/src/commands/help.ts +61 -0
  12. package/src/commands/init.ts +24 -0
  13. package/src/commands/quickstart.ts +61 -0
  14. package/src/commands/subtask.ts +187 -0
  15. package/src/commands/sync.ts +128 -0
  16. package/src/commands/task.ts +554 -0
  17. package/src/commands/wipe.ts +39 -0
  18. package/src/domain/tracker-domain.ts +576 -0
  19. package/src/domain/types.ts +99 -0
  20. package/src/index.ts +21 -0
  21. package/src/io/human-table.ts +191 -0
  22. package/src/io/output.ts +70 -0
  23. package/src/runtime/cli-shell.ts +158 -0
  24. package/src/runtime/command-types.ts +33 -0
  25. package/src/storage/database.ts +35 -0
  26. package/src/storage/migrations.ts +46 -0
  27. package/src/storage/path.ts +22 -0
  28. package/src/storage/schema.ts +116 -0
  29. package/src/storage/types.ts +15 -0
  30. package/src/sync/branch-db.ts +49 -0
  31. package/src/sync/event-writes.ts +49 -0
  32. package/src/sync/git-context.ts +67 -0
  33. package/src/sync/service.ts +654 -0
  34. package/src/sync/types.ts +31 -0
  35. package/tests/commands/dep.test.ts +101 -0
  36. package/tests/commands/epic.test.ts +383 -0
  37. package/tests/commands/subtask.test.ts +132 -0
  38. package/tests/commands/sync/sync-command.test.ts +1 -0
  39. package/tests/commands/sync.test.ts +199 -0
  40. package/tests/commands/task.test.ts +474 -0
  41. package/tests/integration/sync-workflow.test.ts +279 -0
  42. package/tests/io/human-table.test.ts +81 -0
  43. package/tests/runtime/output-mode.test.ts +54 -0
  44. package/tests/storage/database.test.ts +91 -0
  45. package/tsconfig.json +19 -0
@@ -0,0 +1,654 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { type Database } from "bun:sqlite";
4
+
5
+ import { openTrekoonDatabase } from "../storage/database";
6
+ import { openBranchDatabaseSnapshot } from "./branch-db";
7
+ import { persistGitContext, resolveGitContext } from "./git-context";
8
+ import { type PullSummary, type ResolveSummary, type SyncResolution, type SyncStatusSummary } from "./types";
9
+
10
+ interface StoredEvent {
11
+ readonly id: string;
12
+ readonly entity_kind: string;
13
+ readonly entity_id: string;
14
+ readonly operation: string;
15
+ readonly payload: string;
16
+ readonly git_branch: string | null;
17
+ readonly git_head: string | null;
18
+ readonly created_at: number;
19
+ readonly updated_at: number;
20
+ readonly version: number;
21
+ }
22
+
23
+ interface CursorRow {
24
+ readonly source_branch: string;
25
+ readonly cursor_token: string;
26
+ readonly last_event_at: number | null;
27
+ }
28
+
29
+ interface ConflictRow {
30
+ readonly id: string;
31
+ readonly event_id: string;
32
+ readonly entity_kind: string;
33
+ readonly entity_id: string;
34
+ readonly field_name: string;
35
+ readonly ours_value: string | null;
36
+ readonly theirs_value: string | null;
37
+ readonly resolution: string;
38
+ }
39
+
40
+ interface EventPayload {
41
+ readonly fields?: Record<string, unknown>;
42
+ }
43
+
44
+ function parsePayload(rawPayload: string): EventPayload {
45
+ try {
46
+ const parsed: unknown = JSON.parse(rawPayload);
47
+
48
+ if (typeof parsed === "object" && parsed !== null) {
49
+ return parsed as EventPayload;
50
+ }
51
+ } catch {
52
+ // Fall back to empty payload.
53
+ }
54
+
55
+ return {};
56
+ }
57
+
58
+ function tableForEntityKind(entityKind: string): "epics" | "tasks" | "subtasks" | "dependencies" | null {
59
+ switch (entityKind) {
60
+ case "epic":
61
+ return "epics";
62
+ case "task":
63
+ return "tasks";
64
+ case "subtask":
65
+ return "subtasks";
66
+ case "dependency":
67
+ return "dependencies";
68
+ default:
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function parseCursorToken(token: string): { createdAt: number; id: string | null } {
74
+ const [createdAtRaw, idRaw] = token.split(":");
75
+ const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
76
+
77
+ return {
78
+ createdAt: Number.isFinite(createdAt) ? createdAt : 0,
79
+ id: idRaw && idRaw.length > 0 ? idRaw : null,
80
+ };
81
+ }
82
+
83
+ function cursorTokenFromEvent(event: StoredEvent): string {
84
+ return `${event.created_at}:${event.id}`;
85
+ }
86
+
87
+ function loadCursor(db: Database, sourceBranch: string): CursorRow | null {
88
+ return db
89
+ .query(
90
+ `
91
+ SELECT source_branch, cursor_token, last_event_at
92
+ FROM sync_cursors
93
+ WHERE source_branch = ?
94
+ LIMIT 1;
95
+ `,
96
+ )
97
+ .get(sourceBranch) as CursorRow | null;
98
+ }
99
+
100
+ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, lastEventAt: number | null): void {
101
+ const now: number = Date.now();
102
+
103
+ db.query(
104
+ `
105
+ INSERT INTO sync_cursors (
106
+ id,
107
+ source_branch,
108
+ cursor_token,
109
+ last_event_at,
110
+ created_at,
111
+ updated_at,
112
+ version
113
+ ) VALUES (
114
+ @sourceBranch,
115
+ @sourceBranch,
116
+ @cursorToken,
117
+ @lastEventAt,
118
+ @now,
119
+ @now,
120
+ 1
121
+ )
122
+ ON CONFLICT(id) DO UPDATE SET
123
+ cursor_token = excluded.cursor_token,
124
+ last_event_at = excluded.last_event_at,
125
+ updated_at = excluded.updated_at,
126
+ version = sync_cursors.version + 1;
127
+ `,
128
+ ).run({
129
+ "@sourceBranch": sourceBranch,
130
+ "@cursorToken": cursorToken,
131
+ "@lastEventAt": lastEventAt,
132
+ "@now": now,
133
+ });
134
+ }
135
+
136
+ function queryNewEvents(remoteDb: Database, cursorToken: string): StoredEvent[] {
137
+ const cursor = parseCursorToken(cursorToken);
138
+
139
+ return remoteDb
140
+ .query(
141
+ `
142
+ SELECT id, entity_kind, entity_id, operation, payload, git_branch, git_head, created_at, updated_at, version
143
+ FROM events
144
+ WHERE created_at > @createdAt
145
+ OR (created_at = @createdAt AND id > @id)
146
+ ORDER BY created_at ASC, id ASC;
147
+ `,
148
+ )
149
+ .all({
150
+ "@createdAt": cursor.createdAt,
151
+ "@id": cursor.id ?? "",
152
+ }) as StoredEvent[];
153
+ }
154
+
155
+ function countPendingConflicts(db: Database): number {
156
+ const row = db
157
+ .query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
158
+ .get() as { count: number } | null;
159
+
160
+ return row?.count ?? 0;
161
+ }
162
+
163
+ function countBehind(remoteDb: Database, cursorToken: string): number {
164
+ const cursor = parseCursorToken(cursorToken);
165
+ const row = remoteDb
166
+ .query(
167
+ `
168
+ SELECT COUNT(*) AS count
169
+ FROM events
170
+ WHERE created_at > @createdAt
171
+ OR (created_at = @createdAt AND id > @id);
172
+ `,
173
+ )
174
+ .get({
175
+ "@createdAt": cursor.createdAt,
176
+ "@id": cursor.id ?? "",
177
+ }) as { count: number } | null;
178
+
179
+ return row?.count ?? 0;
180
+ }
181
+
182
+ function listRemoteEventIds(remoteDb: Database): Set<string> {
183
+ const rows = remoteDb.query("SELECT id FROM events;").all() as Array<{ id: string }>;
184
+ return new Set(rows.map((row) => row.id));
185
+ }
186
+
187
+ function countAhead(localDb: Database, currentBranch: string | null, remoteEventIds: Set<string>): number {
188
+ const rows = localDb
189
+ .query("SELECT id, git_branch FROM events WHERE git_branch = ?;")
190
+ .all(currentBranch) as Array<{ id: string; git_branch: string | null }>;
191
+
192
+ return rows.filter((row) => !remoteEventIds.has(row.id)).length;
193
+ }
194
+
195
+ function readFieldValue(payload: EventPayload, field: string): unknown {
196
+ const fields = payload.fields;
197
+ if (!fields) {
198
+ return undefined;
199
+ }
200
+
201
+ return fields[field];
202
+ }
203
+
204
+ function serializeValue(value: unknown): string | null {
205
+ if (typeof value === "undefined") {
206
+ return null;
207
+ }
208
+
209
+ return JSON.stringify(value);
210
+ }
211
+
212
+ function entityFieldConflict(
213
+ localDb: Database,
214
+ sourceBranch: string,
215
+ event: StoredEvent,
216
+ fieldName: string,
217
+ incomingValue: unknown,
218
+ ): { oursValue: string | null; theirsValue: string | null } | null {
219
+ const rows = localDb
220
+ .query(
221
+ `
222
+ SELECT payload, git_branch
223
+ FROM events
224
+ WHERE entity_kind = ? AND entity_id = ?
225
+ ORDER BY created_at DESC, id DESC
226
+ LIMIT 50;
227
+ `,
228
+ )
229
+ .all(event.entity_kind, event.entity_id) as Array<{ payload: string; git_branch: string | null }>;
230
+
231
+ for (const row of rows) {
232
+ if (row.git_branch === sourceBranch) {
233
+ continue;
234
+ }
235
+
236
+ const payload = parsePayload(row.payload);
237
+ const localValue: unknown = readFieldValue(payload, fieldName);
238
+
239
+ if (typeof localValue === "undefined") {
240
+ continue;
241
+ }
242
+
243
+ const oursValue = serializeValue(localValue);
244
+ const theirsValue = serializeValue(incomingValue);
245
+
246
+ if (oursValue !== theirsValue) {
247
+ return {
248
+ oursValue,
249
+ theirsValue,
250
+ };
251
+ }
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ function createConflict(
258
+ db: Database,
259
+ event: StoredEvent,
260
+ fieldName: string,
261
+ oursValue: string | null,
262
+ theirsValue: string | null,
263
+ ): void {
264
+ const now: number = Date.now();
265
+ db.query(
266
+ `
267
+ INSERT INTO sync_conflicts (
268
+ id,
269
+ event_id,
270
+ entity_kind,
271
+ entity_id,
272
+ field_name,
273
+ ours_value,
274
+ theirs_value,
275
+ resolution,
276
+ created_at,
277
+ updated_at,
278
+ version
279
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, 1);
280
+ `,
281
+ ).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, now, now);
282
+ }
283
+
284
+ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
285
+ const tableName = tableForEntityKind(event.entity_kind);
286
+ if (!tableName) {
287
+ return false;
288
+ }
289
+
290
+ const now: number = Date.now();
291
+
292
+ if (tableName === "epics") {
293
+ db.query(
294
+ `
295
+ INSERT INTO epics (id, title, description, status, created_at, updated_at, version)
296
+ VALUES (@id, @title, @description, @status, @now, @now, 1)
297
+ ON CONFLICT(id) DO UPDATE SET
298
+ title = excluded.title,
299
+ description = excluded.description,
300
+ status = excluded.status,
301
+ updated_at = excluded.updated_at,
302
+ version = epics.version + 1;
303
+ `,
304
+ ).run({
305
+ "@id": event.entity_id,
306
+ "@title": typeof fields.title === "string" ? fields.title : "Untitled epic",
307
+ "@description": typeof fields.description === "string" ? fields.description : "",
308
+ "@status": typeof fields.status === "string" ? fields.status : "open",
309
+ "@now": now,
310
+ });
311
+
312
+ return true;
313
+ }
314
+
315
+ if (tableName === "tasks") {
316
+ db.query(
317
+ `
318
+ INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version)
319
+ VALUES (@id, @epicId, @title, @description, @status, @now, @now, 1)
320
+ ON CONFLICT(id) DO UPDATE SET
321
+ epic_id = excluded.epic_id,
322
+ title = excluded.title,
323
+ description = excluded.description,
324
+ status = excluded.status,
325
+ updated_at = excluded.updated_at,
326
+ version = tasks.version + 1;
327
+ `,
328
+ ).run({
329
+ "@id": event.entity_id,
330
+ "@epicId": typeof fields.epic_id === "string" ? fields.epic_id : "missing-epic",
331
+ "@title": typeof fields.title === "string" ? fields.title : "Untitled task",
332
+ "@description": typeof fields.description === "string" ? fields.description : "",
333
+ "@status": typeof fields.status === "string" ? fields.status : "open",
334
+ "@now": now,
335
+ });
336
+
337
+ return true;
338
+ }
339
+
340
+ if (tableName === "subtasks") {
341
+ db.query(
342
+ `
343
+ INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version)
344
+ VALUES (@id, @taskId, @title, @description, @status, @now, @now, 1)
345
+ ON CONFLICT(id) DO UPDATE SET
346
+ task_id = excluded.task_id,
347
+ title = excluded.title,
348
+ description = excluded.description,
349
+ status = excluded.status,
350
+ updated_at = excluded.updated_at,
351
+ version = subtasks.version + 1;
352
+ `,
353
+ ).run({
354
+ "@id": event.entity_id,
355
+ "@taskId": typeof fields.task_id === "string" ? fields.task_id : "missing-task",
356
+ "@title": typeof fields.title === "string" ? fields.title : "Untitled subtask",
357
+ "@description": typeof fields.description === "string" ? fields.description : "",
358
+ "@status": typeof fields.status === "string" ? fields.status : "open",
359
+ "@now": now,
360
+ });
361
+
362
+ return true;
363
+ }
364
+
365
+ db.query(
366
+ `
367
+ INSERT INTO dependencies (
368
+ id,
369
+ source_id,
370
+ source_kind,
371
+ depends_on_id,
372
+ depends_on_kind,
373
+ created_at,
374
+ updated_at,
375
+ version
376
+ )
377
+ VALUES (@id, @sourceId, @sourceKind, @dependsOnId, @dependsOnKind, @now, @now, 1)
378
+ ON CONFLICT(id) DO UPDATE SET
379
+ source_id = excluded.source_id,
380
+ source_kind = excluded.source_kind,
381
+ depends_on_id = excluded.depends_on_id,
382
+ depends_on_kind = excluded.depends_on_kind,
383
+ updated_at = excluded.updated_at,
384
+ version = dependencies.version + 1;
385
+ `,
386
+ ).run({
387
+ "@id": event.entity_id,
388
+ "@sourceId": typeof fields.source_id === "string" ? fields.source_id : "",
389
+ "@sourceKind": typeof fields.source_kind === "string" ? fields.source_kind : "task",
390
+ "@dependsOnId": typeof fields.depends_on_id === "string" ? fields.depends_on_id : "",
391
+ "@dependsOnKind": typeof fields.depends_on_kind === "string" ? fields.depends_on_kind : "task",
392
+ "@now": now,
393
+ });
394
+
395
+ return true;
396
+ }
397
+
398
+ function storeEvent(db: Database, event: StoredEvent): void {
399
+ db.query(
400
+ `
401
+ INSERT OR IGNORE INTO events (
402
+ id,
403
+ entity_kind,
404
+ entity_id,
405
+ operation,
406
+ payload,
407
+ git_branch,
408
+ git_head,
409
+ created_at,
410
+ updated_at,
411
+ version
412
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
413
+ `,
414
+ ).run(
415
+ event.id,
416
+ event.entity_kind,
417
+ event.entity_id,
418
+ event.operation,
419
+ event.payload,
420
+ event.git_branch,
421
+ event.git_head,
422
+ event.created_at,
423
+ event.updated_at,
424
+ event.version,
425
+ );
426
+ }
427
+
428
+ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary {
429
+ const storage = openTrekoonDatabase(cwd);
430
+ const git = resolveGitContext(cwd);
431
+
432
+ try {
433
+ persistGitContext(storage.db, git);
434
+
435
+ const cursor = loadCursor(storage.db, sourceBranch);
436
+ const cursorToken: string = cursor?.cursor_token ?? "0:";
437
+ const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
438
+
439
+ try {
440
+ return {
441
+ sourceBranch,
442
+ ahead: countAhead(storage.db, git.branchName, listRemoteEventIds(remote.db)),
443
+ behind: countBehind(remote.db, cursorToken),
444
+ pendingConflicts: countPendingConflicts(storage.db),
445
+ git,
446
+ };
447
+ } finally {
448
+ remote.close();
449
+ }
450
+ } finally {
451
+ storage.close();
452
+ }
453
+ }
454
+
455
+ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
456
+ const storage = openTrekoonDatabase(cwd);
457
+ const git = resolveGitContext(cwd);
458
+ persistGitContext(storage.db, git);
459
+
460
+ const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
461
+
462
+ try {
463
+ const cursor = loadCursor(storage.db, sourceBranch);
464
+ const cursorToken = cursor?.cursor_token ?? "0:";
465
+ const incomingEvents: StoredEvent[] = queryNewEvents(remote.db, cursorToken);
466
+
467
+ let appliedEvents = 0;
468
+ let createdConflicts = 0;
469
+ let lastToken: string | null = null;
470
+ let lastEventAt: number | null = cursor?.last_event_at ?? null;
471
+
472
+ storage.db.transaction((): void => {
473
+ for (const incoming of incomingEvents) {
474
+ const payload = parsePayload(incoming.payload);
475
+ const incomingFields: Record<string, unknown> = payload.fields ?? {};
476
+ const fieldsToApply: Record<string, unknown> = {};
477
+ let hasAppliedField = false;
478
+
479
+ for (const [fieldName, value] of Object.entries(incomingFields)) {
480
+ const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
481
+
482
+ if (conflict) {
483
+ createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
484
+ createdConflicts += 1;
485
+ continue;
486
+ }
487
+
488
+ fieldsToApply[fieldName] = value;
489
+ hasAppliedField = true;
490
+ }
491
+
492
+ if (hasAppliedField && applyEntityFields(storage.db, incoming, fieldsToApply)) {
493
+ appliedEvents += 1;
494
+ }
495
+
496
+ storeEvent(storage.db, incoming);
497
+ lastToken = cursorTokenFromEvent(incoming);
498
+ lastEventAt = incoming.created_at;
499
+ }
500
+
501
+ if (lastToken) {
502
+ saveCursor(storage.db, sourceBranch, lastToken, lastEventAt);
503
+ }
504
+ })();
505
+
506
+ return {
507
+ sourceBranch,
508
+ scannedEvents: incomingEvents.length,
509
+ appliedEvents,
510
+ createdConflicts,
511
+ cursorToken: lastToken,
512
+ };
513
+ } finally {
514
+ remote.close();
515
+ storage.close();
516
+ }
517
+ }
518
+
519
+ function parseConflictValue(value: string | null): unknown {
520
+ if (!value) {
521
+ return null;
522
+ }
523
+
524
+ try {
525
+ return JSON.parse(value);
526
+ } catch {
527
+ return value;
528
+ }
529
+ }
530
+
531
+ function updateSingleField(db: Database, entityKind: string, entityId: string, fieldName: string, value: unknown): void {
532
+ const tableName = tableForEntityKind(entityKind);
533
+ if (!tableName) {
534
+ return;
535
+ }
536
+
537
+ const allowedFields: Record<string, readonly string[]> = {
538
+ epics: ["title", "description", "status"],
539
+ tasks: ["epic_id", "title", "description", "status"],
540
+ subtasks: ["task_id", "title", "description", "status"],
541
+ dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
542
+ };
543
+
544
+ const validFields: readonly string[] = allowedFields[tableName] ?? [];
545
+ if (!validFields.includes(fieldName)) {
546
+ return;
547
+ }
548
+
549
+ const now: number = Date.now();
550
+ db.query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
551
+ typeof value === "string" ? value : JSON.stringify(value),
552
+ now,
553
+ entityId,
554
+ );
555
+ }
556
+
557
+ function appendResolutionEvent(
558
+ db: Database,
559
+ gitBranch: string | null,
560
+ gitHead: string | null,
561
+ conflict: ConflictRow,
562
+ resolution: SyncResolution,
563
+ ): void {
564
+ const now: number = Date.now();
565
+ const resolvedValue: string | null = resolution === "theirs" ? conflict.theirs_value : conflict.ours_value;
566
+
567
+ db.query(
568
+ `
569
+ INSERT INTO events (
570
+ id,
571
+ entity_kind,
572
+ entity_id,
573
+ operation,
574
+ payload,
575
+ git_branch,
576
+ git_head,
577
+ created_at,
578
+ updated_at,
579
+ version
580
+ ) VALUES (?, ?, ?, 'resolve_conflict', ?, ?, ?, ?, ?, 1);
581
+ `,
582
+ ).run(
583
+ randomUUID(),
584
+ conflict.entity_kind,
585
+ conflict.entity_id,
586
+ JSON.stringify({
587
+ conflict_id: conflict.id,
588
+ field: conflict.field_name,
589
+ resolution,
590
+ value: resolvedValue,
591
+ }),
592
+ gitBranch,
593
+ gitHead,
594
+ now,
595
+ now,
596
+ );
597
+ }
598
+
599
+ export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
600
+ const storage = openTrekoonDatabase(cwd);
601
+ const git = resolveGitContext(cwd);
602
+
603
+ try {
604
+ persistGitContext(storage.db, git);
605
+
606
+ const conflict = storage.db
607
+ .query(
608
+ `
609
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution
610
+ FROM sync_conflicts
611
+ WHERE id = ?
612
+ LIMIT 1;
613
+ `,
614
+ )
615
+ .get(conflictId) as ConflictRow | null;
616
+
617
+ if (!conflict) {
618
+ throw new Error(`Conflict '${conflictId}' not found.`);
619
+ }
620
+
621
+ if (conflict.resolution !== "pending") {
622
+ throw new Error(`Conflict '${conflictId}' already resolved.`);
623
+ }
624
+
625
+ storage.db.transaction((): void => {
626
+ if (resolution === "theirs") {
627
+ updateSingleField(
628
+ storage.db,
629
+ conflict.entity_kind,
630
+ conflict.entity_id,
631
+ conflict.field_name,
632
+ parseConflictValue(conflict.theirs_value),
633
+ );
634
+ }
635
+
636
+ const now: number = Date.now();
637
+ storage.db
638
+ .query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
639
+ .run(resolution, now, conflict.id);
640
+
641
+ appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
642
+ })();
643
+
644
+ return {
645
+ conflictId,
646
+ resolution,
647
+ entityKind: conflict.entity_kind,
648
+ entityId: conflict.entity_id,
649
+ fieldName: conflict.field_name,
650
+ };
651
+ } finally {
652
+ storage.close();
653
+ }
654
+ }
@@ -0,0 +1,31 @@
1
+ export type SyncResolution = "ours" | "theirs";
2
+
3
+ export interface GitContextSnapshot {
4
+ readonly worktreePath: string;
5
+ readonly branchName: string | null;
6
+ readonly headSha: string | null;
7
+ }
8
+
9
+ export interface SyncStatusSummary {
10
+ readonly sourceBranch: string;
11
+ readonly ahead: number;
12
+ readonly behind: number;
13
+ readonly pendingConflicts: number;
14
+ readonly git: GitContextSnapshot;
15
+ }
16
+
17
+ export interface PullSummary {
18
+ readonly sourceBranch: string;
19
+ readonly scannedEvents: number;
20
+ readonly appliedEvents: number;
21
+ readonly createdConflicts: number;
22
+ readonly cursorToken: string | null;
23
+ }
24
+
25
+ export interface ResolveSummary {
26
+ readonly conflictId: string;
27
+ readonly resolution: SyncResolution;
28
+ readonly entityKind: string;
29
+ readonly entityId: string;
30
+ readonly fieldName: string;
31
+ }