trekoon 0.4.0 → 0.4.2
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.
- package/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
package/src/sync/service.ts
CHANGED
|
@@ -44,7 +44,7 @@ function isSyncFieldValueSupported(tableName: string, fieldName: string, value:
|
|
|
44
44
|
return value === null && isSyncNullableStringField(tableName, fieldName);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
|
|
47
|
+
export function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
|
|
48
48
|
if (cursorToken === "0:") {
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
@@ -57,10 +57,8 @@ function isCursorStale(db: Database, cursorToken: string, sourceBranch: string):
|
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Check if the event referenced by the cursor still exists
|
|
61
|
-
// If the cursor
|
|
62
|
-
// Otherwise, check if any event at or after the cursor timestamp exists
|
|
63
|
-
// on the source branch.
|
|
60
|
+
// Check if the event referenced by the cursor still exists in the live
|
|
61
|
+
// events table. If found, the cursor position is valid.
|
|
64
62
|
if (id.length > 0) {
|
|
65
63
|
const row = db
|
|
66
64
|
.query("SELECT id FROM events WHERE id = ? LIMIT 1;")
|
|
@@ -70,20 +68,56 @@ function isCursorStale(db: Database, cursorToken: string, sourceBranch: string):
|
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
71
|
+
// Compute the earliest retained created_at on the cursor's source branch
|
|
72
|
+
// across both the live events table and event_archive (pruned events). If
|
|
73
|
+
// the cursor predates that minimum, history before the cursor on this
|
|
74
|
+
// branch has been lost and re-bootstrap is required.
|
|
75
|
+
//
|
|
76
|
+
// Scoping by git_branch is essential: a global MIN would falsely report
|
|
77
|
+
// staleness on branch B simply because branch A has older retained events
|
|
78
|
+
// than the cursor on B.
|
|
79
|
+
const minRow = db
|
|
80
|
+
.query(
|
|
81
|
+
`SELECT MIN(min_ts) AS min_ts FROM (
|
|
82
|
+
SELECT MIN(created_at) AS min_ts FROM events WHERE git_branch = ?
|
|
83
|
+
UNION ALL
|
|
84
|
+
SELECT MIN(created_at) AS min_ts FROM event_archive WHERE git_branch = ?
|
|
85
|
+
);`,
|
|
86
|
+
)
|
|
87
|
+
.get(sourceBranch, sourceBranch) as { min_ts: number | null } | null;
|
|
88
|
+
|
|
89
|
+
const minTs: number | null = minRow?.min_ts ?? null;
|
|
90
|
+
|
|
91
|
+
// If there are no events at all, the cursor may simply be ahead of an
|
|
92
|
+
// empty log — not stale.
|
|
93
|
+
if (minTs === null) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Cursor predates the earliest retained event: the history window has
|
|
98
|
+
// been pruned past the cursor's position.
|
|
99
|
+
if (createdAt < minTs) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The referenced event is gone but the cursor timestamp is within the
|
|
104
|
+
// retained window. Check if there are any events on the source branch at
|
|
105
|
+
// or after the cursor timestamp across both tables — if there are, events
|
|
106
|
+
// between the cursor and the oldest remaining event were pruned.
|
|
76
107
|
const newerRow = db
|
|
77
108
|
.query(
|
|
78
|
-
`SELECT id FROM
|
|
79
|
-
|
|
109
|
+
`SELECT id FROM (
|
|
110
|
+
SELECT id, created_at FROM events
|
|
111
|
+
WHERE git_branch = ? AND created_at >= ?
|
|
112
|
+
UNION ALL
|
|
113
|
+
SELECT id, created_at FROM event_archive
|
|
114
|
+
WHERE git_branch = ? AND created_at >= ?
|
|
115
|
+
)
|
|
80
116
|
ORDER BY created_at ASC, id ASC
|
|
81
117
|
LIMIT 1;`,
|
|
82
118
|
)
|
|
83
|
-
.get(sourceBranch, createdAt) as { id: string } | null;
|
|
119
|
+
.get(sourceBranch, createdAt, sourceBranch, createdAt) as { id: string } | null;
|
|
84
120
|
|
|
85
|
-
// If there are newer events but our referenced event is gone,
|
|
86
|
-
// events between the cursor and the oldest remaining event were pruned.
|
|
87
121
|
return newerRow !== null;
|
|
88
122
|
}
|
|
89
123
|
|
|
@@ -119,6 +153,24 @@ interface ConflictRow {
|
|
|
119
153
|
readonly resolution: string;
|
|
120
154
|
readonly created_at: number;
|
|
121
155
|
readonly updated_at: number;
|
|
156
|
+
readonly worktree_path: string;
|
|
157
|
+
readonly current_branch: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Worktree+branch scope under which a conflict is recorded. Required so that
|
|
162
|
+
* cleanup, listing, and resolution paths can isolate conflicts that two
|
|
163
|
+
* sibling worktrees independently observed on the same entity. Without this
|
|
164
|
+
* scoping a `removeConflictsForEntityIds` from worktree A's pull would erase
|
|
165
|
+
* worktree B's pending conflicts on the same entity.
|
|
166
|
+
*/
|
|
167
|
+
interface ConflictScope {
|
|
168
|
+
readonly worktreePath: string;
|
|
169
|
+
readonly currentBranch: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scopeFromGitContext(git: { worktreePath: string; branchName: string | null }): ConflictScope {
|
|
173
|
+
return { worktreePath: git.worktreePath, currentBranch: git.branchName ?? "" };
|
|
122
174
|
}
|
|
123
175
|
|
|
124
176
|
interface ResolutionEventPayload {
|
|
@@ -127,6 +179,17 @@ interface ResolutionEventPayload {
|
|
|
127
179
|
readonly field: string;
|
|
128
180
|
readonly resolution: string;
|
|
129
181
|
readonly value?: string | null;
|
|
182
|
+
/**
|
|
183
|
+
* Worktree+branch scope of the conflict row that was resolved on the
|
|
184
|
+
* emitter side. Receivers use these (combined with their OWN active
|
|
185
|
+
* scope) to ensure a single shared `source_event_id` does not cause one
|
|
186
|
+
* worktree's resolution to clobber a sibling worktree's pending row.
|
|
187
|
+
*
|
|
188
|
+
* Optional for back-compat with events emitted before scope-aware
|
|
189
|
+
* resolution was introduced.
|
|
190
|
+
*/
|
|
191
|
+
readonly worktree_path?: string;
|
|
192
|
+
readonly current_branch?: string;
|
|
130
193
|
}
|
|
131
194
|
|
|
132
195
|
interface ResolutionWriteContext {
|
|
@@ -319,10 +382,18 @@ function saveCursor(
|
|
|
319
382
|
});
|
|
320
383
|
}
|
|
321
384
|
|
|
322
|
-
function countPendingConflicts(db: Database): number {
|
|
385
|
+
function countPendingConflicts(db: Database, scope: ConflictScope): number {
|
|
323
386
|
const row = db
|
|
324
|
-
.query(
|
|
325
|
-
|
|
387
|
+
.query(
|
|
388
|
+
`
|
|
389
|
+
SELECT COUNT(*) AS count
|
|
390
|
+
FROM sync_conflicts
|
|
391
|
+
WHERE resolution = 'pending'
|
|
392
|
+
AND worktree_path = ?
|
|
393
|
+
AND current_branch = ?;
|
|
394
|
+
`,
|
|
395
|
+
)
|
|
396
|
+
.get(scope.worktreePath, scope.currentBranch) as { count: number } | null;
|
|
326
397
|
|
|
327
398
|
return row?.count ?? 0;
|
|
328
399
|
}
|
|
@@ -428,18 +499,197 @@ function dependencyEventIdentity(event: StoredEvent): DependencyEventIdentity |
|
|
|
428
499
|
return dependencyEventIdentityFromFields(payloadValidation.fields);
|
|
429
500
|
}
|
|
430
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Memoized lookup table for "ours" field values keyed by
|
|
504
|
+
* `${entity_kind}|${entity_id}|${field_name}` on the current branch.
|
|
505
|
+
*
|
|
506
|
+
* Entries:
|
|
507
|
+
* - undefined: not yet probed
|
|
508
|
+
* - {found:false}: probed, no event on currentBranch touches this field
|
|
509
|
+
* - {found:true,value}: probed, most recent serialized local value found
|
|
510
|
+
*/
|
|
511
|
+
type OursLookupResult =
|
|
512
|
+
| { readonly found: false }
|
|
513
|
+
| { readonly found: true; readonly value: string | null };
|
|
514
|
+
|
|
515
|
+
type OursValueCache = Map<string, OursLookupResult>;
|
|
516
|
+
|
|
517
|
+
function createOursValueCache(): OursValueCache {
|
|
518
|
+
return new Map<string, OursLookupResult>();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function oursCacheKey(entityKind: string, entityId: string, fieldName: string): string {
|
|
522
|
+
return `${entityKind}|${entityId}|${fieldName}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Safe field-name guard for use in JSON1 path strings. We only inline
|
|
526
|
+
// fieldName into a `$.fields.<name>` path; any non-identifier character
|
|
527
|
+
// would either break the path syntax or invite injection-like surprises.
|
|
528
|
+
const SAFE_FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
529
|
+
|
|
530
|
+
function isSafeJsonPathField(fieldName: string): boolean {
|
|
531
|
+
return SAFE_FIELD_NAME_PATTERN.test(fieldName);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Fast O(1)-per-call probe (after first lookup is memoized) for the most
|
|
536
|
+
* recent local-branch event that touched a given (entity, field).
|
|
537
|
+
*
|
|
538
|
+
* Uses the `idx_events_entity_branch_cursor` index plus SQLite JSON1
|
|
539
|
+
* (`json_type`) to find the newest event whose payload has the field key,
|
|
540
|
+
* with a single LIMIT 1 query — replacing the previous unbounded batched
|
|
541
|
+
* history walk.
|
|
542
|
+
*
|
|
543
|
+
* Returns undefined when no event on `currentBranch` for `(entityKind,
|
|
544
|
+
* entityId)` has the field in its payload; otherwise the serialized
|
|
545
|
+
* local value (matching `serializeValue(payload.fields[field])`).
|
|
546
|
+
*/
|
|
547
|
+
function lookupOursFieldValue(
|
|
548
|
+
localDb: Database,
|
|
549
|
+
cache: OursValueCache,
|
|
550
|
+
currentBranch: string,
|
|
551
|
+
entityKind: string,
|
|
552
|
+
entityId: string,
|
|
553
|
+
fieldName: string,
|
|
554
|
+
): string | null | undefined {
|
|
555
|
+
const key = oursCacheKey(entityKind, entityId, fieldName);
|
|
556
|
+
const cached = cache.get(key);
|
|
557
|
+
if (cached !== undefined) {
|
|
558
|
+
return cached.found ? cached.value : undefined;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// json_type returns SQL NULL only when the JSON path does not exist.
|
|
562
|
+
// It returns the string 'null' when the field is explicitly null —
|
|
563
|
+
// we treat that as a real value (matching the legacy walk's behavior
|
|
564
|
+
// where only `typeof undefined` skipped a key).
|
|
565
|
+
//
|
|
566
|
+
// Defense-in-depth: pass fieldName as a SQLite bind value rather than
|
|
567
|
+
// interpolating it into the JS path string. The caller already gates
|
|
568
|
+
// fieldName via SAFE_FIELD_NAME_PATTERN, but binding ensures the value
|
|
569
|
+
// is never spliced into the path expression on the JS side. SQLite
|
|
570
|
+
// concatenates `'$.fields.' || ?` server-side; if the bound value were
|
|
571
|
+
// somehow malformed, the JSON-path parser rejects the whole expression
|
|
572
|
+
// (`bad JSON path`) instead of silently widening the query.
|
|
573
|
+
const row = localDb
|
|
574
|
+
.query(
|
|
575
|
+
`
|
|
576
|
+
SELECT json_extract(payload, '$.fields.' || ?) AS value,
|
|
577
|
+
json_type(payload, '$.fields.' || ?) AS jt
|
|
578
|
+
FROM events
|
|
579
|
+
WHERE entity_kind = ?
|
|
580
|
+
AND entity_id = ?
|
|
581
|
+
AND git_branch = ?
|
|
582
|
+
AND json_type(payload, '$.fields.' || ?) IS NOT NULL
|
|
583
|
+
ORDER BY created_at DESC, id DESC
|
|
584
|
+
LIMIT 1;
|
|
585
|
+
`,
|
|
586
|
+
)
|
|
587
|
+
.get(fieldName, fieldName, entityKind, entityId, currentBranch, fieldName) as
|
|
588
|
+
| { value: unknown; jt: string | null }
|
|
589
|
+
| null;
|
|
590
|
+
|
|
591
|
+
if (row === null || row.jt === null) {
|
|
592
|
+
cache.set(key, { found: false });
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Reconstruct serialized local value matching
|
|
597
|
+
// `serializeValue(JSON.parse(payload).fields[field])`.
|
|
598
|
+
// For JSON nulls, json_extract returns SQL NULL; we represent ours as "null".
|
|
599
|
+
// For other types we re-serialize using JSON.stringify on the JSON-extracted value.
|
|
600
|
+
const ours: string | null = row.jt === "null" ? "null" : JSON.stringify(row.value);
|
|
601
|
+
|
|
602
|
+
cache.set(key, { found: true, value: ours });
|
|
603
|
+
return ours;
|
|
604
|
+
}
|
|
605
|
+
|
|
431
606
|
function entityFieldConflict(
|
|
432
607
|
localDb: Database,
|
|
608
|
+
currentBranch: string | null,
|
|
433
609
|
sourceBranch: string,
|
|
434
610
|
event: StoredEvent,
|
|
435
611
|
fieldName: string,
|
|
436
612
|
incomingValue: unknown,
|
|
613
|
+
oursCache: OursValueCache,
|
|
437
614
|
): { oursValue: string | null; theirsValue: string | null } | null {
|
|
615
|
+
// Detached HEAD has no named branch — no local-branch events can conflict.
|
|
616
|
+
if (currentBranch === null) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Current-row short-circuit: live entity already matches the incoming
|
|
621
|
+
// value, so applying the incoming event is a no-op — no conflict possible.
|
|
622
|
+
//
|
|
623
|
+
// Only valid when the local row exists. If the row was deleted locally
|
|
624
|
+
// currentEntityFieldValue returns `undefined`, which serializeValue maps
|
|
625
|
+
// to `null` — that would falsely match an incoming `null` field and mask
|
|
626
|
+
// a real conflict (delete vs. concurrent update). When the row is gone
|
|
627
|
+
// we must fall through to the history walk so the local delete event is
|
|
628
|
+
// discovered and reported as a conflict against the incoming non-delete.
|
|
438
629
|
const currentValue = currentEntityFieldValue(localDb, event.entity_kind, event.entity_id, fieldName);
|
|
439
|
-
|
|
630
|
+
const theirsValue = serializeValue(incomingValue);
|
|
631
|
+
if (typeof currentValue !== "undefined" && serializeValue(currentValue) === theirsValue) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Dependency events use identity-tuple matching (entity_id can be reused
|
|
636
|
+
// across distinct dependencies). Fall back to the legacy filtered scan;
|
|
637
|
+
// dependency events are bounded by per-entity history depth and not the
|
|
638
|
+
// hot path that the optimization targets. Same fallback applies for
|
|
639
|
+
// payload field names that can't safely be inlined into a JSON1 path
|
|
640
|
+
// (defense in depth — the canonical SYNC_ALLOWED_FIELDS are all simple
|
|
641
|
+
// identifiers, but incoming payloads are not strictly schema-checked).
|
|
642
|
+
const incomingDependencyIdentity = dependencyEventIdentity(event);
|
|
643
|
+
if (incomingDependencyIdentity !== null || !isSafeJsonPathField(fieldName)) {
|
|
644
|
+
return entityFieldConflictHistoryWalk(
|
|
645
|
+
localDb,
|
|
646
|
+
currentBranch,
|
|
647
|
+
event,
|
|
648
|
+
fieldName,
|
|
649
|
+
theirsValue,
|
|
650
|
+
incomingDependencyIdentity,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Fast path: indexed + memoized probe for "most recent local event
|
|
655
|
+
// touching this field on this entity".
|
|
656
|
+
const oursValue = lookupOursFieldValue(
|
|
657
|
+
localDb,
|
|
658
|
+
oursCache,
|
|
659
|
+
currentBranch,
|
|
660
|
+
event.entity_kind,
|
|
661
|
+
event.entity_id,
|
|
662
|
+
fieldName,
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
if (oursValue === undefined) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (oursValue === theirsValue) {
|
|
440
670
|
return null;
|
|
441
671
|
}
|
|
442
672
|
|
|
673
|
+
return { oursValue, theirsValue };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Slow-path conflict detection. Preserves the legacy batched history walk
|
|
678
|
+
* for two cases:
|
|
679
|
+
* 1. Dependency events — identity (source/depends_on tuple) must match
|
|
680
|
+
* the incoming event because distinct dependencies can share an
|
|
681
|
+
* entity_id. A static-field probe is not sufficient.
|
|
682
|
+
* 2. Field names that cannot safely be inlined into a JSON1 path — falls
|
|
683
|
+
* back to JS-side payload parsing instead of a json_type SQL probe.
|
|
684
|
+
*/
|
|
685
|
+
function entityFieldConflictHistoryWalk(
|
|
686
|
+
localDb: Database,
|
|
687
|
+
currentBranch: string,
|
|
688
|
+
event: StoredEvent,
|
|
689
|
+
fieldName: string,
|
|
690
|
+
theirsValue: string | null,
|
|
691
|
+
incomingDependencyIdentity: DependencyEventIdentity | null,
|
|
692
|
+
): { oursValue: string | null; theirsValue: string | null } | null {
|
|
443
693
|
let beforeCreatedAt = Number.MAX_SAFE_INTEGER;
|
|
444
694
|
let beforeId = "\uffff";
|
|
445
695
|
|
|
@@ -451,7 +701,7 @@ function entityFieldConflict(
|
|
|
451
701
|
FROM events
|
|
452
702
|
WHERE entity_kind = ?
|
|
453
703
|
AND entity_id = ?
|
|
454
|
-
AND
|
|
704
|
+
AND git_branch = ?
|
|
455
705
|
AND (
|
|
456
706
|
created_at < ?
|
|
457
707
|
OR (created_at = ? AND id < ?)
|
|
@@ -463,15 +713,13 @@ function entityFieldConflict(
|
|
|
463
713
|
.all(
|
|
464
714
|
event.entity_kind,
|
|
465
715
|
event.entity_id,
|
|
466
|
-
|
|
716
|
+
currentBranch,
|
|
467
717
|
beforeCreatedAt,
|
|
468
718
|
beforeCreatedAt,
|
|
469
719
|
beforeId,
|
|
470
720
|
CONFLICT_HISTORY_SCAN_BATCH_SIZE,
|
|
471
721
|
) as LocalEntityEventRow[];
|
|
472
722
|
|
|
473
|
-
const incomingDependencyIdentity = dependencyEventIdentity(event);
|
|
474
|
-
|
|
475
723
|
if (rows.length === 0) {
|
|
476
724
|
return null;
|
|
477
725
|
}
|
|
@@ -503,7 +751,6 @@ function entityFieldConflict(
|
|
|
503
751
|
}
|
|
504
752
|
|
|
505
753
|
const oursValue = serializeValue(localValue);
|
|
506
|
-
const theirsValue = serializeValue(incomingValue);
|
|
507
754
|
|
|
508
755
|
if (oursValue !== theirsValue) {
|
|
509
756
|
return {
|
|
@@ -525,6 +772,7 @@ function createConflict(
|
|
|
525
772
|
fieldName: string,
|
|
526
773
|
oursValue: string | null,
|
|
527
774
|
theirsValue: string | null,
|
|
775
|
+
scope: ConflictScope,
|
|
528
776
|
resolution: string = "pending",
|
|
529
777
|
): void {
|
|
530
778
|
const now: number = Date.now();
|
|
@@ -534,11 +782,19 @@ function createConflict(
|
|
|
534
782
|
SELECT id, resolution, ours_value, theirs_value
|
|
535
783
|
FROM sync_conflicts
|
|
536
784
|
WHERE event_id = ? AND entity_kind = ? AND entity_id = ? AND field_name = ?
|
|
785
|
+
AND worktree_path = ? AND current_branch = ?
|
|
537
786
|
ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
|
|
538
787
|
LIMIT 1;
|
|
539
788
|
`,
|
|
540
789
|
)
|
|
541
|
-
.get(
|
|
790
|
+
.get(
|
|
791
|
+
event.id,
|
|
792
|
+
event.entity_kind,
|
|
793
|
+
event.entity_id,
|
|
794
|
+
fieldName,
|
|
795
|
+
scope.worktreePath,
|
|
796
|
+
scope.currentBranch,
|
|
797
|
+
) as
|
|
542
798
|
| { id: string; resolution: string; ours_value: string | null; theirs_value: string | null }
|
|
543
799
|
| null;
|
|
544
800
|
|
|
@@ -580,32 +836,72 @@ function createConflict(
|
|
|
580
836
|
resolution,
|
|
581
837
|
created_at,
|
|
582
838
|
updated_at,
|
|
583
|
-
version
|
|
584
|
-
|
|
839
|
+
version,
|
|
840
|
+
worktree_path,
|
|
841
|
+
current_branch
|
|
842
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);
|
|
585
843
|
`,
|
|
586
|
-
).run(
|
|
844
|
+
).run(
|
|
845
|
+
randomUUID(),
|
|
846
|
+
event.id,
|
|
847
|
+
event.entity_kind,
|
|
848
|
+
event.entity_id,
|
|
849
|
+
fieldName,
|
|
850
|
+
oursValue,
|
|
851
|
+
theirsValue,
|
|
852
|
+
resolution,
|
|
853
|
+
now,
|
|
854
|
+
now,
|
|
855
|
+
scope.worktreePath,
|
|
856
|
+
scope.currentBranch,
|
|
857
|
+
);
|
|
587
858
|
}
|
|
588
859
|
|
|
589
860
|
function findConflictForResolutionEvent(
|
|
590
861
|
db: Database,
|
|
591
862
|
event: StoredEvent,
|
|
592
863
|
payload: ResolutionEventPayload,
|
|
864
|
+
receiverScope: ConflictScope,
|
|
593
865
|
): ConflictRow | null {
|
|
866
|
+
// Scope-isolated resolve: a single (event_id, entity, field) tuple can
|
|
867
|
+
// have multiple sync_conflicts rows — one per observer worktree+branch.
|
|
868
|
+
// The receiver only resolves its OWN scoped row. If the payload carries
|
|
869
|
+
// an emitter scope (new, scope-aware events), require it to match the
|
|
870
|
+
// receiver — cross-scope events are a no-op so a resolve in worktree A
|
|
871
|
+
// never mutates worktree B's row.
|
|
872
|
+
if (
|
|
873
|
+
typeof payload.worktree_path === "string" &&
|
|
874
|
+
typeof payload.current_branch === "string" &&
|
|
875
|
+
(payload.worktree_path !== receiverScope.worktreePath ||
|
|
876
|
+
payload.current_branch !== receiverScope.currentBranch)
|
|
877
|
+
) {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
|
|
594
881
|
if (typeof payload.source_event_id === "string" && payload.source_event_id.length > 0) {
|
|
595
882
|
const bySourceEvent = db
|
|
596
883
|
.query(
|
|
597
884
|
`
|
|
598
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
885
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
599
886
|
FROM sync_conflicts
|
|
600
887
|
WHERE event_id = ?
|
|
601
888
|
AND entity_kind = ?
|
|
602
889
|
AND entity_id = ?
|
|
603
890
|
AND field_name = ?
|
|
891
|
+
AND worktree_path = ?
|
|
892
|
+
AND current_branch = ?
|
|
604
893
|
ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
|
|
605
894
|
LIMIT 1;
|
|
606
895
|
`,
|
|
607
896
|
)
|
|
608
|
-
.get(
|
|
897
|
+
.get(
|
|
898
|
+
payload.source_event_id,
|
|
899
|
+
event.entity_kind,
|
|
900
|
+
event.entity_id,
|
|
901
|
+
payload.field,
|
|
902
|
+
receiverScope.worktreePath,
|
|
903
|
+
receiverScope.currentBranch,
|
|
904
|
+
) as ConflictRow | null;
|
|
609
905
|
|
|
610
906
|
if (bySourceEvent) {
|
|
611
907
|
return bySourceEvent;
|
|
@@ -619,23 +915,63 @@ function findConflictForResolutionEvent(
|
|
|
619
915
|
return db
|
|
620
916
|
.query(
|
|
621
917
|
`
|
|
622
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
918
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
623
919
|
FROM sync_conflicts
|
|
624
920
|
WHERE id = ?
|
|
625
921
|
AND entity_kind = ?
|
|
626
922
|
AND entity_id = ?
|
|
627
923
|
AND field_name = ?
|
|
924
|
+
AND worktree_path = ?
|
|
925
|
+
AND current_branch = ?
|
|
628
926
|
LIMIT 1;
|
|
629
927
|
`,
|
|
630
928
|
)
|
|
631
|
-
.get(
|
|
929
|
+
.get(
|
|
930
|
+
payload.conflict_id,
|
|
931
|
+
event.entity_kind,
|
|
932
|
+
event.entity_id,
|
|
933
|
+
payload.field,
|
|
934
|
+
receiverScope.worktreePath,
|
|
935
|
+
receiverScope.currentBranch,
|
|
936
|
+
) as ConflictRow | null;
|
|
632
937
|
}
|
|
633
938
|
|
|
634
939
|
function removeDependenciesTouchingNode(db: Database, nodeId: string): void {
|
|
635
940
|
db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(nodeId, nodeId);
|
|
636
941
|
}
|
|
637
942
|
|
|
638
|
-
function
|
|
943
|
+
function removeConflictsForEntityIds(
|
|
944
|
+
db: Database,
|
|
945
|
+
entityKind: string,
|
|
946
|
+
entityIds: readonly string[],
|
|
947
|
+
scope: ConflictScope,
|
|
948
|
+
excludeConflictId?: string,
|
|
949
|
+
): void {
|
|
950
|
+
if (entityIds.length === 0) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const placeholders = entityIds.map(() => "?").join(", ");
|
|
954
|
+
if (excludeConflictId !== undefined) {
|
|
955
|
+
db.query(
|
|
956
|
+
`DELETE FROM sync_conflicts
|
|
957
|
+
WHERE entity_kind = ?
|
|
958
|
+
AND entity_id IN (${placeholders})
|
|
959
|
+
AND worktree_path = ?
|
|
960
|
+
AND current_branch = ?
|
|
961
|
+
AND id != ?;`,
|
|
962
|
+
).run(entityKind, ...entityIds, scope.worktreePath, scope.currentBranch, excludeConflictId);
|
|
963
|
+
} else {
|
|
964
|
+
db.query(
|
|
965
|
+
`DELETE FROM sync_conflicts
|
|
966
|
+
WHERE entity_kind = ?
|
|
967
|
+
AND entity_id IN (${placeholders})
|
|
968
|
+
AND worktree_path = ?
|
|
969
|
+
AND current_branch = ?;`,
|
|
970
|
+
).run(entityKind, ...entityIds, scope.worktreePath, scope.currentBranch);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function removeTaskSubtree(db: Database, taskId: string): Array<{ id: string }> {
|
|
639
975
|
const subtasks = db
|
|
640
976
|
.query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
|
|
641
977
|
.all(taskId) as Array<{ id: string }>;
|
|
@@ -646,6 +982,7 @@ function removeTaskSubtree(db: Database, taskId: string): void {
|
|
|
646
982
|
|
|
647
983
|
db.query("DELETE FROM subtasks WHERE task_id = ?;").run(taskId);
|
|
648
984
|
removeDependenciesTouchingNode(db, taskId);
|
|
985
|
+
return subtasks;
|
|
649
986
|
}
|
|
650
987
|
|
|
651
988
|
function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow): void {
|
|
@@ -670,12 +1007,25 @@ function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow
|
|
|
670
1007
|
}
|
|
671
1008
|
}
|
|
672
1009
|
|
|
1010
|
+
function scopeFromConflictRow(conflict: ConflictRow): ConflictScope {
|
|
1011
|
+
return { worktreePath: conflict.worktree_path, currentBranch: conflict.current_branch };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
673
1014
|
function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): void {
|
|
1015
|
+
// Cleanup is scoped to the conflict's own worktree+branch so resolving a
|
|
1016
|
+
// conflict in this worktree does not erase peer worktrees' pending
|
|
1017
|
+
// conflicts on the same entity.
|
|
1018
|
+
const scope: ConflictScope = scopeFromConflictRow(conflict);
|
|
1019
|
+
|
|
674
1020
|
if (conflict.field_name === "__delete__") {
|
|
675
1021
|
if (conflict.entity_kind === "task") {
|
|
676
|
-
removeTaskSubtree(db, conflict.entity_id);
|
|
1022
|
+
const subtasks = removeTaskSubtree(db, conflict.entity_id);
|
|
1023
|
+
const subtaskIds = subtasks.map((s) => s.id);
|
|
1024
|
+
removeConflictsForEntityIds(db, "subtask", subtaskIds, scope, conflict.id);
|
|
1025
|
+
removeConflictsForEntityIds(db, "task", [conflict.entity_id], scope, conflict.id);
|
|
677
1026
|
} else if (conflict.entity_kind === "subtask") {
|
|
678
1027
|
removeDependenciesTouchingNode(db, conflict.entity_id);
|
|
1028
|
+
removeConflictsForEntityIds(db, "subtask", [conflict.entity_id], scope, conflict.id);
|
|
679
1029
|
}
|
|
680
1030
|
applyPendingDeleteCascadeResolution(db, conflict);
|
|
681
1031
|
deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id, { allowMissing: true });
|
|
@@ -687,7 +1037,11 @@ function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): voi
|
|
|
687
1037
|
});
|
|
688
1038
|
}
|
|
689
1039
|
|
|
690
|
-
function applyIncomingResolutionEvent(
|
|
1040
|
+
function applyIncomingResolutionEvent(
|
|
1041
|
+
db: Database,
|
|
1042
|
+
event: StoredEvent,
|
|
1043
|
+
receiverScope: ConflictScope,
|
|
1044
|
+
): boolean {
|
|
691
1045
|
const parsed = parseJsonObject(event.payload);
|
|
692
1046
|
if (!parsed) {
|
|
693
1047
|
return false;
|
|
@@ -704,7 +1058,7 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
|
|
|
704
1058
|
return false;
|
|
705
1059
|
}
|
|
706
1060
|
|
|
707
|
-
const conflict = findConflictForResolutionEvent(db, event, resolutionPayload);
|
|
1061
|
+
const conflict = findConflictForResolutionEvent(db, event, resolutionPayload, receiverScope);
|
|
708
1062
|
if (!conflict) {
|
|
709
1063
|
return false;
|
|
710
1064
|
}
|
|
@@ -739,16 +1093,16 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
|
|
|
739
1093
|
return updated.changes > 0;
|
|
740
1094
|
}
|
|
741
1095
|
|
|
742
|
-
function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string,
|
|
1096
|
+
function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, currentBranch: string): boolean {
|
|
743
1097
|
const row = db
|
|
744
1098
|
.query(
|
|
745
|
-
`SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND
|
|
1099
|
+
`SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch = ? LIMIT 1;`,
|
|
746
1100
|
)
|
|
747
|
-
.get(entityKind, entityId,
|
|
1101
|
+
.get(entityKind, entityId, currentBranch);
|
|
748
1102
|
return row !== null;
|
|
749
1103
|
}
|
|
750
1104
|
|
|
751
|
-
function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[],
|
|
1105
|
+
function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], currentBranch: string): boolean {
|
|
752
1106
|
if (nodeIds.length === 0) {
|
|
753
1107
|
return false;
|
|
754
1108
|
}
|
|
@@ -762,7 +1116,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
762
1116
|
SELECT 1
|
|
763
1117
|
FROM events
|
|
764
1118
|
WHERE entity_kind = 'dependency'
|
|
765
|
-
AND
|
|
1119
|
+
AND git_branch = ?
|
|
766
1120
|
AND (
|
|
767
1121
|
json_extract(payload, '$.fields.source_id') IN (${placeholders})
|
|
768
1122
|
OR json_extract(payload, '$.fields.depends_on_id') IN (${placeholders})
|
|
@@ -770,7 +1124,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
770
1124
|
LIMIT 1;
|
|
771
1125
|
`,
|
|
772
1126
|
)
|
|
773
|
-
.get(
|
|
1127
|
+
.get(currentBranch, ...chunk, ...chunk);
|
|
774
1128
|
|
|
775
1129
|
if (row !== null) {
|
|
776
1130
|
return true;
|
|
@@ -782,7 +1136,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
782
1136
|
|
|
783
1137
|
function hasLocalDependencyEditsForIdentity(
|
|
784
1138
|
db: Database,
|
|
785
|
-
|
|
1139
|
+
currentBranch: string,
|
|
786
1140
|
identity: DependencyEventIdentity,
|
|
787
1141
|
): boolean {
|
|
788
1142
|
const row = db
|
|
@@ -791,7 +1145,7 @@ function hasLocalDependencyEditsForIdentity(
|
|
|
791
1145
|
SELECT 1
|
|
792
1146
|
FROM events
|
|
793
1147
|
WHERE entity_kind = 'dependency'
|
|
794
|
-
AND
|
|
1148
|
+
AND git_branch = ?
|
|
795
1149
|
AND json_extract(payload, '$.fields.source_id') = ?
|
|
796
1150
|
AND json_extract(payload, '$.fields.source_kind') = ?
|
|
797
1151
|
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
@@ -799,7 +1153,7 @@ function hasLocalDependencyEditsForIdentity(
|
|
|
799
1153
|
LIMIT 1;
|
|
800
1154
|
`,
|
|
801
1155
|
)
|
|
802
|
-
.get(
|
|
1156
|
+
.get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
|
|
803
1157
|
|
|
804
1158
|
return row !== null;
|
|
805
1159
|
}
|
|
@@ -824,7 +1178,7 @@ function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventI
|
|
|
824
1178
|
|
|
825
1179
|
function latestLocalDependencyOperationForIdentity(
|
|
826
1180
|
db: Database,
|
|
827
|
-
|
|
1181
|
+
currentBranch: string,
|
|
828
1182
|
identity: DependencyEventIdentity,
|
|
829
1183
|
): string | null {
|
|
830
1184
|
const row = db
|
|
@@ -833,7 +1187,7 @@ function latestLocalDependencyOperationForIdentity(
|
|
|
833
1187
|
SELECT operation
|
|
834
1188
|
FROM events
|
|
835
1189
|
WHERE entity_kind = 'dependency'
|
|
836
|
-
AND
|
|
1190
|
+
AND git_branch = ?
|
|
837
1191
|
AND json_extract(payload, '$.fields.source_id') = ?
|
|
838
1192
|
AND json_extract(payload, '$.fields.source_kind') = ?
|
|
839
1193
|
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
@@ -842,7 +1196,7 @@ function latestLocalDependencyOperationForIdentity(
|
|
|
842
1196
|
LIMIT 1;
|
|
843
1197
|
`,
|
|
844
1198
|
)
|
|
845
|
-
.get(
|
|
1199
|
+
.get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
|
|
846
1200
|
| { operation: string }
|
|
847
1201
|
| null;
|
|
848
1202
|
|
|
@@ -851,7 +1205,7 @@ function latestLocalDependencyOperationForIdentity(
|
|
|
851
1205
|
|
|
852
1206
|
function hasLocalDependencyRemovalForIdentity(
|
|
853
1207
|
db: Database,
|
|
854
|
-
|
|
1208
|
+
currentBranch: string,
|
|
855
1209
|
identity: DependencyEventIdentity,
|
|
856
1210
|
): boolean {
|
|
857
1211
|
const row = db
|
|
@@ -861,7 +1215,7 @@ function hasLocalDependencyRemovalForIdentity(
|
|
|
861
1215
|
FROM events
|
|
862
1216
|
WHERE entity_kind = 'dependency'
|
|
863
1217
|
AND operation = 'dependency.removed'
|
|
864
|
-
AND
|
|
1218
|
+
AND git_branch = ?
|
|
865
1219
|
AND json_extract(payload, '$.fields.source_id') = ?
|
|
866
1220
|
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
867
1221
|
AND (
|
|
@@ -875,12 +1229,17 @@ function hasLocalDependencyRemovalForIdentity(
|
|
|
875
1229
|
LIMIT 1;
|
|
876
1230
|
`,
|
|
877
1231
|
)
|
|
878
|
-
.get(
|
|
1232
|
+
.get(currentBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
|
|
879
1233
|
|
|
880
1234
|
return row !== null;
|
|
881
1235
|
}
|
|
882
1236
|
|
|
883
|
-
function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent,
|
|
1237
|
+
function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
|
|
1238
|
+
// Detached HEAD has no named branch — no local-branch events can conflict.
|
|
1239
|
+
if (currentBranch === null) {
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
884
1243
|
const identity = dependencyEventIdentity(event);
|
|
885
1244
|
if (identity === null) {
|
|
886
1245
|
return false;
|
|
@@ -890,21 +1249,26 @@ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sour
|
|
|
890
1249
|
return false;
|
|
891
1250
|
}
|
|
892
1251
|
|
|
893
|
-
const latestOperation = latestLocalDependencyOperationForIdentity(db,
|
|
1252
|
+
const latestOperation = latestLocalDependencyOperationForIdentity(db, currentBranch, identity);
|
|
894
1253
|
if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
|
|
895
1254
|
return false;
|
|
896
1255
|
}
|
|
897
1256
|
|
|
898
|
-
return hasLocalDependencyEditsForIdentity(db,
|
|
1257
|
+
return hasLocalDependencyEditsForIdentity(db, currentBranch, identity);
|
|
899
1258
|
}
|
|
900
1259
|
|
|
901
|
-
function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent,
|
|
902
|
-
|
|
1260
|
+
function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
|
|
1261
|
+
// Detached HEAD has no named branch — no local-branch events can conflict.
|
|
1262
|
+
if (currentBranch === null) {
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, currentBranch)) {
|
|
903
1267
|
return true;
|
|
904
1268
|
}
|
|
905
1269
|
|
|
906
1270
|
if (event.entity_kind === "subtask") {
|
|
907
|
-
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id],
|
|
1271
|
+
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], currentBranch);
|
|
908
1272
|
}
|
|
909
1273
|
|
|
910
1274
|
if (event.entity_kind !== "task") {
|
|
@@ -917,12 +1281,12 @@ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBran
|
|
|
917
1281
|
const subtaskIds = subtaskRows.map((row) => row.id);
|
|
918
1282
|
|
|
919
1283
|
for (const subtaskId of subtaskIds) {
|
|
920
|
-
if (hasLocalEntityEdits(db, "subtask", subtaskId,
|
|
1284
|
+
if (hasLocalEntityEdits(db, "subtask", subtaskId, currentBranch)) {
|
|
921
1285
|
return true;
|
|
922
1286
|
}
|
|
923
1287
|
}
|
|
924
1288
|
|
|
925
|
-
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds],
|
|
1289
|
+
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], currentBranch);
|
|
926
1290
|
}
|
|
927
1291
|
|
|
928
1292
|
function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
@@ -1122,7 +1486,12 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
|
|
|
1122
1486
|
return true;
|
|
1123
1487
|
}
|
|
1124
1488
|
|
|
1125
|
-
function applyDelete(
|
|
1489
|
+
function applyDelete(
|
|
1490
|
+
db: Database,
|
|
1491
|
+
event: StoredEvent,
|
|
1492
|
+
fields: Record<string, unknown>,
|
|
1493
|
+
scope: ConflictScope,
|
|
1494
|
+
): boolean {
|
|
1126
1495
|
const tableName = tableForEntityKind(event.entity_kind);
|
|
1127
1496
|
if (!tableName) {
|
|
1128
1497
|
return false;
|
|
@@ -1140,16 +1509,22 @@ function applyDelete(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
1140
1509
|
}
|
|
1141
1510
|
|
|
1142
1511
|
if (event.entity_kind === "task") {
|
|
1143
|
-
removeTaskSubtree(db, event.entity_id);
|
|
1512
|
+
const subtasks = removeTaskSubtree(db, event.entity_id);
|
|
1513
|
+
const subtaskIds = subtasks.map((s) => s.id);
|
|
1514
|
+
removeConflictsForEntityIds(db, "subtask", subtaskIds, scope);
|
|
1515
|
+
removeConflictsForEntityIds(db, "task", [event.entity_id], scope);
|
|
1144
1516
|
} else if (event.entity_kind === "subtask") {
|
|
1145
1517
|
removeDependenciesTouchingNode(db, event.entity_id);
|
|
1518
|
+
removeConflictsForEntityIds(db, "subtask", [event.entity_id], scope);
|
|
1519
|
+
} else {
|
|
1520
|
+
removeConflictsForEntityIds(db, event.entity_kind, [event.entity_id], scope);
|
|
1146
1521
|
}
|
|
1147
1522
|
|
|
1148
1523
|
db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
|
|
1149
1524
|
return true;
|
|
1150
1525
|
}
|
|
1151
1526
|
|
|
1152
|
-
function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean {
|
|
1527
|
+
function hasPendingDeleteConflict(db: Database, sourceEventId: string, scope: ConflictScope): boolean {
|
|
1153
1528
|
const row = db
|
|
1154
1529
|
.query(
|
|
1155
1530
|
`
|
|
@@ -1158,10 +1533,12 @@ function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean
|
|
|
1158
1533
|
WHERE event_id = ?
|
|
1159
1534
|
AND field_name = '__delete__'
|
|
1160
1535
|
AND resolution = 'pending'
|
|
1536
|
+
AND worktree_path = ?
|
|
1537
|
+
AND current_branch = ?
|
|
1161
1538
|
LIMIT 1;
|
|
1162
1539
|
`,
|
|
1163
1540
|
)
|
|
1164
|
-
.get(sourceEventId);
|
|
1541
|
+
.get(sourceEventId, scope.worktreePath, scope.currentBranch);
|
|
1165
1542
|
|
|
1166
1543
|
return row !== null;
|
|
1167
1544
|
}
|
|
@@ -1171,23 +1548,36 @@ function pendingDeleteConflictSourceEventId(fields: Record<string, unknown>): st
|
|
|
1171
1548
|
return typeof sourceEventId === "string" && sourceEventId.length > 0 ? sourceEventId : null;
|
|
1172
1549
|
}
|
|
1173
1550
|
|
|
1174
|
-
function shouldWithholdDeleteCascadeEvent(
|
|
1551
|
+
function shouldWithholdDeleteCascadeEvent(
|
|
1552
|
+
db: Database,
|
|
1553
|
+
event: StoredEvent,
|
|
1554
|
+
fields: Record<string, unknown>,
|
|
1555
|
+
scope: ConflictScope,
|
|
1556
|
+
): boolean {
|
|
1175
1557
|
const sourceEventId = pendingDeleteConflictSourceEventId(fields);
|
|
1176
1558
|
if (!sourceEventId) {
|
|
1177
1559
|
return false;
|
|
1178
1560
|
}
|
|
1179
1561
|
|
|
1180
|
-
const isDeleteCascadeEvent =
|
|
1562
|
+
const isDeleteCascadeEvent =
|
|
1563
|
+
event.operation === "dependency.removed"
|
|
1564
|
+
|| event.operation === "subtask.deleted"
|
|
1565
|
+
|| event.operation === "task.deleted";
|
|
1181
1566
|
if (!isDeleteCascadeEvent) {
|
|
1182
1567
|
return false;
|
|
1183
1568
|
}
|
|
1184
1569
|
|
|
1185
|
-
return hasPendingDeleteConflict(db, sourceEventId);
|
|
1570
|
+
return hasPendingDeleteConflict(db, sourceEventId, scope);
|
|
1186
1571
|
}
|
|
1187
1572
|
|
|
1188
|
-
function applyEntityFields(
|
|
1573
|
+
function applyEntityFields(
|
|
1574
|
+
db: Database,
|
|
1575
|
+
event: StoredEvent,
|
|
1576
|
+
fields: Record<string, unknown>,
|
|
1577
|
+
scope: ConflictScope,
|
|
1578
|
+
): boolean {
|
|
1189
1579
|
if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
|
|
1190
|
-
return applyDelete(db, event, fields);
|
|
1580
|
+
return applyDelete(db, event, fields, scope);
|
|
1191
1581
|
}
|
|
1192
1582
|
|
|
1193
1583
|
if (event.operation.endsWith(".created") || event.operation === "dependency.added") {
|
|
@@ -1277,7 +1667,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
|
|
|
1277
1667
|
sourceBranch,
|
|
1278
1668
|
ahead: countAhead(storage.db, git.branchName, sourceBranch),
|
|
1279
1669
|
behind: onSourceBranch ? 0 : countBranchEventsSince(storage.db, sourceBranch, cursorToken),
|
|
1280
|
-
pendingConflicts: countPendingConflicts(storage.db),
|
|
1670
|
+
pendingConflicts: countPendingConflicts(storage.db, scopeFromGitContext(git)),
|
|
1281
1671
|
sameBranch: onSourceBranch,
|
|
1282
1672
|
git,
|
|
1283
1673
|
};
|
|
@@ -1303,32 +1693,44 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1303
1693
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
1304
1694
|
let scannedEvents = 0;
|
|
1305
1695
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
}
|
|
1696
|
+
// Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
|
|
1697
|
+
// events is committed in its own transaction so the write lock is
|
|
1698
|
+
// never held across multiple batches. On crash, the cursor reflects
|
|
1699
|
+
// the last fully-committed batch and the next pull resumes there.
|
|
1700
|
+
while (true) {
|
|
1701
|
+
const incomingEvents = queryBranchEventsSinceBatch(
|
|
1702
|
+
storage.db,
|
|
1703
|
+
sourceBranch,
|
|
1704
|
+
lastToken ?? cursorToken,
|
|
1705
|
+
SYNC_PULL_BATCH_SIZE,
|
|
1706
|
+
) as StoredEvent[];
|
|
1318
1707
|
|
|
1319
|
-
|
|
1708
|
+
if (incomingEvents.length === 0) {
|
|
1709
|
+
break;
|
|
1710
|
+
}
|
|
1320
1711
|
|
|
1712
|
+
const batchResult = writeTransaction(storage.db, (): { token: string | null; eventAt: number | null } => {
|
|
1713
|
+
let token: string | null = lastToken;
|
|
1714
|
+
let eventAt: number | null = lastEventAt;
|
|
1321
1715
|
for (const incoming of incomingEvents) {
|
|
1322
1716
|
storeEvent(storage.db, incoming);
|
|
1323
|
-
|
|
1324
|
-
|
|
1717
|
+
token = cursorTokenFromEvent(incoming);
|
|
1718
|
+
eventAt = incoming.created_at;
|
|
1325
1719
|
}
|
|
1326
|
-
|
|
1720
|
+
if (token) {
|
|
1721
|
+
saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
|
|
1722
|
+
}
|
|
1723
|
+
return { token, eventAt };
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
scannedEvents += incomingEvents.length;
|
|
1727
|
+
lastToken = batchResult.token;
|
|
1728
|
+
lastEventAt = batchResult.eventAt;
|
|
1327
1729
|
|
|
1328
|
-
if (
|
|
1329
|
-
|
|
1730
|
+
if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
|
|
1731
|
+
break;
|
|
1330
1732
|
}
|
|
1331
|
-
}
|
|
1733
|
+
}
|
|
1332
1734
|
|
|
1333
1735
|
return {
|
|
1334
1736
|
sourceBranch,
|
|
@@ -1360,71 +1762,106 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1360
1762
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
1361
1763
|
let scannedEvents = 0;
|
|
1362
1764
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1765
|
+
// Per-pull memoization for "ours" field-value lookups. Reused across
|
|
1766
|
+
// every incoming event so repeated probes of the same (entity, field)
|
|
1767
|
+
// are O(1) after first hit.
|
|
1768
|
+
const oursCache = createOursValueCache();
|
|
1769
|
+
|
|
1770
|
+
// Conflict scope: every conflict / cleanup created by this pull is
|
|
1771
|
+
// tagged with the current worktree+branch so peer worktrees observing
|
|
1772
|
+
// the same entity own their own row set and cannot erase each other.
|
|
1773
|
+
const conflictScope: ConflictScope = scopeFromGitContext(git);
|
|
1774
|
+
|
|
1775
|
+
// Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
|
|
1776
|
+
// events is processed inside its own writeTransaction. The cursor and
|
|
1777
|
+
// lastEventAt are persisted at the end of each batch, so a crash mid-
|
|
1778
|
+
// pull leaves a consistent cursor pointing at the last fully-committed
|
|
1779
|
+
// batch and the next pull resumes from there. The write lock is no
|
|
1780
|
+
// longer held across multiple batches.
|
|
1781
|
+
while (true) {
|
|
1782
|
+
const incomingEvents = queryBranchEventsSinceBatch(
|
|
1783
|
+
storage.db,
|
|
1784
|
+
sourceBranch,
|
|
1785
|
+
lastToken ?? cursorToken,
|
|
1786
|
+
SYNC_PULL_BATCH_SIZE,
|
|
1787
|
+
) as StoredEvent[];
|
|
1371
1788
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1789
|
+
if (incomingEvents.length === 0) {
|
|
1790
|
+
break;
|
|
1791
|
+
}
|
|
1375
1792
|
|
|
1376
|
-
|
|
1793
|
+
interface BatchResult {
|
|
1794
|
+
readonly appliedDelta: number;
|
|
1795
|
+
readonly createdConflictsDelta: number;
|
|
1796
|
+
readonly malformedPayloadDelta: number;
|
|
1797
|
+
readonly applyRejectedDelta: number;
|
|
1798
|
+
readonly quarantinedDelta: number;
|
|
1799
|
+
readonly conflictEventsDelta: number;
|
|
1800
|
+
readonly token: string | null;
|
|
1801
|
+
readonly eventAt: number | null;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
const batchResult: BatchResult = writeTransaction(storage.db, (): BatchResult => {
|
|
1805
|
+
let appliedDelta = 0;
|
|
1806
|
+
let createdConflictsDelta = 0;
|
|
1807
|
+
let malformedPayloadDelta = 0;
|
|
1808
|
+
let applyRejectedDelta = 0;
|
|
1809
|
+
let quarantinedDelta = 0;
|
|
1810
|
+
let conflictEventsDelta = 0;
|
|
1811
|
+
let token: string | null = lastToken;
|
|
1812
|
+
let eventAt: number | null = lastEventAt;
|
|
1377
1813
|
|
|
1378
1814
|
for (const incoming of incomingEvents) {
|
|
1379
1815
|
if (incoming.operation === "resolve_conflict") {
|
|
1380
|
-
if (applyIncomingResolutionEvent(storage.db, incoming)) {
|
|
1381
|
-
|
|
1816
|
+
if (applyIncomingResolutionEvent(storage.db, incoming, conflictScope)) {
|
|
1817
|
+
appliedDelta += 1;
|
|
1382
1818
|
}
|
|
1383
1819
|
storeEvent(storage.db, incoming);
|
|
1384
|
-
|
|
1385
|
-
|
|
1820
|
+
token = cursorTokenFromEvent(incoming);
|
|
1821
|
+
eventAt = incoming.created_at;
|
|
1386
1822
|
continue;
|
|
1387
1823
|
}
|
|
1388
1824
|
|
|
1389
1825
|
const payloadValidation = parsePayload(incoming.payload);
|
|
1390
1826
|
|
|
1391
1827
|
if (!payloadValidation.ok) {
|
|
1392
|
-
|
|
1393
|
-
|
|
1828
|
+
malformedPayloadDelta += 1;
|
|
1829
|
+
quarantinedDelta += 1;
|
|
1394
1830
|
createConflict(
|
|
1395
1831
|
storage.db,
|
|
1396
1832
|
incoming,
|
|
1397
1833
|
"__payload__",
|
|
1398
1834
|
null,
|
|
1399
1835
|
payloadValidation.reason ?? "Invalid payload",
|
|
1836
|
+
conflictScope,
|
|
1400
1837
|
"invalid",
|
|
1401
1838
|
);
|
|
1402
|
-
|
|
1839
|
+
createdConflictsDelta += 1;
|
|
1403
1840
|
storeEvent(storage.db, incoming);
|
|
1404
|
-
|
|
1405
|
-
|
|
1841
|
+
token = cursorTokenFromEvent(incoming);
|
|
1842
|
+
eventAt = incoming.created_at;
|
|
1406
1843
|
continue;
|
|
1407
1844
|
}
|
|
1408
1845
|
|
|
1409
1846
|
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
1410
1847
|
|
|
1411
|
-
if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields)) {
|
|
1848
|
+
if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields, conflictScope)) {
|
|
1412
1849
|
storeEvent(storage.db, incoming);
|
|
1413
|
-
|
|
1414
|
-
|
|
1850
|
+
token = cursorTokenFromEvent(incoming);
|
|
1851
|
+
eventAt = incoming.created_at;
|
|
1415
1852
|
continue;
|
|
1416
1853
|
}
|
|
1417
1854
|
|
|
1418
1855
|
const isDeleteWithLocalEdits =
|
|
1419
|
-
(incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming,
|
|
1420
|
-
(incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming,
|
|
1856
|
+
(incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, git.branchName)) ||
|
|
1857
|
+
(incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, git.branchName));
|
|
1421
1858
|
if (isDeleteWithLocalEdits) {
|
|
1422
|
-
createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
|
|
1423
|
-
|
|
1424
|
-
|
|
1859
|
+
createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch", conflictScope);
|
|
1860
|
+
createdConflictsDelta += 1;
|
|
1861
|
+
conflictEventsDelta += 1;
|
|
1425
1862
|
storeEvent(storage.db, incoming);
|
|
1426
|
-
|
|
1427
|
-
|
|
1863
|
+
token = cursorTokenFromEvent(incoming);
|
|
1864
|
+
eventAt = incoming.created_at;
|
|
1428
1865
|
continue;
|
|
1429
1866
|
}
|
|
1430
1867
|
|
|
@@ -1437,47 +1874,88 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1437
1874
|
continue;
|
|
1438
1875
|
}
|
|
1439
1876
|
|
|
1440
|
-
const conflict = entityFieldConflict(
|
|
1877
|
+
const conflict = entityFieldConflict(
|
|
1878
|
+
storage.db,
|
|
1879
|
+
git.branchName,
|
|
1880
|
+
sourceBranch,
|
|
1881
|
+
incoming,
|
|
1882
|
+
fieldName,
|
|
1883
|
+
value,
|
|
1884
|
+
oursCache,
|
|
1885
|
+
);
|
|
1441
1886
|
|
|
1442
1887
|
if (conflict) {
|
|
1443
1888
|
withheldConflictCount += 1;
|
|
1444
|
-
|
|
1445
|
-
createConflict(
|
|
1446
|
-
|
|
1889
|
+
conflictEventsDelta += 1;
|
|
1890
|
+
createConflict(
|
|
1891
|
+
storage.db,
|
|
1892
|
+
incoming,
|
|
1893
|
+
fieldName,
|
|
1894
|
+
conflict.oursValue,
|
|
1895
|
+
conflict.theirsValue,
|
|
1896
|
+
conflictScope,
|
|
1897
|
+
);
|
|
1898
|
+
createdConflictsDelta += 1;
|
|
1447
1899
|
continue;
|
|
1448
1900
|
}
|
|
1449
1901
|
|
|
1450
1902
|
fieldsToApply[fieldName] = value;
|
|
1451
1903
|
}
|
|
1452
1904
|
|
|
1453
|
-
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
1454
|
-
|
|
1905
|
+
if (applyEntityFields(storage.db, incoming, fieldsToApply, conflictScope)) {
|
|
1906
|
+
appliedDelta += 1;
|
|
1455
1907
|
} else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
|
|
1456
|
-
|
|
1908
|
+
appliedDelta += 1;
|
|
1457
1909
|
} else {
|
|
1458
|
-
|
|
1459
|
-
|
|
1910
|
+
applyRejectedDelta += 1;
|
|
1911
|
+
quarantinedDelta += 1;
|
|
1460
1912
|
createConflict(
|
|
1461
1913
|
storage.db,
|
|
1462
1914
|
incoming,
|
|
1463
1915
|
"__apply__",
|
|
1464
1916
|
null,
|
|
1465
1917
|
`Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
|
|
1918
|
+
conflictScope,
|
|
1466
1919
|
"invalid",
|
|
1467
1920
|
);
|
|
1468
|
-
|
|
1921
|
+
createdConflictsDelta += 1;
|
|
1469
1922
|
}
|
|
1470
1923
|
|
|
1471
1924
|
storeEvent(storage.db, incoming);
|
|
1472
|
-
|
|
1473
|
-
|
|
1925
|
+
token = cursorTokenFromEvent(incoming);
|
|
1926
|
+
eventAt = incoming.created_at;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (token) {
|
|
1930
|
+
saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
|
|
1474
1931
|
}
|
|
1475
|
-
}
|
|
1476
1932
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1933
|
+
return {
|
|
1934
|
+
appliedDelta,
|
|
1935
|
+
createdConflictsDelta,
|
|
1936
|
+
malformedPayloadDelta,
|
|
1937
|
+
applyRejectedDelta,
|
|
1938
|
+
quarantinedDelta,
|
|
1939
|
+
conflictEventsDelta,
|
|
1940
|
+
token,
|
|
1941
|
+
eventAt,
|
|
1942
|
+
};
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
scannedEvents += incomingEvents.length;
|
|
1946
|
+
appliedEvents += batchResult.appliedDelta;
|
|
1947
|
+
createdConflicts += batchResult.createdConflictsDelta;
|
|
1948
|
+
malformedPayloadEvents += batchResult.malformedPayloadDelta;
|
|
1949
|
+
applyRejectedEvents += batchResult.applyRejectedDelta;
|
|
1950
|
+
quarantinedEvents += batchResult.quarantinedDelta;
|
|
1951
|
+
conflictEvents += batchResult.conflictEventsDelta;
|
|
1952
|
+
lastToken = batchResult.token;
|
|
1953
|
+
lastEventAt = batchResult.eventAt;
|
|
1954
|
+
|
|
1955
|
+
if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
|
|
1956
|
+
break;
|
|
1479
1957
|
}
|
|
1480
|
-
}
|
|
1958
|
+
}
|
|
1481
1959
|
|
|
1482
1960
|
const errorHints: string[] = buildSyncErrorHints({
|
|
1483
1961
|
malformedPayloadEvents,
|
|
@@ -1651,6 +2129,12 @@ function appendResolutionEvent(
|
|
|
1651
2129
|
field: conflict.field_name,
|
|
1652
2130
|
resolution,
|
|
1653
2131
|
value: resolvedValue,
|
|
2132
|
+
// Embed the resolved row's scope so receivers can audit which
|
|
2133
|
+
// worktree/branch performed the resolution. Receivers still
|
|
2134
|
+
// intersect against their OWN active scope when looking up the
|
|
2135
|
+
// local row to mutate.
|
|
2136
|
+
worktree_path: conflict.worktree_path,
|
|
2137
|
+
current_branch: conflict.current_branch,
|
|
1654
2138
|
}),
|
|
1655
2139
|
gitBranch,
|
|
1656
2140
|
gitHead,
|
|
@@ -1661,19 +2145,30 @@ function appendResolutionEvent(
|
|
|
1661
2145
|
|
|
1662
2146
|
export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConflictListItem[] {
|
|
1663
2147
|
const storage = openTrekoonDatabase(cwd);
|
|
2148
|
+
const git = resolveGitContext(cwd);
|
|
2149
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1664
2150
|
|
|
1665
2151
|
try {
|
|
1666
|
-
|
|
2152
|
+
// Conflicts are scoped to the worktree+branch that recorded them. Each
|
|
2153
|
+
// worktree only sees its own pending/resolved conflicts so peer
|
|
2154
|
+
// worktrees on the same shared DB don't bleed into one another.
|
|
2155
|
+
const conditions: string[] = ["worktree_path = ?", "current_branch = ?"];
|
|
2156
|
+
const params: string[] = [scope.worktreePath, scope.currentBranch];
|
|
2157
|
+
|
|
2158
|
+
if (mode === "pending") {
|
|
2159
|
+
conditions.push("resolution = 'pending'");
|
|
2160
|
+
}
|
|
2161
|
+
|
|
1667
2162
|
return storage.db
|
|
1668
2163
|
.query(
|
|
1669
2164
|
`
|
|
1670
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2165
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1671
2166
|
FROM sync_conflicts
|
|
1672
|
-
${
|
|
2167
|
+
WHERE ${conditions.join(" AND ")}
|
|
1673
2168
|
ORDER BY created_at ASC;
|
|
1674
2169
|
`,
|
|
1675
2170
|
)
|
|
1676
|
-
.all() as SyncConflictListItem[];
|
|
2171
|
+
.all(...params) as SyncConflictListItem[];
|
|
1677
2172
|
} finally {
|
|
1678
2173
|
storage.close();
|
|
1679
2174
|
}
|
|
@@ -1686,7 +2181,7 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
|
|
|
1686
2181
|
const conflict = storage.db
|
|
1687
2182
|
.query(
|
|
1688
2183
|
`
|
|
1689
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2184
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1690
2185
|
FROM sync_conflicts
|
|
1691
2186
|
WHERE id = ?
|
|
1692
2187
|
LIMIT 1;
|
|
@@ -1740,7 +2235,7 @@ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
|
|
|
1740
2235
|
const conflict = db
|
|
1741
2236
|
.query(
|
|
1742
2237
|
`
|
|
1743
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2238
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1744
2239
|
FROM sync_conflicts
|
|
1745
2240
|
WHERE id = ?
|
|
1746
2241
|
LIMIT 1;
|
|
@@ -1818,17 +2313,22 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
|
|
|
1818
2313
|
function queryPendingConflictIds(
|
|
1819
2314
|
db: Database,
|
|
1820
2315
|
filters: ResolveAllQueryFilters,
|
|
2316
|
+
scope: ConflictScope,
|
|
1821
2317
|
): readonly string[] {
|
|
1822
|
-
const conditions: string[] = [
|
|
1823
|
-
|
|
2318
|
+
const conditions: string[] = [
|
|
2319
|
+
"c.resolution = 'pending'",
|
|
2320
|
+
"c.worktree_path = ?",
|
|
2321
|
+
"c.current_branch = ?",
|
|
2322
|
+
];
|
|
2323
|
+
const params: string[] = [scope.worktreePath, scope.currentBranch];
|
|
1824
2324
|
|
|
1825
2325
|
if (filters.entityId !== undefined) {
|
|
1826
|
-
conditions.push("entity_id = ?");
|
|
2326
|
+
conditions.push("c.entity_id = ?");
|
|
1827
2327
|
params.push(filters.entityId);
|
|
1828
2328
|
}
|
|
1829
2329
|
|
|
1830
2330
|
if (filters.fieldName !== undefined) {
|
|
1831
|
-
conditions.push("field_name = ?");
|
|
2331
|
+
conditions.push("c.field_name = ?");
|
|
1832
2332
|
params.push(filters.fieldName);
|
|
1833
2333
|
}
|
|
1834
2334
|
|
|
@@ -1836,7 +2336,7 @@ function queryPendingConflictIds(
|
|
|
1836
2336
|
SELECT c.id
|
|
1837
2337
|
FROM sync_conflicts c
|
|
1838
2338
|
LEFT JOIN events e ON e.id = c.event_id
|
|
1839
|
-
WHERE ${conditions.
|
|
2339
|
+
WHERE ${conditions.join(" AND ")}
|
|
1840
2340
|
ORDER BY COALESCE(e.created_at, c.created_at) ASC, COALESCE(e.id, c.event_id) ASC, c.created_at ASC, c.id ASC;
|
|
1841
2341
|
`;
|
|
1842
2342
|
|
|
@@ -1852,7 +2352,7 @@ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]
|
|
|
1852
2352
|
const rows = db
|
|
1853
2353
|
.query(
|
|
1854
2354
|
`
|
|
1855
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2355
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1856
2356
|
FROM sync_conflicts
|
|
1857
2357
|
WHERE resolution = 'pending' AND id IN (${placeholders});
|
|
1858
2358
|
`,
|
|
@@ -1880,9 +2380,10 @@ export function syncResolveAll(
|
|
|
1880
2380
|
try {
|
|
1881
2381
|
persistGitContext(storage.db, git);
|
|
1882
2382
|
|
|
2383
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1883
2384
|
const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
|
|
1884
2385
|
const expectedConflictIds = options.expectedConflictIds;
|
|
1885
|
-
const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
|
|
2386
|
+
const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters, scope);
|
|
1886
2387
|
|
|
1887
2388
|
if (orderedConflictIds.length === 0) {
|
|
1888
2389
|
throw new DomainError({
|
|
@@ -1936,10 +2437,12 @@ export function syncResolveAllPreview(
|
|
|
1936
2437
|
filters: ResolveAllQueryFilters,
|
|
1937
2438
|
): ResolveAllPreviewSummary {
|
|
1938
2439
|
const storage = openTrekoonDatabase(cwd);
|
|
2440
|
+
const git = resolveGitContext(cwd);
|
|
2441
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1939
2442
|
const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
|
|
1940
2443
|
|
|
1941
2444
|
try {
|
|
1942
|
-
const conflictIds = queryPendingConflictIds(storage.db, filters);
|
|
2445
|
+
const conflictIds = queryPendingConflictIds(storage.db, filters, scope);
|
|
1943
2446
|
|
|
1944
2447
|
if (conflictIds.length === 0) {
|
|
1945
2448
|
throw new DomainError({
|