trekoon 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -1,7 +1,27 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
1
4
  import { Database } from "bun:sqlite";
2
5
 
6
+ import { DomainError } from "../domain/types";
3
7
  import { BASE_SCHEMA_STATEMENTS, SCHEMA_VERSION } from "./schema";
4
8
 
9
+ const BACKUP_HINT = "Run 'trekoon migrate backup' to snapshot .trekoon/trekoon.db before any manual recovery.";
10
+
11
+ function migrationDownUnsupported(migrationName: string, version: number): DomainError {
12
+ return new DomainError({
13
+ code: "migration_down_unsupported",
14
+ message:
15
+ `Migration ${migrationName} is irreversible: rolling back below version ${version} is not supported. ` +
16
+ `${BACKUP_HINT}`,
17
+ details: {
18
+ migrationName,
19
+ version,
20
+ backupCommand: "trekoon migrate backup",
21
+ },
22
+ });
23
+ }
24
+
5
25
  const BASE_MIGRATION_VERSION = 1;
6
26
  const BASE_MIGRATION_NAME = `0001_base_schema_v${SCHEMA_VERSION}`;
7
27
  const LEGACY_BASE_MIGRATION_NAME_PATTERNS: readonly string[] = [
@@ -113,6 +133,57 @@ const BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS: readonly string[] = [
113
133
  "DROP INDEX IF EXISTS idx_board_idempotency_state_created_at;",
114
134
  ];
115
135
 
136
+ const SYNC_CONFLICTS_SCOPE_DOWN_STATEMENTS: readonly string[] = [
137
+ "DROP INDEX IF EXISTS idx_sync_conflicts_scope_entity;",
138
+ "DROP INDEX IF EXISTS idx_sync_conflicts_scope_resolution;",
139
+ ];
140
+
141
+ function migrateSyncConflictsScope(db: Database): void {
142
+ if (!tableExists(db, "sync_conflicts")) {
143
+ return;
144
+ }
145
+
146
+ if (!tableHasColumn(db, "sync_conflicts", "worktree_path")) {
147
+ db.exec("ALTER TABLE sync_conflicts ADD COLUMN worktree_path TEXT NOT NULL DEFAULT '';");
148
+ }
149
+
150
+ if (!tableHasColumn(db, "sync_conflicts", "current_branch")) {
151
+ db.exec("ALTER TABLE sync_conflicts ADD COLUMN current_branch TEXT NOT NULL DEFAULT '';");
152
+ }
153
+
154
+ // Backfill legacy rows from the most-recent git_context entry so existing
155
+ // pending conflicts remain reachable to the current worktree. Pre-existing
156
+ // rows from peer worktrees (rare in practice — pre-fix the bug erased
157
+ // them anyway) end up scoped to the current worktree's branch; this is a
158
+ // best-effort migration since we have no historical context to recover.
159
+ db.exec(`
160
+ UPDATE sync_conflicts
161
+ SET worktree_path = COALESCE(
162
+ NULLIF(worktree_path, ''),
163
+ (SELECT worktree_path FROM git_context ORDER BY updated_at DESC LIMIT 1),
164
+ ''
165
+ )
166
+ WHERE worktree_path IS NULL OR worktree_path = '';
167
+ `);
168
+
169
+ db.exec(`
170
+ UPDATE sync_conflicts
171
+ SET current_branch = COALESCE(
172
+ NULLIF(current_branch, ''),
173
+ (SELECT branch_name FROM git_context ORDER BY updated_at DESC LIMIT 1),
174
+ ''
175
+ )
176
+ WHERE current_branch IS NULL OR current_branch = '';
177
+ `);
178
+
179
+ db.exec(
180
+ "CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_entity ON sync_conflicts(worktree_path, current_branch, entity_kind, entity_id);",
181
+ );
182
+ db.exec(
183
+ "CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_resolution ON sync_conflicts(worktree_path, current_branch, resolution);",
184
+ );
185
+ }
186
+
116
187
  function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
117
188
  const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
118
189
  return columns.some((column) => column.name === columnName);
@@ -266,12 +337,7 @@ const MIGRATIONS: readonly Migration[] = [
266
337
  migrateWorktreeScopedSyncMetadata(db);
267
338
  },
268
339
  down(_db: Database): void {
269
- throw new Error(
270
- "Migration 0004 (worktree_scoped_sync_metadata) is irreversible. " +
271
- "It adds columns via ALTER TABLE that cannot be removed without " +
272
- "reconstructing tables and risking data loss. " +
273
- "Rollback below version 4 is not supported.",
274
- );
340
+ throw migrationDownUnsupported("0004_worktree_scoped_sync_metadata", 4);
275
341
  },
276
342
  },
