trekoon 0.2.0 → 0.2.4
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 +232 -297
- package/README.md +288 -16
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +116 -0
- package/src/commands/dep.ts +197 -25
- package/src/commands/epic.ts +490 -28
- package/src/commands/error-utils.ts +111 -0
- package/src/commands/events.ts +23 -3
- package/src/commands/help.ts +83 -17
- package/src/commands/init.ts +115 -9
- package/src/commands/migrate.ts +11 -4
- package/src/commands/quickstart.ts +76 -30
- package/src/commands/session.ts +223 -0
- package/src/commands/skills.ts +100 -63
- package/src/commands/subtask.ts +224 -26
- package/src/commands/sync.ts +64 -17
- package/src/commands/task-readiness.ts +147 -0
- package/src/commands/task.ts +277 -168
- package/src/commands/wipe.ts +15 -5
- package/src/domain/mutation-service.ts +152 -0
- package/src/domain/tracker-domain.ts +503 -0
- package/src/domain/types.ts +80 -0
- package/src/runtime/cli-shell.ts +83 -5
- package/src/storage/database.ts +86 -0
- package/src/storage/migrations.ts +48 -0
- package/src/storage/path.ts +70 -21
- package/src/storage/schema.ts +9 -2
- package/src/storage/worktree-recovery.ts +376 -0
- package/src/sync/branch-db.ts +87 -35
- package/src/sync/git-context.ts +7 -2
- package/src/sync/service.ts +131 -95
- package/src/sync/types.ts +2 -0
package/src/sync/service.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { type Database } from "bun:sqlite";
|
|
4
4
|
|
|
5
5
|
import { openTrekoonDatabase } from "../storage/database";
|
|
6
|
-
import {
|
|
6
|
+
import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
|
|
7
7
|
import { persistGitContext, resolveGitContext } from "./git-context";
|
|
8
8
|
import {
|
|
9
9
|
type PullSummary,
|
|
@@ -29,6 +29,8 @@ interface StoredEvent {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
interface CursorRow {
|
|
32
|
+
readonly owner_scope: string;
|
|
33
|
+
readonly owner_worktree_path: string;
|
|
32
34
|
readonly source_branch: string;
|
|
33
35
|
readonly cursor_token: string;
|
|
34
36
|
readonly last_event_at: number | null;
|
|
@@ -116,40 +118,45 @@ function tableForEntityKind(entityKind: string): "epics" | "tasks" | "subtasks"
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
function parseCursorToken(token: string): { createdAt: number; id: string | null } {
|
|
120
|
-
const [createdAtRaw, idRaw] = token.split(":");
|
|
121
|
-
const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
createdAt: Number.isFinite(createdAt) ? createdAt : 0,
|
|
125
|
-
id: idRaw && idRaw.length > 0 ? idRaw : null,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
121
|
function cursorTokenFromEvent(event: StoredEvent): string {
|
|
130
122
|
return `${event.created_at}:${event.id}`;
|
|
131
123
|
}
|
|
132
124
|
|
|
133
|
-
function
|
|
125
|
+
function cursorIdForWorktree(worktreePath: string, sourceBranch: string): string {
|
|
126
|
+
return `${worktreePath}::${sourceBranch}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function loadCursor(db: Database, worktreePath: string, sourceBranch: string): CursorRow | null {
|
|
134
130
|
return db
|
|
135
131
|
.query(
|
|
136
132
|
`
|
|
137
|
-
SELECT source_branch, cursor_token, last_event_at
|
|
133
|
+
SELECT owner_scope, owner_worktree_path, source_branch, cursor_token, last_event_at
|
|
138
134
|
FROM sync_cursors
|
|
139
|
-
WHERE
|
|
135
|
+
WHERE owner_scope = 'worktree'
|
|
136
|
+
AND owner_worktree_path = ?
|
|
137
|
+
AND source_branch = ?
|
|
140
138
|
LIMIT 1;
|
|
141
139
|
`,
|
|
142
140
|
)
|
|
143
|
-
.get(sourceBranch) as CursorRow | null;
|
|
141
|
+
.get(worktreePath, sourceBranch) as CursorRow | null;
|
|
144
142
|
}
|
|
145
143
|
|
|
146
|
-
function saveCursor(
|
|
144
|
+
function saveCursor(
|
|
145
|
+
db: Database,
|
|
146
|
+
worktreePath: string,
|
|
147
|
+
sourceBranch: string,
|
|
148
|
+
cursorToken: string,
|
|
149
|
+
lastEventAt: number | null,
|
|
150
|
+
): void {
|
|
147
151
|
const now: number = Date.now();
|
|
152
|
+
const cursorId = cursorIdForWorktree(worktreePath, sourceBranch);
|
|
148
153
|
|
|
149
154
|
db.query(
|
|
150
155
|
`
|
|
151
156
|
INSERT INTO sync_cursors (
|
|
152
157
|
id,
|
|
158
|
+
owner_scope,
|
|
159
|
+
owner_worktree_path,
|
|
153
160
|
source_branch,
|
|
154
161
|
cursor_token,
|
|
155
162
|
last_event_at,
|
|
@@ -157,7 +164,9 @@ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, las
|
|
|
157
164
|
updated_at,
|
|
158
165
|
version
|
|
159
166
|
) VALUES (
|
|
160
|
-
@
|
|
167
|
+
@cursorId,
|
|
168
|
+
'worktree',
|
|
169
|
+
@worktreePath,
|
|
161
170
|
@sourceBranch,
|
|
162
171
|
@cursorToken,
|
|
163
172
|
@lastEventAt,
|
|
@@ -166,12 +175,16 @@ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, las
|
|
|
166
175
|
1
|
|
167
176
|
)
|
|
168
177
|
ON CONFLICT(id) DO UPDATE SET
|
|
178
|
+
owner_scope = excluded.owner_scope,
|
|
179
|
+
owner_worktree_path = excluded.owner_worktree_path,
|
|
169
180
|
cursor_token = excluded.cursor_token,
|
|
170
181
|
last_event_at = excluded.last_event_at,
|
|
171
182
|
updated_at = excluded.updated_at,
|
|
172
183
|
version = sync_cursors.version + 1;
|
|
173
184
|
`,
|
|
174
185
|
).run({
|
|
186
|
+
"@cursorId": cursorId,
|
|
187
|
+
"@worktreePath": worktreePath,
|
|
175
188
|
"@sourceBranch": sourceBranch,
|
|
176
189
|
"@cursorToken": cursorToken,
|
|
177
190
|
"@lastEventAt": lastEventAt,
|
|
@@ -179,25 +192,6 @@ function saveCursor(db: Database, sourceBranch: string, cursorToken: string, las
|
|
|
179
192
|
});
|
|
180
193
|
}
|
|
181
194
|
|
|
182
|
-
function queryNewEvents(remoteDb: Database, cursorToken: string): StoredEvent[] {
|
|
183
|
-
const cursor = parseCursorToken(cursorToken);
|
|
184
|
-
|
|
185
|
-
return remoteDb
|
|
186
|
-
.query(
|
|
187
|
-
`
|
|
188
|
-
SELECT id, entity_kind, entity_id, operation, payload, git_branch, git_head, created_at, updated_at, version
|
|
189
|
-
FROM events
|
|
190
|
-
WHERE created_at > @createdAt
|
|
191
|
-
OR (created_at = @createdAt AND id > @id)
|
|
192
|
-
ORDER BY created_at ASC, id ASC;
|
|
193
|
-
`,
|
|
194
|
-
)
|
|
195
|
-
.all({
|
|
196
|
-
"@createdAt": cursor.createdAt,
|
|
197
|
-
"@id": cursor.id ?? "",
|
|
198
|
-
}) as StoredEvent[];
|
|
199
|
-
}
|
|
200
|
-
|
|
201
195
|
function countPendingConflicts(db: Database): number {
|
|
202
196
|
const row = db
|
|
203
197
|
.query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
|
|
@@ -206,54 +200,24 @@ function countPendingConflicts(db: Database): number {
|
|
|
206
200
|
return row?.count ?? 0;
|
|
207
201
|
}
|
|
208
202
|
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
203
|
+
function countAhead(localDb: Database, currentBranch: string | null, sourceBranch: string): number {
|
|
204
|
+
if (!currentBranch || currentBranch === sourceBranch) {
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const row = localDb
|
|
212
209
|
.query(
|
|
213
210
|
`
|
|
214
211
|
SELECT COUNT(*) AS count
|
|
215
212
|
FROM events
|
|
216
|
-
WHERE
|
|
217
|
-
OR (created_at = @createdAt AND id > @id);
|
|
213
|
+
WHERE git_branch = @branch;
|
|
218
214
|
`,
|
|
219
215
|
)
|
|
220
|
-
.get({
|
|
221
|
-
"@createdAt": cursor.createdAt,
|
|
222
|
-
"@id": cursor.id ?? "",
|
|
223
|
-
}) as { count: number } | null;
|
|
216
|
+
.get({ "@branch": currentBranch }) as { count: number } | null;
|
|
224
217
|
|
|
225
218
|
return row?.count ?? 0;
|
|
226
219
|
}
|
|
227
220
|
|
|
228
|
-
function countAhead(localDb: Database, currentBranch: string | null, remoteDbPath: string): number {
|
|
229
|
-
if (!currentBranch) {
|
|
230
|
-
return 0;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
localDb.query("ATTACH DATABASE ? AS sync_remote;").run(remoteDbPath);
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
const row = localDb
|
|
237
|
-
.query(
|
|
238
|
-
`
|
|
239
|
-
SELECT COUNT(*) AS count
|
|
240
|
-
FROM events AS local_events
|
|
241
|
-
WHERE local_events.git_branch = @branch
|
|
242
|
-
AND NOT EXISTS (
|
|
243
|
-
SELECT 1
|
|
244
|
-
FROM sync_remote.events AS remote_events
|
|
245
|
-
WHERE remote_events.id = local_events.id
|
|
246
|
-
);
|
|
247
|
-
`,
|
|
248
|
-
)
|
|
249
|
-
.get({ "@branch": currentBranch }) as { count: number } | null;
|
|
250
|
-
|
|
251
|
-
return row?.count ?? 0;
|
|
252
|
-
} finally {
|
|
253
|
-
localDb.query("DETACH DATABASE sync_remote;").run();
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
221
|
function buildSyncErrorHints(diagnostics: {
|
|
258
222
|
malformedPayloadEvents: number;
|
|
259
223
|
applyRejectedEvents: number;
|
|
@@ -401,6 +365,18 @@ function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
|
401
365
|
return row !== null;
|
|
402
366
|
}
|
|
403
367
|
|
|
368
|
+
function dependencyNodeExists(db: Database, nodeKind: string, nodeId: string): boolean {
|
|
369
|
+
if (nodeKind === "task") {
|
|
370
|
+
return rowExists(db, "tasks", nodeId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (nodeKind === "subtask") {
|
|
374
|
+
return rowExists(db, "subtasks", nodeId);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
404
380
|
function validateRequiredStringField(fields: Record<string, unknown>, fieldName: string): string | null {
|
|
405
381
|
const value: unknown = fields[fieldName];
|
|
406
382
|
if (typeof value !== "string" || value.length === 0) {
|
|
@@ -503,6 +479,10 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
503
479
|
return false;
|
|
504
480
|
}
|
|
505
481
|
|
|
482
|
+
if (!dependencyNodeExists(db, sourceKind, sourceId) || !dependencyNodeExists(db, dependsOnKind, dependsOnId)) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
506
486
|
db.query(
|
|
507
487
|
`
|
|
508
488
|
INSERT INTO dependencies (
|
|
@@ -625,6 +605,28 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
625
605
|
return false;
|
|
626
606
|
}
|
|
627
607
|
|
|
608
|
+
function applyReplayedCreateWithConflicts(
|
|
609
|
+
db: Database,
|
|
610
|
+
event: StoredEvent,
|
|
611
|
+
fields: Record<string, unknown>,
|
|
612
|
+
withheldConflictCount: number,
|
|
613
|
+
): boolean {
|
|
614
|
+
if (withheldConflictCount === 0 || !event.operation.endsWith(".created")) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
619
|
+
if (!tableName || !rowExists(db, tableName, event.entity_id)) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (Object.keys(fields).length === 0) {
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return applyUpdatePatch(db, event, fields);
|
|
628
|
+
}
|
|
629
|
+
|
|
628
630
|
function storeEvent(db: Database, event: StoredEvent): void {
|
|
629
631
|
db.query(
|
|
630
632
|
`
|
|
@@ -662,21 +664,18 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
|
|
|
662
664
|
try {
|
|
663
665
|
persistGitContext(storage.db, git);
|
|
664
666
|
|
|
665
|
-
const cursor = loadCursor(storage.db, sourceBranch);
|
|
667
|
+
const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
|
|
666
668
|
const cursorToken: string = cursor?.cursor_token ?? "0:";
|
|
667
|
-
const
|
|
669
|
+
const onSourceBranch: boolean = git.branchName !== null && git.branchName === sourceBranch;
|
|
668
670
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
} finally {
|
|
678
|
-
remote.close();
|
|
679
|
-
}
|
|
671
|
+
return {
|
|
672
|
+
sourceBranch,
|
|
673
|
+
ahead: countAhead(storage.db, git.branchName, sourceBranch),
|
|
674
|
+
behind: onSourceBranch ? 0 : countBranchEventsSince(storage.db, sourceBranch, cursorToken),
|
|
675
|
+
pendingConflicts: countPendingConflicts(storage.db),
|
|
676
|
+
sameBranch: onSourceBranch,
|
|
677
|
+
git,
|
|
678
|
+
};
|
|
680
679
|
} finally {
|
|
681
680
|
storage.close();
|
|
682
681
|
}
|
|
@@ -684,15 +683,48 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
|
|
|
684
683
|
|
|
685
684
|
export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
686
685
|
const storage = openTrekoonDatabase(cwd);
|
|
687
|
-
const git = resolveGitContext(cwd);
|
|
688
|
-
persistGitContext(storage.db, git);
|
|
689
|
-
|
|
690
|
-
const remote = openBranchDatabaseSnapshot(sourceBranch, cwd);
|
|
691
686
|
|
|
692
687
|
try {
|
|
693
|
-
const
|
|
688
|
+
const git = resolveGitContext(cwd);
|
|
689
|
+
persistGitContext(storage.db, git);
|
|
690
|
+
const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
|
|
694
691
|
const cursorToken = cursor?.cursor_token ?? "0:";
|
|
695
|
-
const incomingEvents: StoredEvent[] =
|
|
692
|
+
const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
|
|
693
|
+
|
|
694
|
+
// Same-branch fast path: skip conflict detection when already on sourceBranch.
|
|
695
|
+
// Null branchName (detached HEAD) falls through to the normal path.
|
|
696
|
+
if (git.branchName !== null && git.branchName === sourceBranch) {
|
|
697
|
+
let lastToken: string | null = null;
|
|
698
|
+
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
699
|
+
|
|
700
|
+
storage.db.transaction((): void => {
|
|
701
|
+
for (const incoming of incomingEvents) {
|
|
702
|
+
storeEvent(storage.db, incoming);
|
|
703
|
+
lastToken = cursorTokenFromEvent(incoming);
|
|
704
|
+
lastEventAt = incoming.created_at;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (lastToken) {
|
|
708
|
+
saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
|
|
709
|
+
}
|
|
710
|
+
})();
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
sourceBranch,
|
|
714
|
+
scannedEvents: incomingEvents.length,
|
|
715
|
+
appliedEvents: 0,
|
|
716
|
+
createdConflicts: 0,
|
|
717
|
+
cursorToken: lastToken,
|
|
718
|
+
sameBranch: true,
|
|
719
|
+
diagnostics: {
|
|
720
|
+
malformedPayloadEvents: 0,
|
|
721
|
+
applyRejectedEvents: 0,
|
|
722
|
+
quarantinedEvents: 0,
|
|
723
|
+
conflictEvents: 0,
|
|
724
|
+
errorHints: [],
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
}
|
|
696
728
|
|
|
697
729
|
let appliedEvents = 0;
|
|
698
730
|
let createdConflicts = 0;
|
|
@@ -727,11 +759,13 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
727
759
|
|
|
728
760
|
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
729
761
|
const fieldsToApply: Record<string, unknown> = {};
|
|
762
|
+
let withheldConflictCount = 0;
|
|
730
763
|
|
|
731
764
|
for (const [fieldName, value] of Object.entries(payload.fields)) {
|
|
732
765
|
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
733
766
|
|
|
734
767
|
if (conflict) {
|
|
768
|
+
withheldConflictCount += 1;
|
|
735
769
|
conflictEvents += 1;
|
|
736
770
|
createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
|
|
737
771
|
createdConflicts += 1;
|
|
@@ -743,6 +777,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
743
777
|
|
|
744
778
|
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
745
779
|
appliedEvents += 1;
|
|
780
|
+
} else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
|
|
781
|
+
appliedEvents += 1;
|
|
746
782
|
} else if (incoming.operation !== "resolve_conflict") {
|
|
747
783
|
applyRejectedEvents += 1;
|
|
748
784
|
quarantinedEvents += 1;
|
|
@@ -763,7 +799,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
763
799
|
}
|
|
764
800
|
|
|
765
801
|
if (lastToken) {
|
|
766
|
-
saveCursor(storage.db, sourceBranch, lastToken, lastEventAt);
|
|
802
|
+
saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
|
|
767
803
|
}
|
|
768
804
|
})();
|
|
769
805
|
|
|
@@ -773,6 +809,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
773
809
|
appliedEvents,
|
|
774
810
|
createdConflicts,
|
|
775
811
|
cursorToken: lastToken,
|
|
812
|
+
sameBranch: false,
|
|
776
813
|
diagnostics: {
|
|
777
814
|
malformedPayloadEvents,
|
|
778
815
|
applyRejectedEvents,
|
|
@@ -786,7 +823,6 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
786
823
|
},
|
|
787
824
|
};
|
|
788
825
|
} finally {
|
|
789
|
-
remote.close();
|
|
790
826
|
storage.close();
|
|
791
827
|
}
|
|
792
828
|
}
|
package/src/sync/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface SyncStatusSummary {
|
|
|
12
12
|
readonly ahead: number;
|
|
13
13
|
readonly behind: number;
|
|
14
14
|
readonly pendingConflicts: number;
|
|
15
|
+
readonly sameBranch: boolean;
|
|
15
16
|
readonly git: GitContextSnapshot;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -21,6 +22,7 @@ export interface PullSummary {
|
|
|
21
22
|
readonly appliedEvents: number;
|
|
22
23
|
readonly createdConflicts: number;
|
|
23
24
|
readonly cursorToken: string | null;
|
|
25
|
+
readonly sameBranch: boolean;
|
|
24
26
|
readonly diagnostics: SyncPullDiagnostics;
|
|
25
27
|
}
|
|
26
28
|
|