trekoon 0.3.7 → 0.3.8
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 +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +8 -25
- package/src/board/assets/state/api.js +5 -6
- package/src/board/assets/state/utils.js +50 -17
- package/src/board/routes.ts +22 -19
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +133 -84
- package/src/commands/board.ts +1 -1
- package/src/commands/quickstart.ts +10 -0
- package/src/domain/mutation-service.ts +179 -65
- package/src/domain/tracker-domain.ts +16 -2
- package/src/storage/migrations.ts +27 -2
- package/src/storage/schema.ts +2 -1
- package/src/sync/event-writes.ts +11 -7
- package/src/sync/service.ts +183 -4
package/src/sync/service.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { type Database } from "bun:sqlite";
|
|
4
4
|
|
|
5
|
+
import { ENTITY_OPERATIONS } from "../domain/mutation-operations";
|
|
5
6
|
import { openTrekoonDatabase, writeTransaction } from "../storage/database";
|
|
6
7
|
import { countBranchEventsSince, queryBranchEventsSinceBatch } from "./branch-db";
|
|
7
8
|
import { nextEventTimestamp } from "./event-writes";
|
|
@@ -29,6 +30,8 @@ const SYNC_ALLOWED_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
|
|
29
30
|
dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
const SYNC_EVENT_METADATA_FIELDS = new Set(["dependency_id", "source_event_id"]);
|
|
34
|
+
|
|
32
35
|
function isSyncNullableStringField(tableName: string, fieldName: string): boolean {
|
|
33
36
|
return (tableName === "tasks" || tableName === "subtasks") && fieldName === "owner";
|
|
34
37
|
}
|
|
@@ -156,6 +159,13 @@ interface LocalEntityEventRow {
|
|
|
156
159
|
readonly id: string;
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
interface DependencyEventIdentity {
|
|
163
|
+
readonly sourceId: string;
|
|
164
|
+
readonly sourceKind: string;
|
|
165
|
+
readonly dependsOnId: string;
|
|
166
|
+
readonly dependsOnKind: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
const SYNC_PULL_BATCH_SIZE = 250;
|
|
160
170
|
const CONFLICT_HISTORY_SCAN_BATCH_SIZE = 250;
|
|
161
171
|
const RESOLVE_ALL_CHUNK_SIZE = 200;
|
|
@@ -387,6 +397,37 @@ function currentEntityFieldValue(db: Database, entityKind: string, entityId: str
|
|
|
387
397
|
return row?.value;
|
|
388
398
|
}
|
|
389
399
|
|
|
400
|
+
function dependencyEventIdentityFromFields(fields: Record<string, unknown>): DependencyEventIdentity | null {
|
|
401
|
+
const sourceId = validateRequiredStringField(fields, "source_id");
|
|
402
|
+
const sourceKind = validateRequiredStringField(fields, "source_kind");
|
|
403
|
+
const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
|
|
404
|
+
const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
|
|
405
|
+
|
|
406
|
+
if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
sourceId,
|
|
412
|
+
sourceKind,
|
|
413
|
+
dependsOnId,
|
|
414
|
+
dependsOnKind,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function dependencyEventIdentity(event: StoredEvent): DependencyEventIdentity | null {
|
|
419
|
+
if (event.entity_kind !== "dependency") {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const payloadValidation = parsePayload(event.payload);
|
|
424
|
+
if (!payloadValidation.ok) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return dependencyEventIdentityFromFields(payloadValidation.fields);
|
|
429
|
+
}
|
|
430
|
+
|
|
390
431
|
function entityFieldConflict(
|
|
391
432
|
localDb: Database,
|
|
392
433
|
sourceBranch: string,
|
|
@@ -429,6 +470,8 @@ function entityFieldConflict(
|
|
|
429
470
|
CONFLICT_HISTORY_SCAN_BATCH_SIZE,
|
|
430
471
|
) as LocalEntityEventRow[];
|
|
431
472
|
|
|
473
|
+
const incomingDependencyIdentity = dependencyEventIdentity(event);
|
|
474
|
+
|
|
432
475
|
if (rows.length === 0) {
|
|
433
476
|
return null;
|
|
434
477
|
}
|
|
@@ -439,6 +482,19 @@ function entityFieldConflict(
|
|
|
439
482
|
continue;
|
|
440
483
|
}
|
|
441
484
|
|
|
485
|
+
if (incomingDependencyIdentity !== null) {
|
|
486
|
+
const localDependencyIdentity = dependencyEventIdentityFromFields(payloadValidation.fields);
|
|
487
|
+
if (
|
|
488
|
+
localDependencyIdentity === null ||
|
|
489
|
+
localDependencyIdentity.sourceId !== incomingDependencyIdentity.sourceId ||
|
|
490
|
+
localDependencyIdentity.sourceKind !== incomingDependencyIdentity.sourceKind ||
|
|
491
|
+
localDependencyIdentity.dependsOnId !== incomingDependencyIdentity.dependsOnId ||
|
|
492
|
+
localDependencyIdentity.dependsOnKind !== incomingDependencyIdentity.dependsOnKind
|
|
493
|
+
) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
442
498
|
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
443
499
|
const localValue: unknown = readFieldValue(payload, fieldName);
|
|
444
500
|
|
|
@@ -724,6 +780,124 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
|
|
|
724
780
|
return false;
|
|
725
781
|
}
|
|
726
782
|
|
|
783
|
+
function hasLocalDependencyEditsForIdentity(
|
|
784
|
+
db: Database,
|
|
785
|
+
sourceBranch: string,
|
|
786
|
+
identity: DependencyEventIdentity,
|
|
787
|
+
): boolean {
|
|
788
|
+
const row = db
|
|
789
|
+
.query(
|
|
790
|
+
`
|
|
791
|
+
SELECT 1
|
|
792
|
+
FROM events
|
|
793
|
+
WHERE entity_kind = 'dependency'
|
|
794
|
+
AND (git_branch IS NULL OR git_branch != ?)
|
|
795
|
+
AND json_extract(payload, '$.fields.source_id') = ?
|
|
796
|
+
AND json_extract(payload, '$.fields.source_kind') = ?
|
|
797
|
+
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
798
|
+
AND json_extract(payload, '$.fields.depends_on_kind') = ?
|
|
799
|
+
LIMIT 1;
|
|
800
|
+
`,
|
|
801
|
+
)
|
|
802
|
+
.get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
|
|
803
|
+
|
|
804
|
+
return row !== null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventIdentity): boolean {
|
|
808
|
+
const row = db
|
|
809
|
+
.query(
|
|
810
|
+
`
|
|
811
|
+
SELECT 1
|
|
812
|
+
FROM dependencies
|
|
813
|
+
WHERE source_id = ?
|
|
814
|
+
AND source_kind = ?
|
|
815
|
+
AND depends_on_id = ?
|
|
816
|
+
AND depends_on_kind = ?
|
|
817
|
+
LIMIT 1;
|
|
818
|
+
`,
|
|
819
|
+
)
|
|
820
|
+
.get(identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
|
|
821
|
+
|
|
822
|
+
return row !== null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function latestLocalDependencyOperationForIdentity(
|
|
826
|
+
db: Database,
|
|
827
|
+
sourceBranch: string,
|
|
828
|
+
identity: DependencyEventIdentity,
|
|
829
|
+
): string | null {
|
|
830
|
+
const row = db
|
|
831
|
+
.query(
|
|
832
|
+
`
|
|
833
|
+
SELECT operation
|
|
834
|
+
FROM events
|
|
835
|
+
WHERE entity_kind = 'dependency'
|
|
836
|
+
AND (git_branch IS NULL OR git_branch != ?)
|
|
837
|
+
AND json_extract(payload, '$.fields.source_id') = ?
|
|
838
|
+
AND json_extract(payload, '$.fields.source_kind') = ?
|
|
839
|
+
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
840
|
+
AND json_extract(payload, '$.fields.depends_on_kind') = ?
|
|
841
|
+
ORDER BY created_at DESC, id DESC
|
|
842
|
+
LIMIT 1;
|
|
843
|
+
`,
|
|
844
|
+
)
|
|
845
|
+
.get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
|
|
846
|
+
| { operation: string }
|
|
847
|
+
| null;
|
|
848
|
+
|
|
849
|
+
return row?.operation ?? null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function hasLocalDependencyRemovalForIdentity(
|
|
853
|
+
db: Database,
|
|
854
|
+
sourceBranch: string,
|
|
855
|
+
identity: DependencyEventIdentity,
|
|
856
|
+
): boolean {
|
|
857
|
+
const row = db
|
|
858
|
+
.query(
|
|
859
|
+
`
|
|
860
|
+
SELECT 1
|
|
861
|
+
FROM events
|
|
862
|
+
WHERE entity_kind = 'dependency'
|
|
863
|
+
AND operation = 'dependency.removed'
|
|
864
|
+
AND (git_branch IS NULL OR git_branch != ?)
|
|
865
|
+
AND json_extract(payload, '$.fields.source_id') = ?
|
|
866
|
+
AND json_extract(payload, '$.fields.depends_on_id') = ?
|
|
867
|
+
AND (
|
|
868
|
+
json_extract(payload, '$.fields.source_kind') IS NULL
|
|
869
|
+
OR json_extract(payload, '$.fields.source_kind') = ?
|
|
870
|
+
)
|
|
871
|
+
AND (
|
|
872
|
+
json_extract(payload, '$.fields.depends_on_kind') IS NULL
|
|
873
|
+
OR json_extract(payload, '$.fields.depends_on_kind') = ?
|
|
874
|
+
)
|
|
875
|
+
LIMIT 1;
|
|
876
|
+
`,
|
|
877
|
+
)
|
|
878
|
+
.get(sourceBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
|
|
879
|
+
|
|
880
|
+
return row !== null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sourceBranch: string): boolean {
|
|
884
|
+
const identity = dependencyEventIdentity(event);
|
|
885
|
+
if (identity === null) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (!dependencyRowExistsForIdentity(db, identity)) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const latestOperation = latestLocalDependencyOperationForIdentity(db, sourceBranch, identity);
|
|
894
|
+
if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return hasLocalDependencyEditsForIdentity(db, sourceBranch, identity);
|
|
899
|
+
}
|
|
900
|
+
|
|
727
901
|
function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBranch: string): boolean {
|
|
728
902
|
if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, sourceBranch)) {
|
|
729
903
|
return true;
|
|
@@ -869,6 +1043,7 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
869
1043
|
const sourceKind = validateRequiredStringField(fields, "source_kind");
|
|
870
1044
|
const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
|
|
871
1045
|
const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
|
|
1046
|
+
const dependencyId = validateRequiredStringField(fields, "dependency_id") ?? event.entity_id;
|
|
872
1047
|
|
|
873
1048
|
if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
|
|
874
1049
|
return false;
|
|
@@ -891,13 +1066,12 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
891
1066
|
version
|
|
892
1067
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
893
1068
|
ON CONFLICT(source_id, depends_on_id) DO UPDATE SET
|
|
894
|
-
id = excluded.id,
|
|
895
1069
|
source_kind = excluded.source_kind,
|
|
896
1070
|
depends_on_kind = excluded.depends_on_kind,
|
|
897
1071
|
updated_at = excluded.updated_at,
|
|
898
1072
|
version = dependencies.version + 1;
|
|
899
1073
|
`,
|
|
900
|
-
).run(
|
|
1074
|
+
).run(dependencyId, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
|
|
901
1075
|
|
|
902
1076
|
return true;
|
|
903
1077
|
}
|
|
@@ -1242,8 +1416,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1242
1416
|
}
|
|
1243
1417
|
|
|
1244
1418
|
const isDeleteWithLocalEdits =
|
|
1245
|
-
incoming.operation.endsWith(".deleted") &&
|
|
1246
|
-
|
|
1419
|
+
(incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch)) ||
|
|
1420
|
+
(incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, sourceBranch));
|
|
1247
1421
|
if (isDeleteWithLocalEdits) {
|
|
1248
1422
|
createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
|
|
1249
1423
|
createdConflicts += 1;
|
|
@@ -1258,6 +1432,11 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
1258
1432
|
let withheldConflictCount = 0;
|
|
1259
1433
|
|
|
1260
1434
|
for (const [fieldName, value] of Object.entries(payload.fields)) {
|
|
1435
|
+
if (SYNC_EVENT_METADATA_FIELDS.has(fieldName)) {
|
|
1436
|
+
fieldsToApply[fieldName] = value;
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1261
1440
|
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
1262
1441
|
|
|
1263
1442
|
if (conflict) {
|