277
343
  {
@@ -298,11 +364,7 @@ const MIGRATIONS: readonly Migration[] = [
298
364
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_edge ON dependencies (source_id, depends_on_id);");
299
365
  },
300
366
  down(_db: Database): void {
301
- throw new Error(
302
- "Migration 0005 (dependency_edge_integrity) is irreversible. " +
303
- "It removes orphaned rows and deduplicates dependency edges. " +
304
- "Rollback below version 5 is not supported.",
305
- );
367
+ throw migrationDownUnsupported("0005_dependency_edge_integrity", 5);
306
368
  },
307
369
  },
308
370
  {
@@ -317,12 +379,7 @@ const MIGRATIONS: readonly Migration[] = [
317
379
  }
318
380
  },
319
381
  down(_db: Database): void {
320
- throw new Error(
321
- "Migration 0006 (add_owner_column) is irreversible. " +
322
- "It adds columns via ALTER TABLE that cannot be removed without " +
323
- "reconstructing tables and risking data loss. " +
324
- "Rollback below version 6 is not supported.",
325
- );
382
+ throw migrationDownUnsupported("0006_add_owner_column", 6);
326
383
  },
327
384
  },
328
385
  {
@@ -381,6 +438,22 @@ const MIGRATIONS: readonly Migration[] = [
381
438
  }
382
439
  },
383
440
  },
441
+ {
442
+ version: 11,
443
+ name: "0011_sync_conflicts_worktree_branch_scope",
444
+ up(db: Database): void {
445
+ migrateSyncConflictsScope(db);
446
+ },
447
+ down(db: Database): void {
448
+ // Dropping columns requires a table rewrite in SQLite (PRAGMA) — not
449
+ // strictly reversible without potential data loss. We drop the new
450
+ // indexes; the columns persist with their default empty-string values
451
+ // so a re-up no-ops cleanly.
452
+ for (const statement of SYNC_CONFLICTS_SCOPE_DOWN_STATEMENTS) {
453
+ db.exec(statement);
454
+ }
455
+ },
456
+ },
384
457
  ];
385
458
 
386
459
  function migrationTableExists(db: Database): boolean {
@@ -525,6 +598,243 @@ function recordMigration(db: Database, migration: Migration): void {
525
598
  );
526
599
  }
527
600
 
