trekoon 0.1.6 → 0.1.7
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/README.md +14 -1
- package/package.json +1 -1
- package/src/commands/help.ts +1 -1
- package/src/commands/skills.ts +177 -14
- package/src/commands/sync.ts +4 -0
- package/src/storage/migrations.ts +48 -1
- package/src/sync/service.ts +67 -9
- package/src/sync/types.ts +9 -0
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npm i -g trekoon
|
|
|
52
52
|
- `trekoon subtask <create|list|update|delete>`
|
|
53
53
|
- `trekoon dep <add|remove|list>`
|
|
54
54
|
- `trekoon sync <status|pull|resolve>`
|
|
55
|
-
- `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>]`
|
|
55
|
+
- `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]`
|
|
56
56
|
- `trekoon skills update`
|
|
57
57
|
- `trekoon wipe --yes`
|
|
58
58
|
|
|
@@ -161,6 +161,15 @@ trekoon sync pull --from main
|
|
|
161
161
|
trekoon sync resolve <conflict-id> --use ours
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
`sync pull` machine output includes diagnostics counters and hints so agents can
|
|
165
|
+
react deterministically:
|
|
166
|
+
|
|
167
|
+
- `diagnostics.malformedPayloadEvents`
|
|
168
|
+
- `diagnostics.applyRejectedEvents`
|
|
169
|
+
- `diagnostics.quarantinedEvents`
|
|
170
|
+
- `diagnostics.conflictEvents`
|
|
171
|
+
- `diagnostics.errorHints`
|
|
172
|
+
|
|
164
173
|
### 6) Install project-local Trekoon skill for agents
|
|
165
174
|
|
|
166
175
|
`trekoon skills install` always writes the bundled skill file into the current
|
|
@@ -186,6 +195,10 @@ Path behavior:
|
|
|
186
195
|
- Default pi link path: `.pi/skills/trekoon`
|
|
187
196
|
- `--to <path>` overrides the editor root for link creation only.
|
|
188
197
|
- `--to` does **not** move or copy `SKILL.md` to that path.
|
|
198
|
+
- By default, link targets must resolve inside the repository root.
|
|
199
|
+
- Use `--allow-outside-repo` only for intentional external links.
|
|
200
|
+
- When override is used, install prints a warning and includes confirmation
|
|
201
|
+
fields in machine output.
|
|
189
202
|
- Re-running install is idempotent: it refreshes `SKILL.md` and reuses/replaces
|
|
190
203
|
the same symlink target.
|
|
191
204
|
- `trekoon skills update` is idempotent: it refreshes canonical
|
package/package.json
CHANGED
package/src/commands/help.ts
CHANGED
|
@@ -42,7 +42,7 @@ const COMMAND_HELP: Record<string, string> = {
|
|
|
42
42
|
migrate: "Usage: trekoon migrate <status|rollback> [--to-version <n>]",
|
|
43
43
|
sync: "Usage: trekoon sync <subcommand> [options]",
|
|
44
44
|
skills:
|
|
45
|
-
"Usage: trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] | trekoon skills update (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md; update refreshes canonical SKILL and reports default link states)",
|
|
45
|
+
"Usage: trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo] | trekoon skills update (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md; links must resolve inside repo unless --allow-outside-repo is set; update refreshes canonical SKILL and reports default link states)",
|
|
46
46
|
help: "Usage: trekoon help [command] [--json|--toon]",
|
|
47
47
|
};
|
|
48
48
|
|
package/src/commands/skills.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
1
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
5
|
import { hasFlag, parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
|
|
@@ -9,10 +9,11 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
9
9
|
|
|
10
10
|
const SKILLS_USAGE = [
|
|
11
11
|
"Usage:",
|
|
12
|
-
" trekoon skills install [--link --editor opencode|claude|pi] [--to <path>]",
|
|
12
|
+
" trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
|
|
13
13
|
" trekoon skills update",
|
|
14
14
|
].join("\n");
|
|
15
15
|
const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
|
|
16
|
+
const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
|
|
16
17
|
|
|
17
18
|
type EditorName = (typeof EDITOR_NAMES)[number];
|
|
18
19
|
type LinkStateStatus = "missing" | "valid" | "conflict";
|
|
@@ -23,6 +24,12 @@ interface InstallOutcome {
|
|
|
23
24
|
readonly installedDir: string;
|
|
24
25
|
readonly linkPath: string | null;
|
|
25
26
|
readonly linkTarget: string | null;
|
|
27
|
+
readonly outsideRepoLink: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LinkTargetValidation {
|
|
31
|
+
readonly linkRoot: string;
|
|
32
|
+
readonly outsideRepoLink: boolean;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
interface LinkState {
|
|
@@ -96,6 +103,114 @@ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | u
|
|
|
96
103
|
return join(cwd, ".pi", "skills");
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
function isPathInsideRoot(pathValue: string, rootPath: string): boolean {
|
|
107
|
+
const normalizedPath: string = resolve(pathValue);
|
|
108
|
+
const normalizedRoot: string = resolve(rootPath);
|
|
109
|
+
const relativePath: string = relative(normalizedRoot, normalizedPath);
|
|
110
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function realpathNearestExistingAncestor(pathValue: string): string {
|
|
114
|
+
let cursor: string = resolve(pathValue);
|
|
115
|
+
|
|
116
|
+
while (!existsSync(cursor)) {
|
|
117
|
+
const parent: string = dirname(cursor);
|
|
118
|
+
if (parent === cursor) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cursor = parent;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return realpathSync(cursor);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateLinkRoot(
|
|
129
|
+
cwd: string,
|
|
130
|
+
editor: EditorName,
|
|
131
|
+
toOverride: string | undefined,
|
|
132
|
+
allowOutsideRepo: boolean,
|
|
133
|
+
): CliResult | LinkTargetValidation {
|
|
134
|
+
const linkRoot: string = resolveLinkRoot(cwd, editor, toOverride);
|
|
135
|
+
const repoRoot: string = realpathSync(cwd);
|
|
136
|
+
const effectiveTargetRoot: string = realpathNearestExistingAncestor(linkRoot);
|
|
137
|
+
const insideRepo: boolean = isPathInsideRoot(effectiveTargetRoot, repoRoot);
|
|
138
|
+
|
|
139
|
+
if (insideRepo) {
|
|
140
|
+
return {
|
|
141
|
+
linkRoot,
|
|
142
|
+
outsideRepoLink: false,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!allowOutsideRepo) {
|
|
147
|
+
return failResult({
|
|
148
|
+
command: "skills.install",
|
|
149
|
+
human: [
|
|
150
|
+
"Refusing to link skills outside repository root by default.",
|
|
151
|
+
`Requested link root: ${linkRoot}`,
|
|
152
|
+
`Resolved existing target ancestor: ${effectiveTargetRoot}`,
|
|
153
|
+
`Repository root: ${repoRoot}`,
|
|
154
|
+
`If intentional, re-run with --${ALLOW_OUTSIDE_REPO_FLAG} to override.`,
|
|
155
|
+
].join("\n"),
|
|
156
|
+
data: {
|
|
157
|
+
code: "outside_repo_target",
|
|
158
|
+
linkRoot,
|
|
159
|
+
effectiveTargetRoot,
|
|
160
|
+
repoRoot,
|
|
161
|
+
overrideFlag: `--${ALLOW_OUTSIDE_REPO_FLAG}`,
|
|
162
|
+
},
|
|
163
|
+
error: {
|
|
164
|
+
code: "outside_repo_target",
|
|
165
|
+
message: "Link target is outside repository root",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
linkRoot,
|
|
172
|
+
outsideRepoLink: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function revalidateLinkParentBoundary(
|
|
177
|
+
repoRoot: string,
|
|
178
|
+
linkPath: string,
|
|
179
|
+
allowOutsideRepo: boolean,
|
|
180
|
+
): CliResult | null {
|
|
181
|
+
if (allowOutsideRepo) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const linkParentRealpath: string = realpathSync(dirname(linkPath));
|
|
186
|
+
const insideRepo: boolean = isPathInsideRoot(linkParentRealpath, repoRoot);
|
|
187
|
+
if (insideRepo) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return failResult({
|
|
192
|
+
command: "skills.install",
|
|
193
|
+
human: [
|
|
194
|
+
"Refusing to link skills outside repository root by default.",
|
|
195
|
+
`Requested link root: ${dirname(linkPath)}`,
|
|
196
|
+
`Resolved existing target ancestor: ${linkParentRealpath}`,
|
|
197
|
+
`Repository root: ${repoRoot}`,
|
|
198
|
+
`If intentional, re-run with --${ALLOW_OUTSIDE_REPO_FLAG} to override.`,
|
|
199
|
+
].join("\n"),
|
|
200
|
+
data: {
|
|
201
|
+
code: "outside_repo_target",
|
|
202
|
+
linkRoot: dirname(linkPath),
|
|
203
|
+
effectiveTargetRoot: linkParentRealpath,
|
|
204
|
+
repoRoot,
|
|
205
|
+
overrideFlag: `--${ALLOW_OUTSIDE_REPO_FLAG}`,
|
|
206
|
+
},
|
|
207
|
+
error: {
|
|
208
|
+
code: "outside_repo_target",
|
|
209
|
+
message: "Link target is outside repository root",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
99
214
|
function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
|
|
100
215
|
return join(resolveLinkRoot(cwd, editor, undefined), "trekoon");
|
|
101
216
|
}
|
|
@@ -146,9 +261,18 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
|
|
|
146
261
|
};
|
|
147
262
|
}
|
|
148
263
|
|
|
149
|
-
function replaceOrCreateSymlink(
|
|
264
|
+
function replaceOrCreateSymlink(
|
|
265
|
+
linkPath: string,
|
|
266
|
+
targetPath: string,
|
|
267
|
+
repoRoot: string,
|
|
268
|
+
allowOutsideRepo: boolean,
|
|
269
|
+
): CliResult | null {
|
|
150
270
|
if (!existsSync(linkPath)) {
|
|
151
271
|
mkdirSync(dirname(linkPath), { recursive: true });
|
|
272
|
+
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
273
|
+
if (boundaryFailure) {
|
|
274
|
+
return boundaryFailure;
|
|
275
|
+
}
|
|
152
276
|
symlinkSync(targetPath, linkPath, "dir");
|
|
153
277
|
return null;
|
|
154
278
|
}
|
|
@@ -191,6 +315,10 @@ function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult
|
|
|
191
315
|
}
|
|
192
316
|
|
|
193
317
|
rmSync(linkPath, { force: true });
|
|
318
|
+
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
319
|
+
if (boundaryFailure) {
|
|
320
|
+
return boundaryFailure;
|
|
321
|
+
}
|
|
194
322
|
symlinkSync(targetPath, linkPath, "dir");
|
|
195
323
|
return null;
|
|
196
324
|
}
|
|
@@ -259,9 +387,16 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
259
387
|
}
|
|
260
388
|
|
|
261
389
|
const wantsLink: boolean = hasFlag(parsed.flags, "link");
|
|
390
|
+
const allowOutsideRepo: boolean = hasFlag(parsed.flags, ALLOW_OUTSIDE_REPO_FLAG);
|
|
262
391
|
const rawEditor: string | undefined = readOption(parsed.options, "editor");
|
|
263
392
|
const rawTo: string | undefined = readOption(parsed.options, "to");
|
|
264
393
|
|
|
394
|
+
if (allowOutsideRepo && !wantsLink) {
|
|
395
|
+
return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} requires --link.`, {
|
|
396
|
+
allowOutsideRepo,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
265
400
|
if (!wantsLink && rawEditor !== undefined) {
|
|
266
401
|
return invalidInput("skills.install", "--editor requires --link.", {
|
|
267
402
|
editor: rawEditor,
|
|
@@ -299,22 +434,42 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
299
434
|
let linkTarget: string | null = null;
|
|
300
435
|
|
|
301
436
|
if (wantsLink && editor !== undefined) {
|
|
302
|
-
const
|
|
437
|
+
const validation = validateLinkRoot(context.cwd, editor, rawTo, allowOutsideRepo);
|
|
438
|
+
if ("ok" in validation) {
|
|
439
|
+
return validation;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const linkRoot: string = validation.linkRoot;
|
|
303
443
|
linkPath = join(linkRoot, "trekoon");
|
|
304
444
|
linkTarget = installResult.installedDir;
|
|
305
|
-
const linkFailure = replaceOrCreateSymlink(
|
|
445
|
+
const linkFailure = replaceOrCreateSymlink(
|
|
446
|
+
linkPath,
|
|
447
|
+
linkTarget,
|
|
448
|
+
realpathSync(context.cwd),
|
|
449
|
+
allowOutsideRepo,
|
|
450
|
+
);
|
|
306
451
|
if (linkFailure) {
|
|
307
452
|
return linkFailure;
|
|
308
453
|
}
|
|
309
|
-
}
|
|
310
454
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
455
|
+
outcome = {
|
|
456
|
+
sourcePath: installResult.sourcePath,
|
|
457
|
+
installedPath: installResult.installedPath,
|
|
458
|
+
installedDir: installResult.installedDir,
|
|
459
|
+
linkPath,
|
|
460
|
+
linkTarget,
|
|
461
|
+
outsideRepoLink: validation.outsideRepoLink,
|
|
462
|
+
};
|
|
463
|
+
} else {
|
|
464
|
+
outcome = {
|
|
465
|
+
sourcePath: installResult.sourcePath,
|
|
466
|
+
installedPath: installResult.installedPath,
|
|
467
|
+
installedDir: installResult.installedDir,
|
|
468
|
+
linkPath,
|
|
469
|
+
linkTarget,
|
|
470
|
+
outsideRepoLink: false,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
318
473
|
} catch (error: unknown) {
|
|
319
474
|
const message = error instanceof Error ? error.message : "Unknown skills install failure";
|
|
320
475
|
return failResult({
|
|
@@ -335,6 +490,11 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
335
490
|
command: "skills.install",
|
|
336
491
|
human: outcome.linkPath
|
|
337
492
|
? [
|
|
493
|
+
...(outcome.outsideRepoLink
|
|
494
|
+
? [
|
|
495
|
+
`WARNING: Linking outside repository root because --${ALLOW_OUTSIDE_REPO_FLAG} was provided.`,
|
|
496
|
+
]
|
|
497
|
+
: []),
|
|
338
498
|
"Installed Trekoon skill and linked editor path.",
|
|
339
499
|
`Source: ${outcome.sourcePath}`,
|
|
340
500
|
`Installed file: ${outcome.installedPath}`,
|
|
@@ -353,6 +513,9 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
353
513
|
linked: outcome.linkPath !== null,
|
|
354
514
|
linkPath: outcome.linkPath,
|
|
355
515
|
linkTarget: outcome.linkTarget,
|
|
516
|
+
outsideRepoLink: outcome.outsideRepoLink,
|
|
517
|
+
outsideRepoOverrideUsed: outcome.outsideRepoLink,
|
|
518
|
+
outsideRepoOverrideFlag: outcome.outsideRepoLink ? `--${ALLOW_OUTSIDE_REPO_FLAG}` : null,
|
|
356
519
|
},
|
|
357
520
|
});
|
|
358
521
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -113,6 +113,10 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
113
113
|
`Scanned events: ${summary.scannedEvents}`,
|
|
114
114
|
`Applied events: ${summary.appliedEvents}`,
|
|
115
115
|
`Created conflicts: ${summary.createdConflicts}`,
|
|
116
|
+
`Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
|
|
117
|
+
`Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
|
|
118
|
+
`Conflict events: ${summary.diagnostics.conflictEvents}`,
|
|
119
|
+
...summary.diagnostics.errorHints,
|
|
116
120
|
].join("\n"),
|
|
117
121
|
data: summary,
|
|
118
122
|
});
|
|
@@ -278,11 +278,58 @@ function recordMigration(db: Database, migration: Migration): void {
|
|
|
278
278
|
);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
function isSchemaCurrentFastPath(db: Database, latestVersion: number): boolean {
|
|
282
|
+
if (latestVersion === 0 || !migrationTableExists(db) || !hasMigrationVersionColumn(db)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const row = db
|
|
287
|
+
.query(
|
|
288
|
+
`
|
|
289
|
+
SELECT
|
|
290
|
+
COALESCE(MIN(version), 0) AS min_version,
|
|
291
|
+
COALESCE(MAX(version), 0) AS max_version,
|
|
292
|
+
COUNT(DISTINCT version) AS distinct_versions,
|
|
293
|
+
SUM(CASE WHEN version IS NULL THEN 1 ELSE 0 END) AS null_versions
|
|
294
|
+
FROM schema_migrations;
|
|
295
|
+
`,
|
|
296
|
+
)
|
|
297
|
+
.get() as
|
|
298
|
+
| {
|
|
299
|
+
min_version: number;
|
|
300
|
+
max_version: number;
|
|
301
|
+
distinct_versions: number;
|
|
302
|
+
null_versions: number;
|
|
303
|
+
}
|
|
304
|
+
| null;
|
|
305
|
+
|
|
306
|
+
if (!row) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
row.null_versions === 0 &&
|
|
312
|
+
row.min_version === 1 &&
|
|
313
|
+
row.max_version === latestVersion &&
|
|
314
|
+
row.distinct_versions === latestVersion
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
281
318
|
export function migrateDatabase(db: Database): void {
|
|
319
|
+
validateMigrationPlan();
|
|
320
|
+
|
|
321
|
+
const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
322
|
+
|
|
323
|
+
// Fast path: avoid BEGIN EXCLUSIVE when schema is already current.
|
|
324
|
+
// This reduces startup lock contention while keeping the explicit
|
|
325
|
+
// transactional migration path for non-current/legacy schemas.
|
|
326
|
+
if (isSchemaCurrentFastPath(db, latestVersion)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
282
330
|
runExclusive(db, (): void => {
|
|
283
331
|
ensureMigrationTable(db);
|
|
284
332
|
ensureMigrationVersionColumn(db);
|
|
285
|
-
validateMigrationPlan();
|
|
286
333
|
|
|
287
334
|
const version: number = currentVersion(db);
|
|
288
335
|
|
package/src/sync/service.ts
CHANGED
|
@@ -225,17 +225,55 @@ function countBehind(remoteDb: Database, cursorToken: string): number {
|
|
|
225
225
|
return row?.count ?? 0;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
function countAhead(localDb: Database, currentBranch: string | null, remoteDbPath: string): number {
|
|
229
|
+
if (!currentBranch) {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
localDb.query("ATTACH DATABASE ? AS sync_remote;").run(remoteDbPath);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const row = localDb
|
|
237
|
+
.query(
|
|
238
|
+
`
|
|
239
|
+
SELECT COUNT(*) AS count
|
|
240
|
+
FROM events AS local_events
|
|
241
|
+
WHERE local_events.git_branch = @branch
|
|
242
|
+
AND NOT EXISTS (
|
|
243
|
+
SELECT 1
|
|
244
|
+
FROM sync_remote.events AS remote_events
|
|
245
|
+
WHERE remote_events.id = local_events.id
|
|
246
|
+
);
|
|
247
|
+
`,
|
|
248
|
+
)
|
|
249
|
+
.get({ "@branch": currentBranch }) as { count: number } | null;
|
|
250
|
+
|
|
251
|
+
return row?.count ?? 0;
|
|
252
|
+
} finally {
|
|
253
|
+
localDb.query("DETACH DATABASE sync_remote;").run();
|
|
254
|
+
}
|
|
231
255
|
}
|
|
232
256
|
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
257
|
+
function buildSyncErrorHints(diagnostics: {
|
|
258
|
+
malformedPayloadEvents: number;
|
|
259
|
+
applyRejectedEvents: number;
|
|
260
|
+
conflictEvents: number;
|
|
261
|
+
}): string[] {
|
|
262
|
+
const hints: string[] = [];
|
|
263
|
+
|
|
264
|
+
if (diagnostics.malformedPayloadEvents > 0) {
|
|
265
|
+
hints.push("Malformed event payloads were quarantined; inspect sync conflicts with field '__payload__'.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (diagnostics.applyRejectedEvents > 0) {
|
|
269
|
+
hints.push("Some events were quarantined as invalid; inspect sync conflicts with field '__apply__'.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (diagnostics.conflictEvents > 0) {
|
|
273
|
+
hints.push("Field-level conflicts detected; run 'trekoon sync conflicts list' and resolve pending entries.");
|
|
274
|
+
}
|
|
237
275
|
|
|
238
|
-
return
|
|
276
|
+
return hints;
|
|
239
277
|
}
|
|
240
278
|
|
|
241
279
|
function readFieldValue(payload: EventPayload, field: string): unknown {
|
|
@@ -631,7 +669,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
|
|
|
631
669
|
try {
|
|
632
670
|
return {
|
|
633
671
|
sourceBranch,
|
|
634
|
-
ahead: countAhead(storage.db, git.branchName,
|
|
672
|
+
ahead: countAhead(storage.db, git.branchName, remote.path),
|
|
635
673
|
behind: countBehind(remote.db, cursorToken),
|
|
636
674
|
pendingConflicts: countPendingConflicts(storage.db),
|
|
637
675
|
git,
|
|
@@ -658,6 +696,10 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
658
696
|
|
|
659
697
|
let appliedEvents = 0;
|
|
660
698
|
let createdConflicts = 0;
|
|
699
|
+
let malformedPayloadEvents = 0;
|
|
700
|
+
let applyRejectedEvents = 0;
|
|
701
|
+
let quarantinedEvents = 0;
|
|
702
|
+
let conflictEvents = 0;
|
|
661
703
|
let lastToken: string | null = null;
|
|
662
704
|
let lastEventAt: number | null = cursor?.last_event_at ?? null;
|
|
663
705
|
|
|
@@ -666,6 +708,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
666
708
|
const payloadValidation = parsePayload(incoming.payload);
|
|
667
709
|
|
|
668
710
|
if (!payloadValidation.ok) {
|
|
711
|
+
malformedPayloadEvents += 1;
|
|
712
|
+
quarantinedEvents += 1;
|
|
669
713
|
createConflict(
|
|
670
714
|
storage.db,
|
|
671
715
|
incoming,
|
|
@@ -688,6 +732,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
688
732
|
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
689
733
|
|
|
690
734
|
if (conflict) {
|
|
735
|
+
conflictEvents += 1;
|
|
691
736
|
createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
|
|
692
737
|
createdConflicts += 1;
|
|
693
738
|
continue;
|
|
@@ -699,6 +744,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
699
744
|
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
700
745
|
appliedEvents += 1;
|
|
701
746
|
} else if (incoming.operation !== "resolve_conflict") {
|
|
747
|
+
applyRejectedEvents += 1;
|
|
748
|
+
quarantinedEvents += 1;
|
|
702
749
|
createConflict(
|
|
703
750
|
storage.db,
|
|
704
751
|
incoming,
|
|
@@ -726,6 +773,17 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
726
773
|
appliedEvents,
|
|
727
774
|
createdConflicts,
|
|
728
775
|
cursorToken: lastToken,
|
|
776
|
+
diagnostics: {
|
|
777
|
+
malformedPayloadEvents,
|
|
778
|
+
applyRejectedEvents,
|
|
779
|
+
quarantinedEvents,
|
|
780
|
+
conflictEvents,
|
|
781
|
+
errorHints: buildSyncErrorHints({
|
|
782
|
+
malformedPayloadEvents,
|
|
783
|
+
applyRejectedEvents,
|
|
784
|
+
conflictEvents,
|
|
785
|
+
}),
|
|
786
|
+
},
|
|
729
787
|
};
|
|
730
788
|
} finally {
|
|
731
789
|
remote.close();
|
package/src/sync/types.ts
CHANGED
|
@@ -21,6 +21,15 @@ export interface PullSummary {
|
|
|
21
21
|
readonly appliedEvents: number;
|
|
22
22
|
readonly createdConflicts: number;
|
|
23
23
|
readonly cursorToken: string | null;
|
|
24
|
+
readonly diagnostics: SyncPullDiagnostics;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SyncPullDiagnostics {
|
|
28
|
+
readonly malformedPayloadEvents: number;
|
|
29
|
+
readonly applyRejectedEvents: number;
|
|
30
|
+
readonly quarantinedEvents: number;
|
|
31
|
+
readonly conflictEvents: number;
|
|
32
|
+
readonly errorHints: readonly string[];
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface ResolveSummary {
|