trekoon 0.2.9 → 0.3.1
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 +162 -26
- package/README.md +18 -15
- package/docs/ai-agents.md +49 -4
- package/docs/commands.md +90 -16
- package/docs/machine-contracts.md +120 -0
- package/docs/plans/r1-unified-skill-rewrite.md +290 -0
- package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
- package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
- package/docs/quickstart.md +41 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +1 -0
- package/src/board/assets/components/EpicRow.js +21 -6
- package/src/board/assets/components/EpicsOverview.js +5 -1
- package/src/board/assets/components/Notice.js +19 -12
- package/src/board/assets/components/Workspace.js +16 -5
- package/src/board/assets/components/helpers.js +17 -0
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +33 -0
- package/src/board/assets/state/actions.js +68 -0
- package/src/board/assets/state/store.js +1 -0
- package/src/board/assets/styles/board.css +156 -36
- package/src/board/routes.ts +2 -0
- package/src/commands/epic.ts +74 -3
- package/src/commands/session.ts +7 -75
- package/src/commands/subtask.ts +7 -5
- package/src/commands/suggest.ts +283 -0
- package/src/commands/sync-helpers.ts +75 -0
- package/src/commands/task-readiness.ts +8 -20
- package/src/commands/task.ts +59 -3
- package/src/domain/mutation-service.ts +69 -42
- package/src/domain/tracker-domain.ts +151 -22
- package/src/domain/types.ts +12 -0
- package/src/index.ts +1 -1
- package/src/io/output.ts +4 -2
- package/src/runtime/cli-shell.ts +26 -3
- package/src/runtime/command-types.ts +1 -1
- package/src/storage/database.ts +43 -1
- package/src/storage/events-retention.ts +57 -8
- package/src/storage/migrations.ts +58 -3
- package/src/sync/service.ts +101 -24
- package/src/sync/types.ts +1 -0
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { executeShell, parseInvocation, renderShellResult } from "./runtime/cli-
|
|
|
5
5
|
export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
|
|
6
6
|
const parsed = parseInvocation(argv);
|
|
7
7
|
const result = await executeShell(parsed);
|
|
8
|
-
const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode);
|
|
8
|
+
const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode, { compact: parsed.compact });
|
|
9
9
|
|
|
10
10
|
if (result.ok) {
|
|
11
11
|
process.stdout.write(`${rendered}\n`);
|
package/src/io/output.ts
CHANGED
|
@@ -13,8 +13,9 @@ const CONTRACT_VERSION = "1.0.0";
|
|
|
13
13
|
const COMPATIBILITY_DEPRECATED_SINCE = "0.1.8";
|
|
14
14
|
const COMPATIBILITY_REMOVAL_AFTER = "2026-09-30";
|
|
15
15
|
|
|
16
|
-
interface RenderOptions {
|
|
16
|
+
export interface RenderOptions {
|
|
17
17
|
readonly compatibilityMode?: CompatibilityMode | null;
|
|
18
|
+
readonly compact?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function toLegacySyncCommandId(command: string): string {
|
|
@@ -136,13 +137,14 @@ export function failResult(input: ResultInput & { readonly error: ToonError }):
|
|
|
136
137
|
|
|
137
138
|
export function toToonEnvelope(result: CliResult, options: RenderOptions = {}): ToonEnvelope {
|
|
138
139
|
const compatibilityMode: CompatibilityMode | null = options.compatibilityMode ?? null;
|
|
140
|
+
const compact: boolean = options.compact ?? false;
|
|
139
141
|
const command: string = resolveCompatibilityCommand(result.command, compatibilityMode);
|
|
140
142
|
|
|
141
143
|
return {
|
|
142
144
|
ok: result.ok,
|
|
143
145
|
command,
|
|
144
146
|
data: result.data,
|
|
145
|
-
metadata: createContractMetadata(result, compatibilityMode),
|
|
147
|
+
...(compact ? {} : { metadata: createContractMetadata(result, compatibilityMode) }),
|
|
146
148
|
...(result.error ? { error: result.error } : {}),
|
|
147
149
|
...(result.meta ? { meta: result.meta } : {}),
|
|
148
150
|
};
|
package/src/runtime/cli-shell.ts
CHANGED
|
@@ -8,11 +8,12 @@ import { runMigrate } from "../commands/migrate";
|
|
|
8
8
|
import { runQuickstart } from "../commands/quickstart";
|
|
9
9
|
import { runSession } from "../commands/session";
|
|
10
10
|
import { runSkills } from "../commands/skills";
|
|
11
|
+
import { runSuggest } from "../commands/suggest";
|
|
11
12
|
import { runSubtask } from "../commands/subtask";
|
|
12
13
|
import { runSync } from "../commands/sync";
|
|
13
14
|
import { runTask } from "../commands/task";
|
|
14
15
|
import { runWipe } from "../commands/wipe";
|
|
15
|
-
import { failResult, okResult, renderResult } from "../io/output";
|
|
16
|
+
import { failResult, okResult, renderResult, type RenderOptions } from "../io/output";
|
|
16
17
|
import { resolveStorageResolutionDiagnostics } from "../storage/database";
|
|
17
18
|
import { type CliContext, type CliResult, type CompatibilityMode, type OutputMode } from "./command-types";
|
|
18
19
|
import { CLI_VERSION } from "./version";
|
|
@@ -32,11 +33,13 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
|
|
|
32
33
|
"migrate",
|
|
33
34
|
"sync",
|
|
34
35
|
"skills",
|
|
36
|
+
"suggest",
|
|
35
37
|
"wipe",
|
|
36
38
|
];
|
|
37
39
|
|
|
38
40
|
export interface ParsedInvocation {
|
|
39
41
|
readonly mode: OutputMode;
|
|
42
|
+
readonly compact: boolean;
|
|
40
43
|
readonly compatibilityMode: CompatibilityMode | null;
|
|
41
44
|
readonly compatibilityModeRaw: string | null;
|
|
42
45
|
readonly compatibilityModeMissingValue: boolean;
|
|
@@ -53,6 +56,7 @@ export interface ParseInvocationOptions {
|
|
|
53
56
|
export function parseInvocation(argv: readonly string[], options: ParseInvocationOptions = {}): ParsedInvocation {
|
|
54
57
|
const stdoutIsTTY: boolean = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
|
|
55
58
|
let explicitMode: OutputMode | null = null;
|
|
59
|
+
let compact = false;
|
|
56
60
|
let compatibilityModeRaw: string | null = null;
|
|
57
61
|
let compatibilityModeMissingValue = false;
|
|
58
62
|
let wantsHelp = false;
|
|
@@ -75,6 +79,11 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
|
|
|
75
79
|
continue;
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
if (token === "--compact") {
|
|
83
|
+
compact = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
if (token === "--help" || token === "-h") {
|
|
79
88
|
wantsHelp = true;
|
|
80
89
|
continue;
|
|
@@ -105,6 +114,7 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
|
|
|
105
114
|
|
|
106
115
|
return {
|
|
107
116
|
mode: explicitMode ?? (stdoutIsTTY ? "human" : "json"),
|
|
117
|
+
compact,
|
|
108
118
|
compatibilityMode,
|
|
109
119
|
compatibilityModeRaw,
|
|
110
120
|
compatibilityModeMissingValue,
|
|
@@ -115,13 +125,23 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
|
|
|
115
125
|
};
|
|
116
126
|
}
|
|
117
127
|
|
|
118
|
-
export function renderShellResult(
|
|
128
|
+
export function renderShellResult(
|
|
129
|
+
result: CliResult,
|
|
130
|
+
mode: OutputMode,
|
|
131
|
+
compatibilityMode: CompatibilityMode | null = null,
|
|
132
|
+
options: { compact?: boolean } = {},
|
|
133
|
+
): string {
|
|
119
134
|
const effectiveCompatibilityMode: CompatibilityMode | null =
|
|
120
135
|
compatibilityMode === "legacy-sync-command-ids" && result.command.startsWith("sync.")
|
|
121
136
|
? compatibilityMode
|
|
122
137
|
: null;
|
|
123
138
|
|
|
124
|
-
|
|
139
|
+
const renderOptions: RenderOptions = {
|
|
140
|
+
compatibilityMode: effectiveCompatibilityMode,
|
|
141
|
+
compact: options.compact ?? false,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return renderResult(result, mode, renderOptions);
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
function isStringArray(value: unknown): value is string[] {
|
|
@@ -375,6 +395,9 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
|
|
|
375
395
|
case "skills":
|
|
376
396
|
result = await runSkills(context);
|
|
377
397
|
break;
|
|
398
|
+
case "suggest":
|
|
399
|
+
result = await runSuggest(context);
|
|
400
|
+
break;
|
|
378
401
|
default:
|
|
379
402
|
result = failResult({
|
|
380
403
|
command: "shell",
|
|
@@ -32,7 +32,7 @@ export interface ToonEnvelope {
|
|
|
32
32
|
readonly ok: boolean;
|
|
33
33
|
readonly command: string;
|
|
34
34
|
readonly data: unknown;
|
|
35
|
-
readonly metadata
|
|
35
|
+
readonly metadata?: ContractMetadata;
|
|
36
36
|
readonly error?: ToonError;
|
|
37
37
|
readonly meta?: Record<string, unknown>;
|
|
38
38
|
}
|
package/src/storage/database.ts
CHANGED
|
@@ -98,6 +98,48 @@ export function resolveStorageResolutionDiagnostics(
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Default connection-level busy_timeout applied at open time. */
|
|
102
|
+
const DEFAULT_BUSY_TIMEOUT_MS = 15000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Maximum time (ms) to wait when acquiring the write lock via BEGIN IMMEDIATE.
|
|
106
|
+
* Kept below the default bun test timeout so that lock-contention surfaces as
|
|
107
|
+
* a SQLITE_BUSY error rather than a test-level timeout.
|
|
108
|
+
*/
|
|
109
|
+
const WRITE_LOCK_BUSY_TIMEOUT_MS = 3000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Execute a write transaction using BEGIN IMMEDIATE to acquire a reserved lock
|
|
113
|
+
* up-front, avoiding SQLITE_BUSY errors that occur when a deferred transaction
|
|
114
|
+
* is promoted to a write lock after readers have already started.
|
|
115
|
+
*
|
|
116
|
+
* A shorter busy_timeout is applied while acquiring the lock so callers receive
|
|
117
|
+
* a prompt SQLITE_BUSY error instead of blocking for the full connection-level
|
|
118
|
+
* timeout. The connection-level timeout is restored before returning.
|
|
119
|
+
*/
|
|
120
|
+
export function writeTransaction<T>(db: Database, fn: (db: Database) => T): T {
|
|
121
|
+
db.exec(`PRAGMA busy_timeout = ${WRITE_LOCK_BUSY_TIMEOUT_MS};`);
|
|
122
|
+
try {
|
|
123
|
+
db.exec("BEGIN IMMEDIATE;");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
|
|
129
|
+
try {
|
|
130
|
+
const result: T = fn(db);
|
|
131
|
+
db.exec("COMMIT;");
|
|
132
|
+
return result;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
try {
|
|
135
|
+
db.exec("ROLLBACK;");
|
|
136
|
+
} catch {
|
|
137
|
+
/* best-effort rollback — propagate the original error */
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
101
143
|
export function openTrekoonDatabase(
|
|
102
144
|
workingDirectory: string = process.cwd(),
|
|
103
145
|
options: OpenTrekoonDatabaseOptions = {},
|
|
@@ -110,7 +152,7 @@ export function openTrekoonDatabase(
|
|
|
110
152
|
|
|
111
153
|
const db: Database = new Database(paths.databaseFile, { create: true });
|
|
112
154
|
|
|
113
|
-
db.exec(
|
|
155
|
+
db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
|
|
114
156
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
115
157
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
116
158
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
|
+
import { writeTransaction } from "./database";
|
|
4
|
+
|
|
3
5
|
export const DEFAULT_EVENT_RETENTION_DAYS = 90;
|
|
4
6
|
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
|
|
5
7
|
|
|
@@ -18,6 +20,7 @@ export interface EventPruneSummary {
|
|
|
18
20
|
readonly candidateCount: number;
|
|
19
21
|
readonly archivedCount: number;
|
|
20
22
|
readonly deletedCount: number;
|
|
23
|
+
readonly staleCursorCount: number;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
function ensureArchiveTable(db: Database): void {
|
|
@@ -53,27 +56,72 @@ function countCandidates(db: Database, cutoffTimestamp: number): number {
|
|
|
53
56
|
return row?.count ?? 0;
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
function oldestCursorTimestamp(db: Database): number | null {
|
|
60
|
+
const row = db
|
|
61
|
+
.query("SELECT MIN(last_event_at) AS oldest FROM sync_cursors WHERE last_event_at IS NOT NULL;")
|
|
62
|
+
.get() as { oldest: number | null } | null;
|
|
63
|
+
|
|
64
|
+
return row?.oldest ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countStaleCursors(db: Database): number {
|
|
68
|
+
// A cursor is stale when its last_event_at references a timestamp
|
|
69
|
+
// that has no corresponding event remaining in the events table.
|
|
70
|
+
// We detect this by checking if the oldest event in the table is
|
|
71
|
+
// newer than the cursor's last_event_at.
|
|
72
|
+
const oldestEventRow = db
|
|
73
|
+
.query("SELECT MIN(created_at) AS oldest FROM events;")
|
|
74
|
+
.get() as { oldest: number | null } | null;
|
|
75
|
+
|
|
76
|
+
const oldestEventAt: number | null = oldestEventRow?.oldest ?? null;
|
|
77
|
+
|
|
78
|
+
if (oldestEventAt === null) {
|
|
79
|
+
// No events at all — any cursor with a last_event_at is stale.
|
|
80
|
+
const row = db
|
|
81
|
+
.query("SELECT COUNT(*) AS count FROM sync_cursors WHERE last_event_at IS NOT NULL;")
|
|
82
|
+
.get() as { count: number } | null;
|
|
83
|
+
return row?.count ?? 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const row = db
|
|
87
|
+
.query(
|
|
88
|
+
"SELECT COUNT(*) AS count FROM sync_cursors WHERE last_event_at IS NOT NULL AND last_event_at < ?;",
|
|
89
|
+
)
|
|
90
|
+
.get(oldestEventAt) as { count: number } | null;
|
|
91
|
+
|
|
92
|
+
return row?.count ?? 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
|
|
57
96
|
const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
|
|
58
97
|
const dryRun: boolean = options.dryRun ?? false;
|
|
59
98
|
const archive: boolean = options.archive ?? false;
|
|
60
99
|
const now: number = options.now ?? Date.now();
|
|
61
|
-
const
|
|
62
|
-
|
|
100
|
+
const retentionCutoff: number = now - retentionDays * DAY_IN_MILLISECONDS;
|
|
101
|
+
|
|
102
|
+
// Guard: never prune events that a sync cursor still references.
|
|
103
|
+
// The effective cutoff is the earlier of the retention cutoff and
|
|
104
|
+
// the oldest cursor timestamp — so cursors always have replayable history.
|
|
105
|
+
const oldest: number | null = oldestCursorTimestamp(db);
|
|
106
|
+
const effectiveCutoff: number = oldest !== null ? Math.min(retentionCutoff, oldest) : retentionCutoff;
|
|
107
|
+
|
|
108
|
+
const candidateCount: number = countCandidates(db, effectiveCutoff);
|
|
109
|
+
const staleCursors: number = countStaleCursors(db);
|
|
63
110
|
|
|
64
111
|
if (dryRun || candidateCount === 0) {
|
|
65
112
|
return {
|
|
66
113
|
retentionDays,
|
|
67
|
-
cutoffTimestamp,
|
|
114
|
+
cutoffTimestamp: effectiveCutoff,
|
|
68
115
|
dryRun,
|
|
69
116
|
archive,
|
|
70
117
|
candidateCount,
|
|
71
118
|
archivedCount: 0,
|
|
72
119
|
deletedCount: 0,
|
|
120
|
+
staleCursorCount: staleCursors,
|
|
73
121
|
};
|
|
74
122
|
}
|
|
75
123
|
|
|
76
|
-
return db
|
|
124
|
+
return writeTransaction(db, (): EventPruneSummary => {
|
|
77
125
|
let archivedCount = 0;
|
|
78
126
|
|
|
79
127
|
if (archive) {
|
|
@@ -118,21 +166,22 @@ export function pruneEvents(db: Database, options: EventPruneOptions = {}): Even
|
|
|
118
166
|
version = excluded.version;
|
|
119
167
|
`,
|
|
120
168
|
)
|
|
121
|
-
.run(
|
|
169
|
+
.run(effectiveCutoff);
|
|
122
170
|
|
|
123
171
|
archivedCount = archived.changes;
|
|
124
172
|
}
|
|
125
173
|
|
|
126
|
-
const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(
|
|
174
|
+
const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(effectiveCutoff);
|
|
127
175
|
|
|
128
176
|
return {
|
|
129
177
|
retentionDays,
|
|
130
|
-
cutoffTimestamp,
|
|
178
|
+
cutoffTimestamp: effectiveCutoff,
|
|
131
179
|
dryRun,
|
|
132
180
|
archive,
|
|
133
181
|
candidateCount,
|
|
134
182
|
archivedCount,
|
|
135
183
|
deletedCount: deleted.changes,
|
|
184
|
+
staleCursorCount: staleCursors,
|
|
136
185
|
};
|
|
137
|
-
})
|
|
186
|
+
});
|
|
138
187
|
}
|
|
@@ -177,9 +177,64 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
177
177
|
up(db: Database): void {
|
|
178
178
|
migrateWorktreeScopedSyncMetadata(db);
|
|
179
179
|
},
|
|
180
|
-
down(
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
down(_db: Database): void {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"Migration 0004 (worktree_scoped_sync_metadata) is irreversible. " +
|
|
183
|
+
"It adds columns via ALTER TABLE that cannot be removed without " +
|
|
184
|
+
"reconstructing tables and risking data loss. " +
|
|
185
|
+
"Rollback below version 4 is not supported.",
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
version: 5,
|
|
191
|
+
name: "0005_dependency_edge_integrity",
|
|
192
|
+
up(db: Database): void {
|
|
193
|
+
// Clean up orphaned dependency rows where source or target no longer exists.
|
|
194
|
+
db.exec(`
|
|
195
|
+
DELETE FROM dependencies
|
|
196
|
+
WHERE source_id NOT IN (SELECT id FROM tasks UNION ALL SELECT id FROM subtasks)
|
|
197
|
+
OR depends_on_id NOT IN (SELECT id FROM tasks UNION ALL SELECT id FROM subtasks);
|
|
198
|
+
`);
|
|
199
|
+
|
|
200
|
+
// Deduplicate any existing duplicate edges before creating the unique index.
|
|
201
|
+
// Keep one arbitrary row per logical edge (MIN(id) is lexicographic, not chronological, but any survivor is equivalent).
|
|
202
|
+
db.exec(`
|
|
203
|
+
DELETE FROM dependencies
|
|
204
|
+
WHERE id NOT IN (
|
|
205
|
+
SELECT MIN(id) FROM dependencies
|
|
206
|
+
GROUP BY source_id, depends_on_id
|
|
207
|
+
);
|
|
208
|
+
`);
|
|
209
|
+
|
|
210
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_edge ON dependencies (source_id, depends_on_id);");
|
|
211
|
+
},
|
|
212
|
+
down(_db: Database): void {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"Migration 0005 (dependency_edge_integrity) is irreversible. " +
|
|
215
|
+
"It removes orphaned rows and deduplicates dependency edges. " +
|
|
216
|
+
"Rollback below version 5 is not supported.",
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
version: 6,
|
|
222
|
+
name: "0006_add_owner_column",
|
|
223
|
+
up(db: Database): void {
|
|
224
|
+
if (!tableHasColumn(db, "tasks", "owner")) {
|
|
225
|
+
db.exec("ALTER TABLE tasks ADD COLUMN owner TEXT;");
|
|
226
|
+
}
|
|
227
|
+
if (!tableHasColumn(db, "subtasks", "owner")) {
|
|
228
|
+
db.exec("ALTER TABLE subtasks ADD COLUMN owner TEXT;");
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
down(_db: Database): void {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"Migration 0006 (add_owner_column) is irreversible. " +
|
|
234
|
+
"It adds columns via ALTER TABLE that cannot be removed without " +
|
|
235
|
+
"reconstructing tables and risking data loss. " +
|
|
236
|
+
"Rollback below version 6 is not supported.",
|
|
237
|
+
);
|
|
183
238
|
},
|
|
184
239
|
},
|
|
185
240
|
];
|
package/src/sync/service.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { type Database } from "bun:sqlite";
|
|
4
4
|
|
|
5
|
-
import { openTrekoonDatabase } from "../storage/database";
|
|
5
|
+
import { openTrekoonDatabase, writeTransaction } from "../storage/database";
|
|
6
6
|
import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
|
|
7
7
|
import { persistGitContext, resolveGitContext } from "./git-context";
|
|
8
8
|
import {
|
|
@@ -15,6 +15,49 @@ import {
|
|
|
15
15
|
type SyncStatusSummary,
|
|
16
16
|
} from "./types";
|
|
17
17
|
|
|
18
|
+
function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
|
|
19
|
+
if (cursorToken === "0:") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const [createdAtRaw, idRaw] = cursorToken.split(":");
|
|
24
|
+
const createdAt: number = Number.parseInt(createdAtRaw ?? "0", 10);
|
|
25
|
+
const id: string = idRaw ?? "";
|
|
26
|
+
|
|
27
|
+
if (!Number.isFinite(createdAt) || createdAt === 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check if the event referenced by the cursor still exists.
|
|
32
|
+
// If the cursor references a specific event id, check for it.
|
|
33
|
+
// Otherwise, check if any event at or after the cursor timestamp exists
|
|
34
|
+
// on the source branch.
|
|
35
|
+
if (id.length > 0) {
|
|
36
|
+
const row = db
|
|
37
|
+
.query("SELECT id FROM events WHERE id = ? LIMIT 1;")
|
|
38
|
+
.get(id) as { id: string } | null;
|
|
39
|
+
if (row) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The referenced event is gone. Check if there are any events on the
|
|
45
|
+
// source branch at or after the cursor timestamp — if not, the cursor
|
|
46
|
+
// may simply be at the end of the stream.
|
|
47
|
+
const newerRow = db
|
|
48
|
+
.query(
|
|
49
|
+
`SELECT id FROM events
|
|
50
|
+
WHERE git_branch = ? AND created_at >= ?
|
|
51
|
+
ORDER BY created_at ASC, id ASC
|
|
52
|
+
LIMIT 1;`,
|
|
53
|
+
)
|
|
54
|
+
.get(sourceBranch, createdAt) as { id: string } | null;
|
|
55
|
+
|
|
56
|
+
// If there are newer events but our referenced event is gone,
|
|
57
|
+
// events between the cursor and the oldest remaining event were pruned.
|
|
58
|
+
return newerRow !== null;
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
interface StoredEvent {
|
|
19
62
|
readonly id: string;
|
|
20
63
|
readonly entity_kind: string;
|
|
@@ -289,23 +332,22 @@ function entityFieldConflict(
|
|
|
289
332
|
return null;
|
|
290
333
|
}
|
|
291
334
|
|
|
335
|
+
// Note: loads all matching events into memory. For entities with very large
|
|
336
|
+
// event histories, consider a cursor-based scan. The idx_events_entity index
|
|
337
|
+
// keeps the scan narrow by (entity_kind, entity_id).
|
|
292
338
|
const rows = localDb
|
|
293
339
|
.query(
|
|
294
340
|
`
|
|
295
341
|
SELECT payload, git_branch
|
|
296
342
|
FROM events
|
|
297
|
-
WHERE entity_kind = ? AND entity_id = ?
|
|
343
|
+
WHERE entity_kind = ? AND entity_id = ? AND git_branch != ?
|
|
298
344
|
ORDER BY created_at DESC, id DESC
|
|
299
|
-
LIMIT
|
|
300
|
-
|
|
345
|
+
LIMIT 500;
|
|
346
|
+
`,
|
|
301
347
|
)
|
|
302
|
-
.all(event.entity_kind, event.entity_id) as Array<{ payload: string; git_branch: string | null }>;
|
|
348
|
+
.all(event.entity_kind, event.entity_id, sourceBranch) as Array<{ payload: string; git_branch: string | null }>;
|
|
303
349
|
|
|
304
350
|
for (const row of rows) {
|
|
305
|
-
if (row.git_branch === sourceBranch) {
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
351
|
const payloadValidation = parsePayload(row.payload);
|
|
310
352
|
if (!payloadValidation.ok) {
|
|
311
353
|
continue;
|
|
@@ -360,6 +402,15 @@ function createConflict(
|
|
|
360
402
|
).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
|
|
361
403
|
}
|
|
362
404
|
|
|
405
|
+
function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
|
|
406
|
+
const row = db
|
|
407
|
+
.query(
|
|
408
|
+
`SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch != ? LIMIT 1;`,
|
|
409
|
+
)
|
|
410
|
+
.get(entityKind, entityId, sourceBranch);
|
|
411
|
+
return row !== null;
|
|
412
|
+
}
|
|
413
|
+
|
|
363
414
|
function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
364
415
|
const row = db.query(`SELECT id FROM ${tableName} WHERE id = ? LIMIT 1;`).get(id) as { id: string } | null;
|
|
365
416
|
return row !== null;
|
|
@@ -495,10 +546,9 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
495
546
|
updated_at,
|
|
496
547
|
version
|
|
497
548
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
498
|
-
ON CONFLICT(
|
|
499
|
-
|
|
549
|
+
ON CONFLICT(source_id, depends_on_id) DO UPDATE SET
|
|
550
|
+
id = excluded.id,
|
|
500
551
|
source_kind = excluded.source_kind,
|
|
501
|
-
depends_on_id = excluded.depends_on_id,
|
|
502
552
|
depends_on_kind = excluded.depends_on_kind,
|
|
503
553
|
updated_at = excluded.updated_at,
|
|
504
554
|
version = dependencies.version + 1;
|
|
@@ -689,6 +739,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
689
739
|
persistGitContext(storage.db, git);
|
|
690
740
|
const cursor = loadCursor(storage.db, git.worktreePath, sourceBranch);
|
|
691
741
|
const cursorToken = cursor?.cursor_token ?? "0:";
|
|
742
|
+
const staleCursor: boolean = cursor !== null && isCursorStale(storage.db, cursorToken, sourceBranch);
|
|
692
743
|
const incomingEvents: StoredEvent[] = queryBranchEventsSince(storage.db, sourceBranch, cursorToken) as StoredEvent[];
|
|
693
744
|
|
|
694
745
|
// Same-branch fast path: skip conflict detection when already on sourceBranch.
|
|
@@ -697,7 +748,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
697
748
|
let lastToken: string | null = null;
|
|
698
749
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
699
750
|
|
|
700
|
-
storage.db
|
|
751
|
+
writeTransaction(storage.db, (): void => {
|
|
701
752
|
for (const incoming of incomingEvents) {
|
|
702
753
|
storeEvent(storage.db, incoming);
|
|
703
754
|
lastToken = cursorTokenFromEvent(incoming);
|
|
@@ -707,7 +758,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
707
758
|
if (lastToken) {
|
|
708
759
|
saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
|
|
709
760
|
}
|
|
710
|
-
})
|
|
761
|
+
});
|
|
711
762
|
|
|
712
763
|
return {
|
|
713
764
|
sourceBranch,
|
|
@@ -721,7 +772,10 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
721
772
|
applyRejectedEvents: 0,
|
|
722
773
|
quarantinedEvents: 0,
|
|
723
774
|
conflictEvents: 0,
|
|
724
|
-
|
|
775
|
+
staleCursor,
|
|
776
|
+
errorHints: staleCursor
|
|
777
|
+
? ["Stale cursor detected; some events may have been pruned. Consider a full rebuild."]
|
|
778
|
+
: [],
|
|
725
779
|
},
|
|
726
780
|
};
|
|
727
781
|
}
|
|
@@ -735,7 +789,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
735
789
|
let lastToken: string | null = null;
|
|
736
790
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
737
791
|
|
|
738
|
-
storage.db
|
|
792
|
+
writeTransaction(storage.db, (): void => {
|
|
739
793
|
for (const incoming of incomingEvents) {
|
|
740
794
|
const payloadValidation = parsePayload(incoming.payload);
|
|
741
795
|
|
|
@@ -758,6 +812,23 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
758
812
|
}
|
|
759
813
|
|
|
760
814
|
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
815
|
+
|
|
816
|
+
const isDeleteWithLocalEdits =
|
|
817
|
+
incoming.operation.endsWith(".deleted") &&
|
|
818
|
+
hasLocalEntityEdits(storage.db, incoming.entity_kind, incoming.entity_id, sourceBranch);
|
|
819
|
+
if (isDeleteWithLocalEdits) {
|
|
820
|
+
// Note: dependency.removed is intentionally excluded — dependencies are
|
|
821
|
+
// edges (not entities with local edit history), so conflict detection
|
|
822
|
+
// does not apply to them.
|
|
823
|
+
createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
|
|
824
|
+
createdConflicts += 1;
|
|
825
|
+
conflictEvents += 1;
|
|
826
|
+
storeEvent(storage.db, incoming);
|
|
827
|
+
lastToken = cursorTokenFromEvent(incoming);
|
|
828
|
+
lastEventAt = incoming.created_at;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
|
|
761
832
|
const fieldsToApply: Record<string, unknown> = {};
|
|
762
833
|
let withheldConflictCount = 0;
|
|
763
834
|
|
|
@@ -801,7 +872,16 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
801
872
|
if (lastToken) {
|
|
802
873
|
saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
|
|
803
874
|
}
|
|
804
|
-
})
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const errorHints: string[] = buildSyncErrorHints({
|
|
878
|
+
malformedPayloadEvents,
|
|
879
|
+
applyRejectedEvents,
|
|
880
|
+
conflictEvents,
|
|
881
|
+
});
|
|
882
|
+
if (staleCursor) {
|
|
883
|
+
errorHints.push("Stale cursor detected; some events may have been pruned. Consider a full rebuild.");
|
|
884
|
+
}
|
|
805
885
|
|
|
806
886
|
return {
|
|
807
887
|
sourceBranch,
|
|
@@ -815,11 +895,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
815
895
|
applyRejectedEvents,
|
|
816
896
|
quarantinedEvents,
|
|
817
897
|
conflictEvents,
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
applyRejectedEvents,
|
|
821
|
-
conflictEvents,
|
|
822
|
-
}),
|
|
898
|
+
staleCursor,
|
|
899
|
+
errorHints,
|
|
823
900
|
},
|
|
824
901
|
};
|
|
825
902
|
} finally {
|
|
@@ -1010,7 +1087,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
1010
1087
|
throw new Error(`Conflict '${conflictId}' already resolved.`);
|
|
1011
1088
|
}
|
|
1012
1089
|
|
|
1013
|
-
storage.db
|
|
1090
|
+
writeTransaction(storage.db, (): void => {
|
|
1014
1091
|
if (resolution === "theirs") {
|
|
1015
1092
|
updateSingleField(
|
|
1016
1093
|
storage.db,
|
|
@@ -1027,7 +1104,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
1027
1104
|
.run(resolution, now, conflict.id);
|
|
1028
1105
|
|
|
1029
1106
|
appendResolutionEvent(storage.db, git.branchName, git.headSha, conflict, resolution);
|
|
1030
|
-
})
|
|
1107
|
+
});
|
|
1031
1108
|
|
|
1032
1109
|
return {
|
|
1033
1110
|
conflictId,
|
package/src/sync/types.ts
CHANGED