trekoon 0.4.2 → 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 -208
- package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
- package/.agents/skills/trekoon/reference/execution.md +170 -380
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +193 -330
- package/.agents/skills/trekoon/reference/sync.md +56 -103
- package/README.md +21 -7
- package/docs/ai-agents.md +48 -4
- package/docs/commands.md +2 -2
- package/docs/quickstart.md +3 -3
- package/package.json +1 -1
- package/src/board/assets/app.js +4 -2
- package/src/board/assets/components/Component.js +6 -8
- package/src/board/assets/state/actions.js +3 -0
- package/src/board/assets/state/api.js +48 -34
- package/src/board/assets/state/store.js +3 -0
- package/src/board/event-bus.ts +9 -0
- package/src/board/routes.ts +94 -83
- package/src/board/server.ts +14 -7
- package/src/board/snapshot.ts +6 -0
- package/src/board/wal-watcher.ts +22 -11
- package/src/commands/help.ts +6 -6
- package/src/commands/skills.ts +17 -5
- package/src/domain/mutation-service.ts +61 -42
- package/src/domain/tracker-domain.ts +20 -16
- package/src/domain/types.ts +3 -0
- package/src/export/render-markdown.ts +1 -2
- package/src/runtime/daemon.ts +110 -49
- package/src/storage/database.ts +9 -2
- package/src/storage/migrations.ts +19 -2
- package/src/sync/service.ts +47 -27
package/src/runtime/daemon.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, unlinkSync, statSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, realpathSync, unlinkSync, statSync } from "node:fs";
|
|
2
2
|
import { connect, createServer, type Server, type Socket } from "node:net";
|
|
3
|
-
import { dirname } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
4
|
import { redactStack, safeErrorMessage } from "../commands/error-utils";
|
|
5
5
|
import { closeCachedDatabases } from "../storage/database";
|
|
6
6
|
import { resolveStoragePaths } from "../storage/path";
|
|
@@ -46,6 +46,7 @@ const MAX_TOTAL_BUFFERED_BYTES = 8 * MAX_REQUEST_BYTES;
|
|
|
46
46
|
const DEFAULT_MAX_CONNECTIONS = 32;
|
|
47
47
|
/** Idle/incomplete-request timeout for a server-side socket. */
|
|
48
48
|
const SERVER_SOCKET_IDLE_MS = 5_000;
|
|
49
|
+
const OWNER_ONLY_MASK = 0o077;
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Pre-write transport failure: the daemon socket was unreachable or the
|
|
@@ -121,6 +122,34 @@ function debugLog(prefix: string, payload: unknown): void {
|
|
|
121
122
|
console.error(prefix);
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
function formatMode(mode: number): string {
|
|
126
|
+
// eslint-disable-next-line no-bitwise
|
|
127
|
+
return `0o${(mode & 0o777).toString(8).padStart(3, "0")}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertOwnerOnlyMode(path: string, label: string): void {
|
|
131
|
+
const stats = statSync(path);
|
|
132
|
+
// eslint-disable-next-line no-bitwise
|
|
133
|
+
if ((stats.mode & OWNER_ONLY_MASK) !== 0) {
|
|
134
|
+
throw new Error(`${label} at ${path} must be owner-only; got ${formatMode(stats.mode)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function __assertOwnerOnlyModeForTests(path: string, label: string): void {
|
|
139
|
+
assertOwnerOnlyMode(path, label);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isPathWithin(candidatePath: string, rootPath: string): boolean {
|
|
143
|
+
const candidate: string = realpathSync(resolve(candidatePath));
|
|
144
|
+
const root: string = realpathSync(resolve(rootPath));
|
|
145
|
+
const pathToCandidate: string = relative(root, candidate);
|
|
146
|
+
return pathToCandidate === "" || (!pathToCandidate.startsWith("..") && !isAbsolute(pathToCandidate));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isAllowedRequestCwd(cwd: string, allowedRoots: readonly string[]): boolean {
|
|
150
|
+
return allowedRoots.some((root: string): boolean => isPathWithin(cwd, root));
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
/**
|
|
125
154
|
* Execute a single daemon request through the in-process CLI shell.
|
|
126
155
|
* Exported for direct unit testing without a socket round-trip.
|
|
@@ -202,16 +231,18 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
|
|
|
202
231
|
const socketPath: string = options.socketPath ?? resolveDaemonSocketPath(cwd);
|
|
203
232
|
const socketDir: string = dirname(socketPath);
|
|
204
233
|
const maxConnections: number = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
234
|
+
const daemonStoragePaths = resolveStoragePaths(cwd);
|
|
235
|
+
const allowedRequestRoots: readonly string[] = [
|
|
236
|
+
daemonStoragePaths.worktreeRoot,
|
|
237
|
+
daemonStoragePaths.storageDir,
|
|
238
|
+
];
|
|
205
239
|
|
|
206
240
|
if (!existsSync(socketDir)) {
|
|
207
241
|
mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
208
242
|
}
|
|
209
243
|
// Tighten parent dir perms even if it pre-existed.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
} catch {
|
|
213
|
-
// best effort; not all filesystems honour this
|
|
214
|
-
}
|
|
244
|
+
chmodSync(socketDir, 0o700);
|
|
245
|
+
assertOwnerOnlyMode(socketDir, "daemon socket directory");
|
|
215
246
|
|
|
216
247
|
// Stale socket from a previous crashed run.
|
|
217
248
|
safeUnlink(socketPath);
|
|
@@ -220,23 +251,20 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
|
|
|
220
251
|
// buffered. Used to enforce both the per-server connection cap and a
|
|
221
252
|
// global upper bound on memory pressure across all in-flight requests.
|
|
222
253
|
const liveSockets: Set<Socket> = new Set<Socket>();
|
|
254
|
+
const processingSockets: Set<Socket> = new Set<Socket>();
|
|
255
|
+
const inFlightRequests: Set<Promise<void>> = new Set<Promise<void>>();
|
|
223
256
|
let totalBufferedBytes = 0;
|
|
224
257
|
|
|
225
258
|
const server: Server = createServer((socket: Socket): void => {
|
|
226
259
|
if (liveSockets.size >= maxConnections) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
} catch {
|
|
236
|
-
// best effort
|
|
237
|
-
}
|
|
238
|
-
socket.end();
|
|
239
|
-
socket.destroy();
|
|
260
|
+
const busyEnvelope: string = `${JSON.stringify({
|
|
261
|
+
stdout: "",
|
|
262
|
+
stderr: "Daemon: daemon_busy (too many concurrent connections)\n",
|
|
263
|
+
exitCode: 1,
|
|
264
|
+
})}\n`;
|
|
265
|
+
socket.write(busyEnvelope, (): void => {
|
|
266
|
+
socket.end();
|
|
267
|
+
});
|
|
240
268
|
return;
|
|
241
269
|
}
|
|
242
270
|
|
|
@@ -322,7 +350,12 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
|
|
|
322
350
|
// meaningful; the dispatcher controls the lifetime from here.
|
|
323
351
|
socket.setTimeout(0);
|
|
324
352
|
|
|
325
|
-
|
|
353
|
+
processingSockets.add(socket);
|
|
354
|
+
const requestPromise = handlePayload(payload, socket, allowedRequestRoots).finally((): void => {
|
|
355
|
+
processingSockets.delete(socket);
|
|
356
|
+
inFlightRequests.delete(requestPromise);
|
|
357
|
+
});
|
|
358
|
+
inFlightRequests.add(requestPromise);
|
|
326
359
|
});
|
|
327
360
|
|
|
328
361
|
socket.on("error", (error: Error): void => {
|
|
@@ -354,8 +387,13 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
|
|
|
354
387
|
// race when other code allocates fds during startup.
|
|
355
388
|
try {
|
|
356
389
|
chmodSync(socketPath, 0o600);
|
|
357
|
-
|
|
358
|
-
|
|
390
|
+
assertOwnerOnlyMode(socketPath, "daemon socket");
|
|
391
|
+
} catch (error: unknown) {
|
|
392
|
+
await new Promise<void>((resolve): void => {
|
|
393
|
+
server.close((): void => resolve());
|
|
394
|
+
});
|
|
395
|
+
safeUnlink(socketPath);
|
|
396
|
+
throw error;
|
|
359
397
|
}
|
|
360
398
|
|
|
361
399
|
if (!options.silent) {
|
|
@@ -365,32 +403,48 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
|
|
|
365
403
|
const handle: DaemonServerHandle = {
|
|
366
404
|
socketPath,
|
|
367
405
|
server,
|
|
368
|
-
close: (): Promise<void> =>
|
|
369
|
-
new Promise<void>((resolve): void => {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
406
|
+
close: async (): Promise<void> => {
|
|
407
|
+
const serverClosed = new Promise<void>((resolve): void => {
|
|
408
|
+
server.close((): void => resolve());
|
|
409
|
+
});
|
|
410
|
+
// Force-close idle sockets so server.close() resolves promptly during
|
|
411
|
+
// test teardown, but let in-flight dispatches finish before DB shutdown.
|
|
412
|
+
for (const sock of liveSockets) {
|
|
413
|
+
if (processingSockets.has(sock)) {
|
|
414
|
+
continue;
|
|
378
415
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
416
|
+
try {
|
|
417
|
+
sock.destroy();
|
|
418
|
+
} catch {
|
|
419
|
+
// best effort
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
await Promise.allSettled([...inFlightRequests]);
|
|
423
|
+
for (const sock of liveSockets) {
|
|
424
|
+
try {
|
|
425
|
+
sock.destroy();
|
|
426
|
+
} catch {
|
|
427
|
+
// best effort
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
await serverClosed;
|
|
431
|
+
liveSockets.clear();
|
|
432
|
+
processingSockets.clear();
|
|
433
|
+
totalBufferedBytes = 0;
|
|
434
|
+
safeUnlink(socketPath);
|
|
435
|
+
closeCachedDatabases();
|
|
436
|
+
delete process.env.TREKOON_DAEMON_INPROCESS;
|
|
437
|
+
},
|
|
388
438
|
};
|
|
389
439
|
|
|
390
440
|
return handle;
|
|
391
441
|
}
|
|
392
442
|
|
|
393
|
-
async function handlePayload(
|
|
443
|
+
async function handlePayload(
|
|
444
|
+
payload: string,
|
|
445
|
+
socket: Socket,
|
|
446
|
+
allowedRequestRoots: readonly string[],
|
|
447
|
+
): Promise<void> {
|
|
394
448
|
let response: DaemonResponse;
|
|
395
449
|
try {
|
|
396
450
|
const parsed: unknown = JSON.parse(payload);
|
|
@@ -400,6 +454,12 @@ async function handlePayload(payload: string, socket: Socket): Promise<void> {
|
|
|
400
454
|
stderr: "Daemon: invalid request payload\n",
|
|
401
455
|
exitCode: 1,
|
|
402
456
|
};
|
|
457
|
+
} else if (!isAllowedRequestCwd(parsed.cwd, allowedRequestRoots)) {
|
|
458
|
+
response = {
|
|
459
|
+
stdout: "",
|
|
460
|
+
stderr: "Daemon: request cwd is outside the daemon worktree/storage scope\n",
|
|
461
|
+
exitCode: 1,
|
|
462
|
+
};
|
|
403
463
|
} else {
|
|
404
464
|
response = await executeDaemonRequest(parsed);
|
|
405
465
|
}
|
|
@@ -472,7 +532,7 @@ export async function sendDaemonRequest(
|
|
|
472
532
|
// socket buffer. Any subsequent error/timeout MUST surface as
|
|
473
533
|
// PostWriteError because the daemon may have already executed the
|
|
474
534
|
// mutation.
|
|
475
|
-
let
|
|
535
|
+
let writeAttempted = false;
|
|
476
536
|
|
|
477
537
|
const fail = (error: unknown): void => {
|
|
478
538
|
if (settled) {
|
|
@@ -485,7 +545,7 @@ export async function sendDaemonRequest(
|
|
|
485
545
|
} catch {
|
|
486
546
|
// best effort
|
|
487
547
|
}
|
|
488
|
-
if (
|
|
548
|
+
if (writeAttempted) {
|
|
489
549
|
const cause: unknown = error;
|
|
490
550
|
const message: string = error instanceof Error ? error.message : String(error);
|
|
491
551
|
reject(new PostWriteError(`daemon may have committed; do not retry: ${message}`, cause));
|
|
@@ -517,15 +577,16 @@ export async function sendDaemonRequest(
|
|
|
517
577
|
|
|
518
578
|
socket.on("connect", (): void => {
|
|
519
579
|
const wireBytes: string = `${JSON.stringify(request)}${REQUEST_TERMINATOR}`;
|
|
580
|
+
writeAttempted = true;
|
|
520
581
|
socket.write(wireBytes, (writeError?: Error | null): void => {
|
|
521
582
|
if (writeError) {
|
|
522
583
|
fail(writeError);
|
|
523
584
|
return;
|
|
524
585
|
}
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
586
|
+
// Callback confirmation is intentionally retained as a second signal
|
|
587
|
+
// for future diagnostics; classification flips synchronously before
|
|
588
|
+
// socket.write can return so write-then-error races are post-write.
|
|
589
|
+
writeAttempted = true;
|
|
529
590
|
});
|
|
530
591
|
});
|
|
531
592
|
|
package/src/storage/database.ts
CHANGED
|
@@ -304,10 +304,17 @@ function releaseCachedDatabase(key: string): void {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
export function closeCachedDatabases(): void {
|
|
307
|
-
for (const entry of cachedDatabases
|
|
307
|
+
for (const [key, entry] of cachedDatabases) {
|
|
308
|
+
if (entry.refcount > 0) {
|
|
309
|
+
// eslint-disable-next-line no-console
|
|
310
|
+
console.warn(
|
|
311
|
+
`[trekoon daemon] leaving cached database open during shutdown because it is still borrowed: ${key}`,
|
|
312
|
+
);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
308
315
|
closeCachedHandle(entry.handle);
|
|
316
|
+
cachedDatabases.delete(key);
|
|
309
317
|
}
|
|
310
|
-
cachedDatabases.clear();
|
|
311
318
|
warnedOnTransientOvergrowth = false;
|
|
312
319
|
}
|
|
313
320
|
|
|
@@ -538,7 +538,20 @@ function validateMigrationPlan(): void {
|
|
|
538
538
|
}
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
-
function
|
|
541
|
+
function attachRollbackFailure(error: unknown, rollbackError: unknown): unknown {
|
|
542
|
+
if (error instanceof Error) {
|
|
543
|
+
Object.defineProperty(error, "cause", {
|
|
544
|
+
value: rollbackError,
|
|
545
|
+
configurable: true,
|
|
546
|
+
writable: true,
|
|
547
|
+
});
|
|
548
|
+
return error;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return new Error("Migration operation failed and rollback also failed.", { cause: rollbackError });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function runExclusive<T>(db: Database, operation: () => T): T {
|
|
542
555
|
db.exec("BEGIN EXCLUSIVE TRANSACTION;");
|
|
543
556
|
|
|
544
557
|
try {
|
|
@@ -546,7 +559,11 @@ function runExclusive<T>(db: Database, operation: () => T): T {
|
|
|
546
559
|
db.exec("COMMIT;");
|
|
547
560
|
return result;
|
|
548
561
|
} catch (error: unknown) {
|
|
549
|
-
|
|
562
|
+
try {
|
|
563
|
+
db.exec("ROLLBACK;");
|
|
564
|
+
} catch (rollbackError: unknown) {
|
|
565
|
+
throw attachRollbackFailure(error, rollbackError);
|
|
566
|
+
}
|
|
550
567
|
throw error;
|
|
551
568
|
}
|
|
552
569
|
}
|
package/src/sync/service.ts
CHANGED
|
@@ -950,24 +950,28 @@ function removeConflictsForEntityIds(
|
|
|
950
950
|
if (entityIds.length === 0) {
|
|
951
951
|
return;
|
|
952
952
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
+
}
|
|
971
975
|
}
|
|
972
976
|
}
|
|
973
977
|
|
|
@@ -2176,6 +2180,8 @@ export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConf
|
|
|
2176
2180
|
|
|
2177
2181
|
export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDetail {
|
|
2178
2182
|
const storage = openTrekoonDatabase(cwd);
|
|
2183
|
+
const git = resolveGitContext(cwd);
|
|
2184
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
2179
2185
|
|
|
2180
2186
|
try {
|
|
2181
2187
|
const conflict = storage.db
|
|
@@ -2184,10 +2190,12 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
|
|
|
2184
2190
|
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
2185
2191
|
FROM sync_conflicts
|
|
2186
2192
|
WHERE id = ?
|
|
2193
|
+
AND worktree_path = ?
|
|
2194
|
+
AND current_branch = ?
|
|
2187
2195
|
LIMIT 1;
|
|
2188
2196
|
`,
|
|
2189
2197
|
)
|
|
2190
|
-
.get(conflictId) as ConflictRow | null;
|
|
2198
|
+
.get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
|
|
2191
2199
|
|
|
2192
2200
|
if (!conflict) {
|
|
2193
2201
|
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
@@ -2231,17 +2239,19 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
|
|
|
2231
2239
|
}
|
|
2232
2240
|
}
|
|
2233
2241
|
|
|
2234
|
-
function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
|
|
2242
|
+
function lookupPendingConflict(db: Database, conflictId: string, scope: ConflictScope): ConflictRow {
|
|
2235
2243
|
const conflict = db
|
|
2236
2244
|
.query(
|
|
2237
2245
|
`
|
|
2238
2246
|
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
2239
2247
|
FROM sync_conflicts
|
|
2240
2248
|
WHERE id = ?
|
|
2249
|
+
AND worktree_path = ?
|
|
2250
|
+
AND current_branch = ?
|
|
2241
2251
|
LIMIT 1;
|
|
2242
2252
|
`,
|
|
2243
2253
|
)
|
|
2244
|
-
.get(conflictId) as ConflictRow | null;
|
|
2254
|
+
.get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
|
|
2245
2255
|
|
|
2246
2256
|
if (!conflict) {
|
|
2247
2257
|
throw new Error(`Conflict '${conflictId}' not found.`);
|
|
@@ -2257,6 +2267,7 @@ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
|
|
|
2257
2267
|
export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
|
|
2258
2268
|
const storage = openTrekoonDatabase(cwd);
|
|
2259
2269
|
const git = resolveGitContext(cwd);
|
|
2270
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
2260
2271
|
|
|
2261
2272
|
try {
|
|
2262
2273
|
persistGitContext(storage.db, git);
|
|
@@ -2266,7 +2277,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
2266
2277
|
// atomic. Without this, two concurrent resolves could both pass
|
|
2267
2278
|
// the check and double-resolve the same conflict.
|
|
2268
2279
|
const conflict = writeTransaction(storage.db, (): ConflictRow => {
|
|
2269
|
-
const row = lookupPendingConflict(storage.db, conflictId);
|
|
2280
|
+
const row = lookupPendingConflict(storage.db, conflictId, scope);
|
|
2270
2281
|
resolveConflictRow(storage.db, row, resolution, git);
|
|
2271
2282
|
return row;
|
|
2272
2283
|
});
|
|
@@ -2286,9 +2297,11 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
2286
2297
|
// Preview is read-only — no git context persistence needed.
|
|
2287
2298
|
export function syncResolvePreview(cwd: string, conflictId: string, resolution: SyncResolution): ResolvePreviewSummary {
|
|
2288
2299
|
const storage = openTrekoonDatabase(cwd);
|
|
2300
|
+
const git = resolveGitContext(cwd);
|
|
2301
|
+
const scope: ConflictScope = scopeFromGitContext(git);
|
|
2289
2302
|
|
|
2290
2303
|
try {
|
|
2291
|
-
const conflict = lookupPendingConflict(storage.db, conflictId);
|
|
2304
|
+
const conflict = lookupPendingConflict(storage.db, conflictId, scope);
|
|
2292
2305
|
|
|
2293
2306
|
const oursValue: unknown = parseConflictValue(conflict.ours_value);
|
|
2294
2307
|
const theirsValue: unknown = parseConflictValue(conflict.theirs_value);
|
|
@@ -2343,7 +2356,11 @@ function queryPendingConflictIds(
|
|
|
2343
2356
|
return (db.query(sql).all(...params) as ConflictOrderRow[]).map((row) => row.id);
|
|
2344
2357
|
}
|
|
2345
2358
|
|
|
2346
|
-
function queryPendingConflictsByIds(
|
|
2359
|
+
function queryPendingConflictsByIds(
|
|
2360
|
+
db: Database,
|
|
2361
|
+
conflictIds: readonly string[],
|
|
2362
|
+
scope: ConflictScope,
|
|
2363
|
+
): readonly ConflictRow[] {
|
|
2347
2364
|
if (conflictIds.length === 0) {
|
|
2348
2365
|
return [];
|
|
2349
2366
|
}
|
|
@@ -2354,10 +2371,13 @@ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]
|
|
|
2354
2371
|
`
|
|
2355
2372
|
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
|
|
2356
2373
|
FROM sync_conflicts
|
|
2357
|
-
WHERE resolution = 'pending'
|
|
2374
|
+
WHERE resolution = 'pending'
|
|
2375
|
+
AND id IN (${placeholders})
|
|
2376
|
+
AND worktree_path = ?
|
|
2377
|
+
AND current_branch = ?;
|
|
2358
2378
|
`,
|
|
2359
2379
|
)
|
|
2360
|
-
.all(...conflictIds) as ConflictRow[];
|
|
2380
|
+
.all(...conflictIds, scope.worktreePath, scope.currentBranch) as ConflictRow[];
|
|
2361
2381
|
|
|
2362
2382
|
const rowById = new Map(rows.map((row) => [row.id, row]));
|
|
2363
2383
|
|
|
@@ -2397,7 +2417,7 @@ export function syncResolveAll(
|
|
|
2397
2417
|
|
|
2398
2418
|
for (let offset = 0; offset < orderedConflictIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
|
|
2399
2419
|
const chunkIds = orderedConflictIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
|
|
2400
|
-
const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds);
|
|
2420
|
+
const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds, scope);
|
|
2401
2421
|
|
|
2402
2422
|
if (chunkConflicts.length !== chunkIds.length) {
|
|
2403
2423
|
throw new DomainError({
|