601
+ /** Name of the marker file relative to the .trekoon storage directory. */
602
+ const MIGRATION_VERSION_MARKER_FILENAME = "migration-version";
603
+
604
+ /**
605
+ * On-disk marker payload. The legacy v1 format was a bare integer. v2 stores a
606
+ * JSON object so we can pin the marker to a content fingerprint of the DB
607
+ * (PRAGMA user_version) instead of relying on filesystem mtimes — which the
608
+ * old defensive check could not distinguish from a DB restored from a backup
609
+ * with an older user_version but a newer mtime.
610
+ */
611
+ interface MarkerPayload {
612
+ readonly version: number;
613
+ readonly userVersion: number;
614
+ }
615
+
616
+ /**
617
+ * Derive the path to the migration-version marker file from the database
618
+ * connection's filename. Returns null for in-memory databases.
619
+ */
620
+ function resolveMarkerPath(db: Database): string | null {
621
+ const dbFile: string = db.filename;
622
+ if (!dbFile || dbFile === ":memory:") {
623
+ return null;
624
+ }
625
+ return join(dirname(dbFile), MIGRATION_VERSION_MARKER_FILENAME);
626
+ }
627
+
628
+ /**
629
+ * Read `PRAGMA user_version` from the connection. SQLite stores this as a
630
+ * 32-bit signed integer in the database header — every restore-from-backup
631
+ * naturally carries the original `user_version` along with the bytes.
632
+ */
633
+ function readUserVersion(db: Database): number {
634
+ const row = db.query("PRAGMA user_version;").get() as { user_version: number } | null;
635
+ return row?.user_version ?? 0;
636
+ }
637
+
638
+ /**
639
+ * Set `PRAGMA user_version`. Used as a content-stamp written inside the same
640
+ * transaction that applies (or rolls back) migrations so the DB header is
641
+ * always authoritative for the current schema state — independent of the
642
+ * sidecar marker file.
643
+ *
644
+ * NOTE: PRAGMA does not support parameter binding. The caller must pass an
645
+ * integer or a value that will throw at the SQL level otherwise.
646
+ */
647
+ function setUserVersion(db: Database, version: number): void {
648
+ if (!Number.isInteger(version) || version < 0) {
649
+ throw new Error(`PRAGMA user_version must be a non-negative integer (got ${version}).`);
650
+ }
651
+ db.exec(`PRAGMA user_version = ${version};`);
652
+ }
653
+
654
+ /**
655
+ * Read the marker payload from disk. Falls back to parsing the legacy v1
656
+ * format (bare integer) so previously-installed markers still work without a
657
+ * cold reset.
658
+ */
659
+ function readMarkerPayload(markerPath: string): MarkerPayload | null {
660
+ try {
661
+ if (!existsSync(markerPath)) {
662
+ return null;
663
+ }
664
+
665
+ const raw: string = readFileSync(markerPath, "utf8").trim();
666
+ if (raw.length === 0) {
667
+ return null;
668
+ }
669
+
670
+ if (raw.startsWith("{")) {
671
+ const parsed = JSON.parse(raw) as Partial<MarkerPayload>;
672
+ const version: number | undefined = parsed.version;
673
+ const userVersion: number | undefined = parsed.userVersion;
674
+ if (
675
+ typeof version !== "number" ||
676
+ !Number.isFinite(version) ||
677
+ version < 0 ||
678
+ typeof userVersion !== "number" ||
679
+ !Number.isFinite(userVersion) ||
680
+ userVersion < 0
681
+ ) {
682
+ return null;
683
+ }
684
+ return { version, userVersion };
685
+ }
686
+
687
+ // Legacy v1 format: bare integer. Treat it as having no fingerprint;
688
+ // returning userVersion: -1 forces canSkipProbeViaMarker to fall through
689
+ // to the slow path, which then rewrites the marker in v2 form.
690
+ const legacyVersion: number = parseInt(raw, 10);
691
+ if (!Number.isFinite(legacyVersion) || legacyVersion < 0) {
692
+ return null;
693
+ }
694
+ return { version: legacyVersion, userVersion: -1 };
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Read the migration version stored in the marker file. Returns null when the
702
+ * file is absent, unreadable, or malformed. Kept for backwards-compatible
703
+ * test/inspection use; the in-process fast-path uses {@link readMarkerPayload}.
704
+ */
705
+ export function readMigrationVersionMarker(db: Database): number | null {
706
+ const markerPath: string | null = resolveMarkerPath(db);
707
+ if (!markerPath) {
708
+ return null;
709
+ }
710
+
711
+ const payload: MarkerPayload | null = readMarkerPayload(markerPath);
712
+ return payload?.version ?? null;
713
+ }
714
+
715
+ interface WriteMarkerResult {
716
+ readonly written: boolean;
717
+ readonly skipped: boolean;
718
+ }
719
+
720
+ /**
721
+ * Internal marker writer that surfaces the outcome to callers. Writes
722
+ * atomically via temp + rename. Returns `{written:false}` on filesystem
723
+ * errors so callers (e.g. {@link rollbackDatabase}) can attempt a stale-marker
724
+ * cleanup instead of silently leaving a misleading hint on disk.
725
+ */
726
+ function writeMarkerPayload(
727
+ db: Database,
728
+ payload: MarkerPayload,
729
+ ): WriteMarkerResult {
730
+ const markerPath: string | null = resolveMarkerPath(db);
731
+ if (!markerPath) {
732
+ return { written: false, skipped: true };
733
+ }
734
+
735
+ try {
736
+ mkdirSync(dirname(markerPath), { recursive: true });
737
+ const tmpPath = `${markerPath}.tmp`;
738
+ writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
739
+ renameSync(tmpPath, markerPath);
740
+ return { written: true, skipped: false };
741
+ } catch {
742
+ return { written: false, skipped: false };
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Best-effort: remove a stale marker so the next cold start re-probes the
748
+ * schema. Used when a fresh marker write fails after a rollback (or other
749
+ * version-changing op) to avoid leaving the previous version's marker on disk
750
+ * pointing at a now-incorrect schema state.
751
+ */
752
+ function unlinkMarkerIfExists(db: Database): void {
753
+ const markerPath: string | null = resolveMarkerPath(db);
754
+ if (!markerPath) {
755
+ return;
756
+ }
757
+ try {
758
+ if (existsSync(markerPath)) {
759
+ unlinkSync(markerPath);
760
+ }
761
+ } catch (error) {
762
+ // Defense-in-depth — the worst case is that the next cold start spends
763
+ // one extra schema probe noticing the marker mismatch. We surface the
764
+ // failure at warn level (System Hardening 0.4.2, finding 30) so that
765
+ // operators can spot recurring stale-marker issues without escalating
766
+ // a one-off cleanup failure to a hard error.
767
+ const message = error instanceof Error ? error.message : String(error);
768
+ console.warn(
769
+ `[trekoon] failed to unlink stale schema marker at ${markerPath}: ${message}`,
770
+ );
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Write the migration version to the marker file atomically (temp + rename).
776
+ * Public, backwards-compatible signature. Reads the current PRAGMA user_version
777
+ * from the connection so fast-path consumers can verify the marker against the
778
+ * DB header on the next call.
779
+ */
780
+ export function writeMigrationVersionMarker(db: Database, version: number): void {
781
+ const markerPath: string | null = resolveMarkerPath(db);
782
+ if (!markerPath) {
783
+ return;
784
+ }
785
+ const userVersion: number = readUserVersion(db);
786
+ writeMarkerPayload(db, { version, userVersion });
787
+ }
788
+
789
+ /**
790
+ * Determine whether the marker file allows skipping the schema probe query.
791
+ * Returns true only when:
792
+ * 1. The marker file exists, parses cleanly, and reports
793
+ * `version === LATEST_MIGRATION_VERSION`.
794
+ * 2. The DB's PRAGMA user_version matches the marker's recorded user_version
795
+ * AND equals `LATEST_MIGRATION_VERSION`.
796
+ *
797
+ * The user_version match is the load-bearing freshness check — replacing the
798
+ * previous mtime heuristic, which could not distinguish a DB restored from an
799
+ * older backup (older user_version, fresh mtime) from a healthy current DB.
800
+ * Restoring a backup brings the original user_version with the bytes, so the
801
+ * pragma check naturally fails over to the slow path even when the marker
802
+ * file is "newer" than the DB on disk.
803
+ */
804
+ function canSkipProbeViaMarker(db: Database): boolean {
805
+ const markerPath: string | null = resolveMarkerPath(db);
806
+ if (!markerPath) {
807
+ return false;
808
+ }
809
+
810
+ try {
811
+ const payload: MarkerPayload | null = readMarkerPayload(markerPath);
812
+ if (!payload) {
813
+ return false;
814
+ }
815
+
816
+ if (payload.version !== LATEST_MIGRATION_VERSION) {
817
+ return false;
818
+ }
819
+
820
+ if (payload.userVersion !== LATEST_MIGRATION_VERSION) {
821
+ // Legacy v1 markers report userVersion: -1; force probe so the marker
822
+ // gets rewritten in v2 form.
823
+ return false;
824
+ }
825
+
826
+ const dbFile: string = db.filename;
827
+ if (!dbFile || !existsSync(dbFile)) {
828
+ return false;
829
+ }
830
+
831
+ const dbUserVersion: number = readUserVersion(db);
832
+ return dbUserVersion === payload.userVersion;
833
+ } catch {
834
+ return false;
835
+ }
836
+ }
837
+
528
838
  function isSchemaCurrentFastPath(db: Database, latestVersion: number): boolean {
529
839
  if (latestVersion === 0 || !migrationTableExists(db) || !hasMigrationVersionColumn(db)) {
530
840
  return false;
@@ -567,12 +877,49 @@ export function migrateDatabase(db: Database): void {
567
877
 
568
878
  const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
569
879
 
570
- // Fast path: avoid BEGIN EXCLUSIVE when schema is already current.
880
+ // Marker fast path: skip ALL probe queries when the persisted marker file
881
+ // records the latest version and is newer than the DB file. This saves the
882
+ // schema_migrations SELECT on warm CLI starts.
883
+ if (canSkipProbeViaMarker(db)) {
884
+ migrateBoardIdempotencyState(db);
885
+ return;
886
+ }
887
+
888
+ // Schema fast path: avoid BEGIN EXCLUSIVE when schema is already current.
571
889
  // This reduces startup lock contention while keeping the explicit
572
890
  // transactional migration path for non-current/legacy schemas.
573
891
  if (isSchemaCurrentFastPath(db, latestVersion)) {
574
892
  migrateBoardIdempotencyState(db);
575
- return;
893
+
894
+ // Re-confirm the schema state under an EXCLUSIVE lock before stamping
895
+ // the DB header. Without the lock, a concurrent rollback in another
896
+ // process can interleave between the unlocked probe above and the
897
+ // setUserVersion call below, leaving PRAGMA user_version pointing at a
898
+ // higher version than schema_migrations actually reflects. If the
899
+ // recheck disagrees we bail to the slow migrate path which will
900
+ // re-apply any missing migrations transactionally.
901
+ let fastPathConfirmed = false;
902
+ runExclusive(db, (): void => {
903
+ if (currentVersion(db) !== latestVersion) {
904
+ return;
905
+ }
906
+ // Stamp the DB header inside the same exclusive transaction so the
907
+ // user_version cannot diverge from schema_migrations on commit.
908
+ // This also covers first runs after the v1 marker format upgrade and
909
+ // restored-from-backup DBs whose schema_migrations happens to match
910
+ // latest but whose user_version still trails.
911
+ setUserVersion(db, latestVersion);
912
+ fastPathConfirmed = true;
913
+ });
914
+
915
+ if (fastPathConfirmed) {
916
+ // Persist the marker so the next cold start can short-circuit. The
917
+ // marker is a sidecar hint — even if this write fails, the DB header
918
+ // (stamped inside the tx above) remains authoritative.
919
+ writeMigrationVersionMarker(db, latestVersion);
920
+ return;
921
+ }
922
+ // Disagreement: fall through to the slow path below.
576
923
  }
577
924
 
578
925
  runExclusive(db, (): void => {
@@ -594,7 +941,37 @@ export function migrateDatabase(db: Database): void {
594
941
  migration.up(db);
595
942
  recordMigration(db, migration);
596
943
  }
944
+
945
+ // Stamp the DB header inside the same exclusive transaction so the
946
+ // user_version cannot diverge from schema_migrations on commit.
947
+ setUserVersion(db, latestVersion);
597
948
  });
949
+
950
+ // Persist the new version so the next cold start can skip the probe.
951
+ writeMigrationVersionMarker(db, latestVersion);
952
+ }
953
+
954
+ export const LATEST_MIGRATION_VERSION: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
955
+
956
+ /**
957
+ * Read the highest applied migration version from a database without mutating
958
+ * it. Safe to call against a connection opened in `readonly: true` mode.
959
+ * Returns 0 when the schema_migrations table does not exist or has no rows.
960
+ */
961
+ export function readCurrentMigrationVersionReadOnly(db: Database): number {
962
+ if (!migrationTableExists(db)) {
963
+ return 0;
964
+ }
965
+
966
+ if (!hasMigrationVersionColumn(db)) {
967
+ return 0;
968
+ }
969
+
970
+ const row = db
971
+ .query("SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE version IS NOT NULL;")
972
+ .get() as { version: number } | null;
973
+
974
+ return row?.version ?? 0;
598
975
  }
599
976
 
600
977
  export function describeMigrations(db: Database): MigrationStatus {
@@ -628,7 +1005,7 @@ export function rollbackDatabase(db: Database, targetVersion: number): RollbackS
628
1005
  throw new Error("Rollback target version must be a non-negative integer.");
629
1006
  }
630
1007
 
631
- return runExclusive(db, (): RollbackSummary => {
1008
+ const summary: RollbackSummary = runExclusive(db, (): RollbackSummary => {
632
1009
  ensureMigrationTable(db);
633
1010
  ensureMigrationVersionColumn(db);
634
1011
  validateMigrationPlan();
@@ -658,6 +1035,13 @@ export function rollbackDatabase(db: Database, targetVersion: number): RollbackS
658
1035
  rolledBackMigrations.push(migration.name);
659
1036
  }
660
1037
 
1038
+ // Stamp the DB header inside the rollback transaction so the
1039
+ // user_version is authoritative for the post-rollback schema state. If
1040
+ // the sidecar marker write below fails for any reason, the next
1041
+ // canSkipProbeViaMarker() check will see a stale marker.userVersion and
1042
+ // fall through to the slow probe path — no silent drift.
1043
+ setUserVersion(db, targetVersion);
1044
+
661
1045
  return {
662
1046
  fromVersion,
663
1047
  toVersion: targetVersion,
@@ -665,4 +1049,22 @@ export function rollbackDatabase(db: Database, targetVersion: number): RollbackS
665
1049
  rolledBackMigrations,
666
1050
  };
667
1051
  });
1052
+
1053
+ // Update the marker so the next start reflects the rolled-back version.
1054
+ // If the write fails, attempt to delete the now-stale marker so a future
1055
+ // cold start re-probes instead of trusting a marker that points at the
1056
+ // pre-rollback version.
1057
+ const markerPath: string | null = resolveMarkerPath(db);
1058
+ if (markerPath) {
1059
+ const userVersion: number = readUserVersion(db);
1060
+ const markerResult: WriteMarkerResult = writeMarkerPayload(db, {
1061
+ version: targetVersion,
1062
+ userVersion,
1063
+ });
1064
+ if (!markerResult.written && !markerResult.skipped) {
1065
+ unlinkMarkerIfExists(db);
1066
+ }
1067
+ }
1068
+
1069
+ return summary;
668
1070
  }
@@ -74,6 +74,14 @@ export interface StoragePathDiagnostics {
74
74
 
75
75
  const storagePathCache: Map<string, StoragePaths> = new Map();
76
76
 
77
+ /**
78
+ * Clear the process-level storage path cache.
79
+ * Intended for test isolation only — production code should never call this.
80
+ */
81
+ export function clearStoragePathCache(): void {
82
+ storagePathCache.clear();
83
+ }
84
+
77
85
  function resolveGitPath(workingDirectory: string, argument: "--git-common-dir" | "--show-toplevel"): string | null {
78
86
  const result = spawnSync("git", ["rev-parse", argument], {
79
87
  cwd: workingDirectory,
@@ -112,7 +112,9 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
112
112
  resolution TEXT NOT NULL DEFAULT 'pending',
113
113
  created_at INTEGER NOT NULL,
114
114
  updated_at INTEGER NOT NULL,
115
- version INTEGER NOT NULL DEFAULT 1
115
+ version INTEGER NOT NULL DEFAULT 1,
116
+ worktree_path TEXT NOT NULL DEFAULT '',
117
+ current_branch TEXT NOT NULL DEFAULT ''
116
118
  );
117
119
  `,
118
120
  `
@@ -136,6 +138,8 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
136
138
  `CREATE INDEX IF NOT EXISTS idx_sync_cursors_owner ON sync_cursors(owner_scope, owner_worktree_path, source_branch);`,
137
139
  `CREATE INDEX IF NOT EXISTS idx_conflicts_resolution ON sync_conflicts(resolution);`,
138
140
  `CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);`,
141
+ `CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_entity ON sync_conflicts(worktree_path, current_branch, entity_kind, entity_id);`,
142
+ `CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_resolution ON sync_conflicts(worktree_path, current_branch, resolution);`,
139
143
  `CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
140
144
  `CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
141
145
  ];
@@ -18,6 +18,14 @@ interface EventWriteContext {
18
18
 
19
19
  const transactionEventContexts: WeakMap<Database, EventWriteContext> = new WeakMap();
20
20
 
21
+ /**
22
+ * Compute the next safe event timestamp INSIDE a write lock.
23
+ *
24
+ * Must only be called after BEGIN IMMEDIATE has been issued on `db`.
25
+ * Reading the max(created_at) while holding the write lock guarantees no
26
+ * concurrent writer can commit a higher timestamp between the read and the
27
+ * subsequent INSERT, preventing (created_at, id) collisions.
28
+ */
21
29
  export function nextEventTimestamp(db: Database): number {
22
30
  const now: number = Date.now();
23
31
  const latestEvent = db
@@ -38,22 +46,41 @@ export function nextEventTimestamp(db: Database): number {
38
46
  return Math.max(now, latestEvent.created_at + 1);
39
47
  }
40
48
 
41
- export function prepareEventWriteContext(db: Database, cwd: string): EventWriteContext {
42
- const nextTimestamp: number = nextEventTimestamp(db);
43
- const git: ResolvedGitContext = resolveGitContext(cwd, nextTimestamp);
44
-
45
- return {
46
- git,
47
- nextTimestamp,
48
- };
49
- }
50
-
51
- export function withTransactionEventContext<T>(db: Database, context: EventWriteContext, fn: () => T): T {
49
+ /**
50
+ * Execute `fn` inside a transaction event context, computing the event
51
+ * timestamp AFTER the write lock is held (i.e. after BEGIN IMMEDIATE).
52
+ *
53
+ * The caller MUST resolve the git context BEFORE entering the write
54
+ * transaction and pass it in via `git`. This keeps the (potentially slow)
55
+ * `git branch` / `git rev-parse` subprocesses outside the SQLite write lock,
56
+ * so concurrent writers on a cold git-context cache do not serialize on
57
+ * subprocess invocations behind BEGIN IMMEDIATE.
58
+ *
59
+ * Only `nextEventTimestamp(db)` runs inside the lock reading
60
+ * `MAX(created_at)` while the write lock is held is what guarantees no
61
+ * concurrent writer can commit a higher timestamp before our INSERT.
62
+ *
63
+ * If a context is already active for this database connection (nested call),
64
+ * `fn` is invoked directly so the outer context's monotonic counter is
65
+ * reused; the supplied `git` argument is ignored in that case.
66
+ *
67
+ * @param db - The database connection that is already inside BEGIN IMMEDIATE.
68
+ * @param git - Pre-resolved git context (resolved BEFORE the write lock).
69
+ * @param fn - The transaction body to run.
70
+ */
71
+ export function withTransactionEventContext<T>(db: Database, git: ResolvedGitContext, fn: () => T): T {
52
72
  const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
53
73
  if (existingContext) {
54
74
  return fn();
55
75
  }
56
76
 
77
+ // Compute the timestamp NOW — the write lock is already held. The git
78
+ // context was resolved by the caller before BEGIN IMMEDIATE, so no
79
+ // subprocess invocations happen here.
80
+ const nextTimestamp: number = nextEventTimestamp(db);
81
+ const resolvedGit: ResolvedGitContext = { ...git, persistedAt: nextTimestamp };
82
+ const context: EventWriteContext = { git: resolvedGit, nextTimestamp };
83
+
57
84
  transactionEventContexts.set(db, context);
58
85
 
59
86
  try {