trekoon 0.4.1 → 0.4.3
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 +97 -765
- package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
- package/.agents/skills/trekoon/reference/execution.md +188 -159
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +213 -213
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +82 -0
- package/README.md +29 -8
- package/docs/ai-agents.md +65 -6
- package/docs/commands.md +149 -5
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +55 -3
- package/package.json +1 -1
- package/src/board/assets/app.js +47 -13
- package/src/board/assets/components/Component.js +20 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- 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 +45 -4
- package/src/board/assets/state/api.js +304 -17
- package/src/board/assets/state/store.js +82 -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 +81 -0
- package/src/board/routes.ts +430 -40
- package/src/board/server.ts +86 -10
- package/src/board/snapshot.ts +6 -0
- package/src/board/wal-watcher.ts +313 -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 +75 -10
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/skills.ts +17 -5
- 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 +842 -187
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +167 -693
- package/src/domain/types.ts +56 -2
- package/src/export/render-markdown.ts +1 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +700 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +268 -4
- package/src/storage/migrations.ts +441 -22
- 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 +679 -156
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,67 @@ 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
|
+
|
|
954
|
+
for (let offset = 0; offset < entityIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
|
|
955
|
+
const chunkIds = entityIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
|
|
956
|
+
const placeholders = chunkIds.map(() => "?").join(", ");
|
|
957
|
+
if (excludeConflictId !== undefined) {
|
|
958
|
+
db.query(
|
|
959
|
+
`DELETE FROM sync_conflicts
|
|
960
|
+
WHERE entity_kind = ?
|
|
961
|
+
AND entity_id IN (${placeholders})
|
|
962
|
+
AND worktree_path = ?
|
|
963
|
+
AND current_branch = ?
|
|
964
|
+
AND id != ?;`,
|
|
965
|
+
).run(entityKind, ...chunkIds, scope.worktreePath, scope.currentBranch, excludeConflictId);
|
|
966
|
+
} else {
|
|
967
|
+
db.query(
|
|
968
|
+
`DELETE FROM sync_conflicts
|
|
969
|
+
WHERE entity_kind = ?
|
|
970
|
+
AND entity_id IN (${placeholders})
|
|
971
|
+
AND worktree_path = ?
|
|
972
|
+
AND current_branch = ?;`,
|
|
973
|
+
).run(entityKind, ...chunkIds, scope.worktreePath, scope.currentBranch);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function removeTaskSubtree(db: Database, taskId: string): Array<{ id: string }> {
|
|
639
979
|
const subtasks = db
|
|
640
980
|
.query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
|
|
641
981
|
.all(taskId) as Array<{ id: string }>;
|
|
@@ -646,6 +986,7 @@ function removeTaskSubtree(db: Database, taskId: string): void {
|
|
|
646
986
|
|
|
647
987
|
db.query("DELETE FROM subtasks WHERE task_id = ?;").run(taskId);
|
|
648
988
|
removeDependenciesTouchingNode(db, taskId);
|
|
989
|
+
return subtasks;
|
|
649
990
|
}
|
|
650
991
|
|
|
651
992
|
function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow): void {
|
|
@@ -670,12 +1011,25 @@ function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow
|
|
|
670
1011
|
}
|
|
671
1012
|
}
|
|
672
1013
|
|
|
1014
|
+
function scopeFromConflictRow(conflict: ConflictRow): ConflictScope {
|
|
1015
|
+
return { worktreePath: conflict.worktree_path, currentBranch: conflict.current_branch };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
673
1018
|
function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): void {
|
|
1019
|
+
// Cleanup is scoped to the conflict's own worktree+branch so resolving a
|
|
1020
|
+
// conflict in this worktree does not erase peer worktrees' pending
|
|
1021
|
+
// conflicts on the same entity.
|
|
1022
|
+
const scope: ConflictScope = scopeFromConflictRow(conflict);
|
|
1023
|
+
|
|
674
1024
|
if (conflict.field_name === "__delete__") {
|
|
675
1025
|
if (conflict.entity_kind === "task") {
|
|
676
|
-
removeTaskSubtree(db, conflict.entity_id);
|
|
1026
|
+
const subtasks = removeTaskSubtree(db, conflict.entity_id);
|
|
1027
|
+
const subtaskIds = subtasks.map((s) => s.id);
|
|
1028
|
+
removeConflictsForEntityIds(db, "subtask", subtaskIds, scope, conflict.id);
|
|
1029
|
+
removeConflictsForEntityIds(db, "task", [conflict.entity_id], scope, conflict.id);
|
|
677
1030
|
} else if (conflict.entity_kind === "subtask") {
|
|
678
1031
|
removeDependenciesTouchingNode(db, conflict.entity_id);
|
|
1032
|
+
removeConflictsForEntityIds(db, "subtask", [conflict.entity_id], scope, conflict.id);
|
|
679
1033
|
}
|
|
680
1034
|
applyPendingDeleteCascadeResolution(db, conflict);
|
|
681
1035
|
deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id, { allowMissing: true });
|
|
@@ -687,7 +1041,11 @@ function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): voi
|
|
|
687
1041
|
});
|
|
688
1042
|
}
|
|
689
1043
|
|
|
690
|
-
function applyIncomingResolutionEvent(
|
|
1044
|
+
function applyIncomingResolutionEvent(
|
|
1045
|
+
db: Database,
|
|
1046
|
+
event: StoredEvent,
|
|
1047
|
+
receiverScope: ConflictScope,
|
|
1048
|
+
): boolean {
|
|
691
1049
|
const parsed = parseJsonObject(event.payload);
|
|
692
1050
|
if (!parsed) {
|
|
693
1051
|
return false;
|
|
@@ -704,7 +1062,7 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
|
|
|
704
1062
|
return false;
|
|
705
1063
|
}
|
|
706
1064
|
|
|
707
|
-
const conflict = findConflictForResolutionEvent(db, event, resolutionPayload);
|
|
1065
|
+
const conflict = findConflictForResolutionEvent(db, event, resolutionPayload, receiverScope);
|
|
708
1066
|
if (!conflict) {
|
|
709
1067
|
return false;
|
|
710
1068
|
}
|
|
@@ -739,16 +1097,16 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
|
|
|
739
1097
|
return updated.changes > 0;
|
|
740
1098
|
}
|
|
741
1099
|
|
|
742
|
-
function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string,
|
|
1100
|
+
function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, currentBranch: string): boolean {
|
|
743
1101
|
const row = db
|
|
744
1102
|
.query(
|
|
745
|
-
`SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND
|
|
1103
|
+
`SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch = ? LIMIT 1;`,
|
|
746
1104
|
)
|
|
747
|
-
.get(entityKind, entityId,
|
|
1105
|
+
.get(entityKind, entityId, currentBranch);
|
|
748
1106
|
return row !== null;
|
|
749
1107
|
}
|
|
750
1108
|
|
|
751
|
-
function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[],
|
|
1109
|
+
function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], currentBranch: string): boolean {
|
|
752
1110
|
if (nodeIds.length === 0) {
|
|
753
1111
|
return false;
|
|
754
1112
|
}
|
|
@@ -762,7 +1120,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
762
1120
|
SELECT 1
|
|
763
1121
|
FROM events
|
|
764
1122
|
WHERE entity_kind = 'dependency'
|
|
765
|
-
AND
|
|
1123
|
+
AND git_branch = ?
|
|
766
1124
|
AND (
|
|
767
1125
|
json_extract(payload, '$.fields.source_id') IN (${placeholders})
|
|
768
1126
|
OR json_extract(payload, '$.fields.depends_on_id') IN (${placeholders})
|
|
@@ -770,7 +1128,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
770
1128
|
LIMIT 1;
|
|
771
1129
|
`,
|
|
772
1130
|
)
|
|
773
|
-
.get(
|
|
1131
|
+
.get(currentBranch, ...chunk, ...chunk);
|
|
774
1132
|
|
|
775
1133
|
if (row !== null) {
|
|
776
1134
|
return true;
|
|
@@ -782,7 +1140,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
782
1140
|
|
|
783
1141
|
function hasLocalDependencyEditsForIdentity(
|
|
784
1142
|
db: Database,
|
|
785
|
-
|
|
1143
|
+
currentBranch: string,
|
|
786
1144
|
identity: DependencyEventIdentity,
|
|
787
1145
|
): boolean {
|
|
788
1146
|
const row = db
|
|
@@ -791,7 +1149,7 @@ function hasLocalDependencyEditsForIdentity(
|
|
|
791
1149
|
SELECT 1
|
|
792
1150
|
FROM events
|
|
793
1151
|
WHERE entity_kind = 'dependency'
|
|
794
|
-
AND
|
|
1152
|
+
AND git_branch = ?
|
|
795
1153
|
AND json_extract(payload, '$.fields.source_id') = ?
|
|
796
1154
|
AND json_extract(payload, '$.fields.source_kind') = ?
|
|
797
1155
|
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
@@ -799,7 +1157,7 @@ function hasLocalDependencyEditsForIdentity(
|
|
|
799
1157
|
LIMIT 1;
|
|
800
1158
|
`,
|
|
801
1159
|
)
|
|
802
|
-
.get(
|
|
1160
|
+
.get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
|
|
803
1161
|
|
|
804
1162
|
return row !== null;
|
|
805
1163
|
}
|
|
@@ -824,7 +1182,7 @@ function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventI
|
|
|
824
1182
|
|
|
825
1183
|
function latestLocalDependencyOperationForIdentity(
|
|
826
1184
|
db: Database,
|
|
827
|
-
|
|
1185
|
+
currentBranch: string,
|
|
828
1186
|
identity: DependencyEventIdentity,
|
|
829
1187
|
): string | null {
|
|
830
1188
|
const row = db
|
|
@@ -833,7 +1191,7 @@ function latestLocalDependencyOperationForIdentity(
|
|
|
833
1191
|
SELECT operation
|
|
834
1192
|
FROM events
|
|
835
1193
|
WHERE entity_kind = 'dependency'
|
|
836
|
-
AND
|
|
1194
|
+
AND git_branch = ?
|
|
837
1195
|
AND json_extract(payload, '$.fields.source_id') = ?
|
|
838
1196
|
AND json_extract(payload, '$.fields.source_kind') = ?
|
|
839
1197
|
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
@@ -842,7 +1200,7 @@ function latestLocalDependencyOperationForIdentity(
|
|
|
842
1200
|
LIMIT 1;
|
|
843
1201
|
`,
|
|
844
1202
|
)
|
|
845
|
-
.get(
|
|
1203
|
+
.get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
|
|
846
1204
|
| { operation: string }
|
|
847
1205
|
| null;
|
|
848
1206
|
|
|
@@ -851,7 +1209,7 @@ function latestLocalDependencyOperationForIdentity(
|
|
|
851
1209
|
|
|
852
1210
|
function hasLocalDependencyRemovalForIdentity(
|
|
853
1211
|
db: Database,
|
|
854
|
-
|
|
1212
|
+
currentBranch: string,
|
|
855
1213
|
identity: DependencyEventIdentity,
|
|
856
1214
|
): boolean {
|
|
857
1215
|
const row = db
|
|
@@ -861,7 +1219,7 @@ function hasLocalDependencyRemovalForIdentity(
|
|
|
861
1219
|
FROM events
|
|
862
1220
|
WHERE entity_kind = 'dependency'
|
|
863
1221
|
AND operation = 'dependency.removed'
|
|
864
|
-
AND
|
|
1222
|
+
AND git_branch = ?
|
|
865
1223
|
AND json_extract(payload, '$.fields.source_id') = ?
|
|
866
1224
|
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
867
1225
|
AND (
|
|
@@ -875,12 +1233,17 @@ function hasLocalDependencyRemovalForIdentity(
|
|
|
875
1233
|
LIMIT 1;
|
|
876
1234
|
`,
|
|
877
1235
|
)
|
|
878
|
-
.get(
|
|
1236
|
+
.get(currentBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
|
|
879
1237
|
|
|
880
1238
|
return row !== null;
|
|
881
1239
|
}
|
|
882
1240
|
|
|
883
|
-
function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent,
|
|
1241
|
+
function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
|
|
1242
|
+
// Detached HEAD has no named branch — no local-branch events can conflict.
|
|
1243
|
+
if (currentBranch === null) {
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
884
1247
|
const identity = dependencyEventIdentity(event);
|
|
885
1248
|
if (identity === null) {
|
|
886
1249
|
return false;
|
|
@@ -890,21 +1253,26 @@ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sour
|
|
|
890
1253
|
return false;
|
|
891
1254
|
}
|
|
892
1255
|
|
|
893
|
-
const latestOperation = latestLocalDependencyOperationForIdentity(db,
|
|
1256
|
+
const latestOperation = latestLocalDependencyOperationForIdentity(db, currentBranch, identity);
|
|
894
1257
|
if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
|
|
895
1258
|
return false;
|
|
896
1259
|
}
|
|
897
1260
|
|
|
898
|
-
return hasLocalDependencyEditsForIdentity(db,
|
|
1261
|
+
return hasLocalDependencyEditsForIdentity(db, currentBranch, identity);
|
|
899
1262
|
}
|
|
900
1263
|
|
|
901
|
-
function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent,
|
|
902
|
-
|
|
1264
|
+
function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
|
|
1265
|
+
// Detached HEAD has no named branch — no local-branch events can conflict.
|
|
1266
|
+
if (currentBranch === null) {
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, currentBranch)) {
|
|
903
1271
|
return true;
|
|
904
1272
|
}
|
|
905
1273
|
|
|
906
1274
|
if (event.entity_kind === "subtask") {
|
|
907
|
-
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id],
|
|
1275
|
+
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], currentBranch);
|
|
908
1276
|
}
|
|
909
1277
|
|
|
910
1278
|
if (event.entity_kind !== "task") {
|
|
@@ -917,12 +1285,12 @@ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBran
|
|
|
917
1285
|
const subtaskIds = subtaskRows.map((row) => row.id);
|
|
918
1286
|
|
|
919
1287
|
for (const subtaskId of subtaskIds) {
|
|
920
|
-
if (hasLocalEntityEdits(db, "subtask", subtaskId,
|
|
1288
|
+
if (hasLocalEntityEdits(db, "subtask", subtaskId, currentBranch)) {
|
|
921
1289
|
return true;
|
|
922
1290
|
}
|
|
923
1291
|
}
|
|
924
1292
|
|
|
925
|
-
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds],
|
|
1293
|
+
return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], currentBranch);
|
|
926
1294
|
}
|
|
927
1295
|
|
|
928
1296
|
function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
@@ -1122,7 +1490,12 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
|
|
|
1122
1490
|
return true;
|
|
1123
1491
|
}
|
|
1124
1492
|
|
|
1125
|
-
function applyDelete(
|
|
1493
|
+
function applyDelete(
|
|
1494
|
+
db: Database,
|
|
1495
|
+
event: StoredEvent,
|
|
1496
|
+
fields: Record<string, unknown>,
|
|
1497
|
+
scope: ConflictScope,
|
|
1498
|
+
): boolean {
|
|
1126
1499
|
const tableName = tableForEntityKind(event.entity_kind);
|
|
1127
1500
|
if (!tableName) {
|
|
1128
1501
|
return false;
|
|
@@ -1140,16 +1513,22 @@ function applyDelete(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
1140
1513
|
}
|
|
1141
1514
|
|
|
1142
1515
|
if (event.entity_kind === "task") {
|
|
1143
|
-
removeTaskSubtree(db, event.entity_id);
|
|
1516
|
+
const subtasks = removeTaskSubtree(db, event.entity_id);
|
|
1517
|
+
const subtaskIds = subtasks.map((s) => s.id);
|
|
1518
|
+
removeConflictsForEntityIds(db, "subtask", subtaskIds, scope);
|
|
1519
|
+
removeConflictsForEntityIds(db, "task", [event.entity_id], scope);
|
|
1144
1520
|
} else if (event.entity_kind === "subtask") {
|
|
1145
1521
|
removeDependenciesTouchingNode(db, event.entity_id);
|
|
1522
|
+
removeConflictsForEntityIds(db, "subtask", [event.entity_id], scope);
|
|
1523
|
+
} else {
|
|
1524
|
+
removeConflictsForEntityIds(db, event.entity_kind, [event.entity_id], scope);
|
|
1146
1525
|
}
|
|
1147
1526
|
|
|
1148
1527
|
db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
|
|
1149
1528
|
return true;
|
|
1150
1529
|
}
|
|
1151
1530
|
|
|
1152
|
-
function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean {
|
|
1531
|
+
function hasPendingDeleteConflict(db: Database, sourceEventId: string, scope: ConflictScope): boolean {
|
|
1153
1532
|
const row = db
|
|
1154
1533
|
.query(
|
|
1155
1534
|
`
|
|
@@ -1158,10 +1537,12 @@ function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean
|
|
|
1158
1537
|
WHERE event_id = ?
|
|
1159
1538
|
AND field_name = '__delete__'
|
|
1160
1539
|
AND resolution = 'pending'
|
|
1540
|
+
AND worktree_path = ?
|
|
1541
|
+
AND current_branch = ?
|
|
1161
1542
|
LIMIT 1;
|
|
1162
1543
|
`,
|
|
1163
1544
|
)
|
|
1164
|
-
.get(sourceEventId);
|
|
1545
|
+
.get(sourceEventId, scope.worktreePath, scope.currentBranch);
|
|
1165
1546
|
|
|
1166
1547
|
return row !== null;
|
|
1167
1548
|
}
|
|
@@ -1171,23 +1552,36 @@ function pendingDeleteConflictSourceEventId(fields: Record<string, unknown>): st
|
|
|
1171
1552
|
return typeof sourceEventId === "string" && sourceEventId.length > 0 ? sourceEventId : null;
|
|
1172
1553
|
}
|
|
1173
1554
|
|
|
1174
|
-
function shouldWithholdDeleteCascadeEvent(
|
|
1555
|
+
function shouldWithholdDeleteCascadeEvent(
|
|
1556
|
+
db: Database,
|
|
1557
|
+
event: StoredEvent,
|
|
1558
|
+
fields: Record<string, unknown>,
|
|
1559
|
+
scope: ConflictScope,
|
|
1560
|
+
): boolean {
|
|
1175
1561
|
const sourceEventId = pendingDeleteConflictSourceEventId(fields);
|
|
1176
1562
|
if (!sourceEventId) {
|
|
1177
1563
|
return false;
|
|
1178
1564
|
}
|
|
1179
1565
|
|
|
1180
|
-
const isDeleteCascadeEvent =
|
|
1566
|
+
const isDeleteCascadeEvent =
|
|
1567
|
+
event.operation === "dependency.removed"
|
|
1568
|
+
|| event.operation === "subtask.deleted"
|
|
1569
|
+
|| event.operation === "task.deleted";
|
|
1181
1570
|
if (!isDeleteCascadeEvent) {
|
|
1182
1571
|
return false;
|
|
1183
1572
|
}
|
|
1184
1573
|
|
|
1185
|
-
return hasPendingDeleteConflict(db, sourceEventId);
|
|
1574
|
+
return hasPendingDeleteConflict(db, sourceEventId, scope);
|
|
1186
1575
|
}
|
|
1187
1576
|
|
|
1188
|
-
function applyEntityFields(
|
|
1577
|
+
function applyEntityFields(
|
|
1578
|
+
db: Database,
|
|
1579
|
+
event: StoredEvent,
|
|
1580
|
+
fields: Record<string, unknown>,
|
|
1581
|
+
scope: ConflictScope,
|
|
1582
|
+
): boolean {
|
|
1189
1583
|
if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
|
|
1190
|
-
return applyDelete(db, event, fields);
|
|
1584
|
+
return applyDelete(db, event, fields, scope);
|
|
1191
1585
|
}
|
|
1192
1586
|
|
|
1193
1587
|
if (event.operation.endsWith(".created") || event.operation === "dependency.added") {
|
|
@@ -1277,7 +1671,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
|
|
|
1277
1671
|
sourceBranch,
|
|
1278
1672
|
ahead: countAhead(storage.db, git.branchName, sourceBranch),
|
|
1279
1673
|
behind: onSourceBranch ? 0 : countBranchEventsSince(storage.db, sourceBranch, cursorToken),
|
|
1280
|
-
pendingConflicts: countPendingConflicts(storage.db),
|
|
1674
|
+
pendingConflicts: countPendingConflicts(storage.db, scopeFromGitContext(git)),
|
|
1281
1675
|
sameBranch: onSourceBranch,
|
|
1282
1676
|
git,
|
|
1283
1677
|
};
|
|
@@ -1303,32 +1697,44 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1303
1697
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
1304
1698
|
let scannedEvents = 0;
|
|
1305
1699
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
}
|
|
1700
|
+
// Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
|
|
1701
|
+
// events is committed in its own transaction so the write lock is
|
|
1702
|
+
// never held across multiple batches. On crash, the cursor reflects
|
|
1703
|
+
// the last fully-committed batch and the next pull resumes there.
|
|
1704
|
+
while (true) {
|
|
1705
|
+
const incomingEvents = queryBranchEventsSinceBatch(
|
|
1706
|
+
storage.db,
|
|
1707
|
+
sourceBranch,
|
|
1708
|
+
lastToken ?? cursorToken,
|
|
1709
|
+
SYNC_PULL_BATCH_SIZE,
|
|
1710
|
+
) as StoredEvent[];
|
|
1318
1711
|
|
|
1319
|
-
|
|
1712
|
+
if (incomingEvents.length === 0) {
|
|
1713
|
+
break;
|
|
1714
|
+
}
|
|
1320
1715
|
|
|
1716
|
+
const batchResult = writeTransaction(storage.db, (): { token: string | null; eventAt: number | null } => {
|
|
1717
|
+
let token: string | null = lastToken;
|
|
1718
|
+
let eventAt: number | null = lastEventAt;
|
|
1321
1719
|
for (const incoming of incomingEvents) {
|
|
1322
1720
|
storeEvent(storage.db, incoming);
|
|
1323
|
-
|
|
1324
|
-
|
|
1721
|
+
token = cursorTokenFromEvent(incoming);
|
|
1722
|
+
eventAt = incoming.created_at;
|
|
1325
1723
|
}
|
|
1326
|
-
|
|
1724
|
+
if (token) {
|
|
1725
|
+
saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
|
|
1726
|
+
}
|
|
1727
|
+
return { token, eventAt };
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
scannedEvents += incomingEvents.length;
|
|
1731
|
+
lastToken = batchResult.token;
|
|
1732
|
+
lastEventAt = batchResult.eventAt;
|
|
1327
1733
|
|
|
1328
|
-
if (
|
|
1329
|
-
|
|
1734
|
+
if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
|
|
1735
|
+
break;
|
|
1330
1736
|
}
|
|
1331
|
-
}
|
|
1737
|
+
}
|
|
1332
1738
|
|
|
1333
1739
|
return {
|
|
1334
1740
|
sourceBranch,
|
|
@@ -1360,71 +1766,106 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1360
1766
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
1361
1767
|
let scannedEvents = 0;
|
|
1362
1768
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1769
|
+
// Per-pull memoization for "ours" field-value lookups. Reused across
|
|
1770
|
+
// every incoming event so repeated probes of the same (entity, field)
|
|
1771
|
+
// are O(1) after first hit.
|
|
1772
|
+
const oursCache = createOursValueCache();
|
|
1773
|
+
|
|
1774
|
+
// Conflict scope: every conflict / cleanup created by this pull is
|
|
1775
|
+
// tagged with the current worktree+branch so peer worktrees observing
|
|
1776
|
+
// the same entity own their own row set and cannot erase each other.
|
|
1777
|
+
const conflictScope: ConflictScope = scopeFromGitContext(git);
|
|
1778
|
+
|
|
1779
|
+
// Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
|
|
1780
|
+
// events is processed inside its own writeTransaction. The cursor and
|
|
1781
|
+
// lastEventAt are persisted at the end of each batch, so a crash mid-
|
|
1782
|
+
// pull leaves a consistent cursor pointing at the last fully-committed
|
|
1783
|
+
// batch and the next pull resumes from there. The write lock is no
|
|
1784
|
+
// longer held across multiple batches.
|
|
1785
|
+
while (true) {
|
|
1786
|
+
const incomingEvents = queryBranchEventsSinceBatch(
|
|
1787
|
+
storage.db,
|
|
1788
|
+
sourceBranch,
|
|
1789
|
+
lastToken ?? cursorToken,
|
|
1790
|
+
SYNC_PULL_BATCH_SIZE,
|
|
1791
|
+
) as StoredEvent[];
|
|
1371
1792
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1793
|
+
if (incomingEvents.length === 0) {
|
|
1794
|
+
break;
|
|
1795
|
+
}
|
|
1375
1796
|
|
|
1376
|
-
|
|
1797
|
+
interface BatchResult {
|
|
1798
|
+
readonly appliedDelta: number;
|
|
1799
|
+
readonly createdConflictsDelta: number;
|
|
1800
|
+
readonly malformedPayloadDelta: number;
|
|
1801
|
+
readonly applyRejectedDelta: number;
|
|
1802
|
+
readonly quarantinedDelta: number;
|
|
1803
|
+
readonly conflictEventsDelta: number;
|
|
1804
|
+
readonly token: string | null;
|
|
1805
|
+
readonly eventAt: number | null;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
const batchResult: BatchResult = writeTransaction(storage.db, (): BatchResult => {
|
|
1809
|
+
let appliedDelta = 0;
|
|
1810
|
+
let createdConflictsDelta = 0;
|
|
1811
|
+
let malformedPayloadDelta = 0;
|
|
1812
|
+
let applyRejectedDelta = 0;
|
|
1813
|
+
let quarantinedDelta = 0;
|
|
1814
|
+
let conflictEventsDelta = 0;
|
|
1815
|
+
let token: string | null = lastToken;
|
|
1816
|
+
let eventAt: number | null = lastEventAt;
|
|
1377
1817
|
|
|
1378
1818
|
for (const incoming of incomingEvents) {
|
|
1379
1819
|
if (incoming.operation === "resolve_conflict") {
|
|
1380
|
-
if (applyIncomingResolutionEvent(storage.db, incoming)) {
|
|
1381
|
-
|
|
1820
|
+
if (applyIncomingResolutionEvent(storage.db, incoming, conflictScope)) {
|
|
1821
|
+
appliedDelta += 1;
|
|
1382
1822
|
}
|
|
1383
1823
|
storeEvent(storage.db, incoming);
|
|
1384
|
-
|
|
1385
|
-
|
|
1824
|
+
token = cursorTokenFromEvent(incoming);
|
|
1825
|
+
eventAt = incoming.created_at;
|
|
1386
1826
|
continue;
|
|
1387
1827
|
}
|
|
1388
1828
|
|
|
1389
1829
|
const payloadValidation = parsePayload(incoming.payload);
|
|
1390
1830
|
|
|
1391
1831
|
if (!payloadValidation.ok) {
|
|
1392
|
-
|
|
1393
|
-
|
|
1832
|
+
malformedPayloadDelta += 1;
|
|
1833
|
+
quarantinedDelta += 1;
|
|
1394
1834
|
createConflict(
|
|
1395
1835
|
storage.db,
|
|
1396
1836
|
incoming,
|
|
1397
1837
|
"__payload__",
|
|
1398
1838
|
null,
|
|
1399
1839
|
payloadValidation.reason ?? "Invalid payload",
|
|
1840
|
+
conflictScope,
|
|
1400
1841
|
"invalid",
|
|
1401
1842
|
);
|
|
1402
|
-
|
|
1843
|
+
createdConflictsDelta += 1;
|
|
1403
1844
|
storeEvent(storage.db, incoming);
|
|
1404
|
-
|
|
1405
|
-
|
|
1845
|
+
token = cursorTokenFromEvent(incoming);
|
|
1846
|
+
eventAt = incoming.created_at;
|
|
1406
1847
|
continue;
|
|
1407
1848
|
}
|
|
1408
1849
|
|
|
1409
1850
|
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
1410
1851
|
|
|
1411
|
-
if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields)) {
|
|
1852
|
+
if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields, conflictScope)) {
|
|
1412
1853
|
storeEvent(storage.db, incoming);
|
|
1413
|
-
|
|
1414
|
-
|
|
1854
|
+
token = cursorTokenFromEvent(incoming);
|
|
1855
|
+
eventAt = incoming.created_at;
|
|
1415
1856
|
continue;
|
|
1416
1857
|
}
|
|
1417
1858
|
|
|
1418
1859
|
const isDeleteWithLocalEdits =
|
|
1419
|
-
(incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming,
|
|
1420
|
-
(incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming,
|
|
1860
|
+
(incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, git.branchName)) ||
|
|
1861
|
+
(incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, git.branchName));
|
|
1421
1862
|
if (isDeleteWithLocalEdits) {
|
|
1422
|
-
createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
|
|
1423
|
-
|
|
1424
|
-
|
|
1863
|
+
createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch", conflictScope);
|
|
1864
|
+
createdConflictsDelta += 1;
|
|
1865
|
+
conflictEventsDelta += 1;
|
|
1425
1866
|
storeEvent(storage.db, incoming);
|
|
1426
|
-
|
|
1427
|
-
|
|
1867
|
+
token = cursorTokenFromEvent(incoming);
|
|
1868
|
+
eventAt = incoming.created_at;
|
|
1428
1869
|
continue;
|
|
1429
1870
|
}
|
|
1430
1871
|
|
|
@@ -1437,47 +1878,88 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1437
1878
|
continue;
|
|
1438
1879
|
}
|
|
1439
1880
|
|
|
1440
|
-
const conflict = entityFieldConflict(
|
|
1881
|
+
const conflict = entityFieldConflict(
|
|
1882
|
+
storage.db,
|
|
1883
|
+
git.branchName,
|
|
1884
|
+
sourceBranch,
|
|
1885
|
+
incoming,
|
|
1886
|
+
fieldName,
|
|
1887
|
+
value,
|
|
1888
|
+
oursCache,
|
|
1889
|
+
);
|
|
1441
1890
|
|
|
1442
1891
|
if (conflict) {
|
|
1443
1892
|
withheldConflictCount += 1;
|
|
1444
|
-
|
|
1445
|
-
createConflict(
|
|
1446
|
-
|
|
1893
|
+
conflictEventsDelta += 1;
|
|
1894
|
+
createConflict(
|
|
1895
|
+
storage.db,
|
|
1896
|
+
incoming,
|
|
1897
|
+
fieldName,
|
|
1898
|
+
conflict.oursValue,
|
|
1899
|
+
conflict.theirsValue,
|
|
1900
|
+
conflictScope,
|
|
1901
|
+
);
|
|
1902
|
+
createdConflictsDelta += 1;
|
|
1447
1903
|
continue;
|
|
1448
1904
|
}
|
|
1449
1905
|
|
|
1450
1906
|
fieldsToApply[fieldName] = value;
|
|
1451
1907
|
}
|
|
1452
1908
|
|
|
1453
|
-
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
1454
|
-
|
|
1909
|
+
if (applyEntityFields(storage.db, incoming, fieldsToApply, conflictScope)) {
|
|
1910
|
+
appliedDelta += 1;
|
|
1455
1911
|
} else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
|
|
1456
|
-
|
|
1912
|
+
appliedDelta += 1;
|
|
1457
1913
|
} else {
|
|
1458
|
-
|
|
1459
|
-
|
|
1914
|
+
applyRejectedDelta += 1;
|
|
1915
|
+
quarantinedDelta += 1;
|
|
1460
1916
|
createConflict(
|
|
1461
1917
|
storage.db,
|
|
1462
1918
|
incoming,
|
|
1463
1919
|
"__apply__",
|
|
1464
1920
|
null,
|
|
1465
1921
|
`Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
|
|
1922
|
+
conflictScope,
|
|
1466
1923
|
"invalid",
|
|
1467
1924
|
);
|
|
1468
|
-
|
|
1925
|
+
createdConflictsDelta += 1;
|
|
1469
1926
|
}
|
|
1470
1927
|
|
|
1471
1928
|
storeEvent(storage.db, incoming);
|
|
1472
|
-
|
|
1473
|
-
|
|
1929
|
+
token = cursorTokenFromEvent(incoming);
|
|
1930
|
+
eventAt = incoming.created_at;
|
|
1474
1931
|
}
|
|
1475
|
-
}
|
|
1476
1932
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1933
|
+
if (token) {
|
|
1934
|
+
saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
return {
|
|
1938
|
+
appliedDelta,
|
|
1939
|
+
createdConflictsDelta,
|
|
1940
|
+
malformedPayloadDelta,
|
|
1941
|
+
applyRejectedDelta,
|
|
1942
|
+
quarantinedDelta,
|
|
1943
|
+
conflictEventsDelta,
|
|
1944
|
+
token,
|
|
1945
|
+
eventAt,
|
|
1946
|
+
};
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
scannedEvents += incomingEvents.length;
|
|
1950
|
+
appliedEvents += batchResult.appliedDelta;
|
|
1951
|
+
createdConflicts += batchResult.createdConflictsDelta;
|
|
1952
|
+
malformedPayloadEvents += batchResult.malformedPayloadDelta;
|
|
1953
|
+
applyRejectedEvents += batchResult.applyRejectedDelta;
|
|
1954
|
+
quarantinedEvents += batchResult.quarantinedDelta;
|
|
1955
|
+
conflictEvents += batchResult.conflictEventsDelta;
|
|
1956
|
+
lastToken = batchResult.token;
|
|
1957
|
+
lastEventAt = batchResult.eventAt;
|
|
1958
|
+
|
|
1959
|
+
if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
|
|
1960
|
+
break;
|
|
1479
1961
|
}
|
|
1480
|
-
}
|
|
1962
|
+
}
|
|
1481
1963
|
|
|
1482
1964
|
const errorHints: string[] = buildSyncErrorHints({
|
|
1483
1965
|
malformedPayloadEvents,
|
|
@@ -1651,6 +2133,12 @@ function appendResolutionEvent(
|
|
|
1651
2133
|
field: conflict.field_name,
|
|
1652
2134
|
resolution,
|
|
1653
2135
|
value: resolvedValue,
|
|
2136
|
+
// Embed the resolved row's scope so receivers can audit which
|
|
2137
|
+
// worktree/branch performed the resolution. Receivers still
|
|
2138
|
+
// intersect against their OWN active scope when looking up the
|
|
2139
|
+
// local row to mutate.
|
|
2140
|
+
worktree_path: conflict.worktree_path,
|
|
2141
|
+
current_branch: conflict.current_branch,
|
|
1654
2142
|
}),
|
|
1655
2143
|
gitBranch,
|
|
1656
2144
|
gitHead,
|
|
@@ -1661,19 +2149,30 @@ function appendResolutionEvent(
|
|
|
1661
2149
|
|
|
1662
2150
|
export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConflictListItem[] {
|
|
1663
2151
|
const storage = openTrekoonDatabase(cwd);
|
|
2152
|
+
const git = resolveGitContext(cwd);
|
|
2153
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1664
2154
|
|
|
1665
2155
|
try {
|
|
1666
|
-
|
|
2156
|
+
// Conflicts are scoped to the worktree+branch that recorded them. Each
|
|
2157
|
+
// worktree only sees its own pending/resolved conflicts so peer
|
|
2158
|
+
// worktrees on the same shared DB don't bleed into one another.
|
|
2159
|
+
const conditions: string[] = ["worktree_path = ?", "current_branch = ?"];
|
|
2160
|
+
const params: string[] = [scope.worktreePath, scope.currentBranch];
|
|
2161
|
+
|
|
2162
|
+
if (mode === "pending") {
|
|
2163
|
+
conditions.push("resolution = 'pending'");
|
|
2164
|
+
}
|
|
2165
|
+
|
|
1667
2166
|
return storage.db
|
|
1668
2167
|
.query(
|
|
1669
2168
|
`
|
|
1670
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2169
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1671
2170
|
FROM sync_conflicts
|
|
1672
|
-
${
|
|
2171
|
+
WHERE ${conditions.join(" AND ")}
|
|
1673
2172
|
ORDER BY created_at ASC;
|
|
1674
2173
|
`,
|
|
1675
2174
|
)
|
|
1676
|
-
.all() as SyncConflictListItem[];
|
|
2175
|
+
.all(...params) as SyncConflictListItem[];
|
|
1677
2176
|
} finally {
|
|
1678
2177
|
storage.close();
|
|
1679
2178
|
}
|
|
@@ -1681,18 +2180,22 @@ export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConf
|
|
|
1681
2180
|
|
|
1682
2181
|
export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDetail {
|
|
1683
2182
|
const storage = openTrekoonDatabase(cwd);
|
|
2183
|
+
const git = resolveGitContext(cwd);
|
|
2184
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1684
2185
|
|
|
1685
2186
|
try {
|
|
1686
2187
|
const conflict = storage.db
|
|
1687
2188
|
.query(
|
|
1688
2189
|
`
|
|
1689
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2190
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1690
2191
|
FROM sync_conflicts
|
|
1691
2192
|
WHERE id = ?
|
|
2193
|
+
AND worktree_path = ?
|
|
2194
|
+
AND current_branch = ?
|
|
1692
2195
|
LIMIT 1;
|
|
1693
2196
|
`,
|
|
1694
2197
|
)
|
|
1695
|
-
.get(conflictId) as ConflictRow | null;
|
|
2198
|
+
.get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
|
|
1696
2199
|
|
|
1697
2200
|
if (!conflict) {
|
|
1698
2201
|
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
@@ -1736,17 +2239,19 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
|
|
|
1736
2239
|
}
|
|
1737
2240
|
}
|
|
1738
2241
|
|
|
1739
|
-
function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
|
|
2242
|
+
function lookupPendingConflict(db: Database, conflictId: string, scope: ConflictScope): ConflictRow {
|
|
1740
2243
|
const conflict = db
|
|
1741
2244
|
.query(
|
|
1742
2245
|
`
|
|
1743
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2246
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1744
2247
|
FROM sync_conflicts
|
|
1745
2248
|
WHERE id = ?
|
|
2249
|
+
AND worktree_path = ?
|
|
2250
|
+
AND current_branch = ?
|
|
1746
2251
|
LIMIT 1;
|
|
1747
2252
|
`,
|
|
1748
2253
|
)
|
|
1749
|
-
.get(conflictId) as ConflictRow | null;
|
|
2254
|
+
.get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
|
|
1750
2255
|
|
|
1751
2256
|
if (!conflict) {
|
|
1752
2257
|
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
@@ -1762,6 +2267,7 @@ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
|
|
|
1762
2267
|
export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
|
|
1763
2268
|
const storage = openTrekoonDatabase(cwd);
|
|
1764
2269
|
const git = resolveGitContext(cwd);
|
|
2270
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1765
2271
|
|
|
1766
2272
|
try {
|
|
1767
2273
|
persistGitContext(storage.db, git);
|
|
@@ -1771,7 +2277,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
1771
2277
|
// atomic. Without this, two concurrent resolves could both pass
|
|
1772
2278
|
// the check and double-resolve the same conflict.
|
|
1773
2279
|
const conflict = writeTransaction(storage.db, (): ConflictRow => {
|
|
1774
|
-
const row = lookupPendingConflict(storage.db, conflictId);
|
|
2280
|
+
const row = lookupPendingConflict(storage.db, conflictId, scope);
|
|
1775
2281
|
resolveConflictRow(storage.db, row, resolution, git);
|
|
1776
2282
|
return row;
|
|
1777
2283
|
});
|
|
@@ -1791,9 +2297,11 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
1791
2297
|
// Preview is read-only — no git context persistence needed.
|
|
1792
2298
|
export function syncResolvePreview(cwd: string, conflictId: string, resolution: SyncResolution): ResolvePreviewSummary {
|
|
1793
2299
|
const storage = openTrekoonDatabase(cwd);
|
|
2300
|
+
const git = resolveGitContext(cwd);
|
|
2301
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1794
2302
|
|
|
1795
2303
|
try {
|
|
1796
|
-
const conflict = lookupPendingConflict(storage.db, conflictId);
|
|
2304
|
+
const conflict = lookupPendingConflict(storage.db, conflictId, scope);
|
|
1797
2305
|
|
|
1798
2306
|
const oursValue: unknown = parseConflictValue(conflict.ours_value);
|
|
1799
2307
|
const theirsValue: unknown = parseConflictValue(conflict.theirs_value);
|
|
@@ -1818,17 +2326,22 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
|
|
|
1818
2326
|
function queryPendingConflictIds(
|
|
1819
2327
|
db: Database,
|
|
1820
2328
|
filters: ResolveAllQueryFilters,
|
|
2329
|
+
scope: ConflictScope,
|
|
1821
2330
|
): readonly string[] {
|
|
1822
|
-
const conditions: string[] = [
|
|
1823
|
-
|
|
2331
|
+
const conditions: string[] = [
|
|
2332
|
+
"c.resolution = 'pending'",
|
|
2333
|
+
"c.worktree_path = ?",
|
|
2334
|
+
"c.current_branch = ?",
|
|
2335
|
+
];
|
|
2336
|
+
const params: string[] = [scope.worktreePath, scope.currentBranch];
|
|
1824
2337
|
|
|
1825
2338
|
if (filters.entityId !== undefined) {
|
|
1826
|
-
conditions.push("entity_id = ?");
|
|
2339
|
+
conditions.push("c.entity_id = ?");
|
|
1827
2340
|
params.push(filters.entityId);
|
|
1828
2341
|
}
|
|
1829
2342
|
|
|
1830
2343
|
if (filters.fieldName !== undefined) {
|
|
1831
|
-
conditions.push("field_name = ?");
|
|
2344
|
+
conditions.push("c.field_name = ?");
|
|
1832
2345
|
params.push(filters.fieldName);
|
|
1833
2346
|
}
|
|
1834
2347
|
|
|
@@ -1836,14 +2349,18 @@ function queryPendingConflictIds(
|
|
|
1836
2349
|
SELECT c.id
|
|
1837
2350
|
FROM sync_conflicts c
|
|
1838
2351
|
LEFT JOIN events e ON e.id = c.event_id
|
|
1839
|
-
WHERE ${conditions.
|
|
2352
|
+
WHERE ${conditions.join(" AND ")}
|
|
1840
2353
|
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
2354
|
`;
|
|
1842
2355
|
|
|
1843
2356
|
return (db.query(sql).all(...params) as ConflictOrderRow[]).map((row) => row.id);
|
|
1844
2357
|
}
|
|
1845
2358
|
|
|
1846
|
-
function queryPendingConflictsByIds(
|
|
2359
|
+
function queryPendingConflictsByIds(
|
|
2360
|
+
db: Database,
|
|
2361
|
+
conflictIds: readonly string[],
|
|
2362
|
+
scope: ConflictScope,
|
|
2363
|
+
): readonly ConflictRow[] {
|
|
1847
2364
|
if (conflictIds.length === 0) {
|
|
1848
2365
|
return [];
|
|
1849
2366
|
}
|
|
@@ -1852,12 +2369,15 @@ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]
|
|
|
1852
2369
|
const rows = db
|
|
1853
2370
|
.query(
|
|
1854
2371
|
`
|
|
1855
|
-
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
2372
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
1856
2373
|
FROM sync_conflicts
|
|
1857
|
-
WHERE resolution = 'pending'
|
|
2374
|
+
WHERE resolution = 'pending'
|
|
2375
|
+
AND id IN (${placeholders})
|
|
2376
|
+
AND worktree_path = ?
|
|
2377
|
+
AND current_branch = ?;
|
|
1858
2378
|
`,
|
|
1859
2379
|
)
|
|
1860
|
-
.all(...conflictIds) as ConflictRow[];
|
|
2380
|
+
.all(...conflictIds, scope.worktreePath, scope.currentBranch) as ConflictRow[];
|
|
1861
2381
|
|
|
1862
2382
|
const rowById = new Map(rows.map((row) => [row.id, row]));
|
|
1863
2383
|
|
|
@@ -1880,9 +2400,10 @@ export function syncResolveAll(
|
|
|
1880
2400
|
try {
|
|
1881
2401
|
persistGitContext(storage.db, git);
|
|
1882
2402
|
|
|
2403
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1883
2404
|
const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
|
|
1884
2405
|
const expectedConflictIds = options.expectedConflictIds;
|
|
1885
|
-
const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
|
|
2406
|
+
const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters, scope);
|
|
1886
2407
|
|
|
1887
2408
|
if (orderedConflictIds.length === 0) {
|
|
1888
2409
|
throw new DomainError({
|
|
@@ -1896,7 +2417,7 @@ export function syncResolveAll(
|
|
|
1896
2417
|
|
|
1897
2418
|
for (let offset = 0; offset < orderedConflictIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
|
|
1898
2419
|
const chunkIds = orderedConflictIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
|
|
1899
|
-
const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds);
|
|
2420
|
+
const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds, scope);
|
|
1900
2421
|
|
|
1901
2422
|
if (chunkConflicts.length !== chunkIds.length) {
|
|
1902
2423
|
throw new DomainError({
|
|
@@ -1936,10 +2457,12 @@ export function syncResolveAllPreview(
|
|
|
1936
2457
|
filters: ResolveAllQueryFilters,
|
|
1937
2458
|
): ResolveAllPreviewSummary {
|
|
1938
2459
|
const storage = openTrekoonDatabase(cwd);
|
|
2460
|
+
const git = resolveGitContext(cwd);
|
|
2461
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
1939
2462
|
const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
|
|
1940
2463
|
|
|
1941
2464
|
try {
|
|
1942
|
-
const conflictIds = queryPendingConflictIds(storage.db, filters);
|
|
2465
|
+
const conflictIds = queryPendingConflictIds(storage.db, filters, scope);
|
|
1943
2466
|
|
|
1944
2467
|
if (conflictIds.length === 0) {
|
|
1945
2468
|
throw new DomainError({
|