@indigoai-us/hq-cloud 6.14.21 → 6.14.23
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/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +9 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-events.test.js +22 -0
- package/dist/bin/sync-runner-events.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +27 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +51 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +54 -20
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +66 -4
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.test.js +1 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +25 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +83 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +171 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/conflict-index.d.ts +7 -0
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +3 -0
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/conflict.test.js +31 -0
- package/dist/lib/conflict.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +8 -0
- package/src/bin/sync-runner-events.test.ts +31 -0
- package/src/bin/sync-runner.test.ts +62 -1
- package/src/bin/sync-runner.ts +40 -1
- package/src/cli/reindex.test.ts +74 -6
- package/src/cli/reindex.ts +59 -22
- package/src/cli/share.test.ts +1 -0
- package/src/cli/sync.test.ts +192 -0
- package/src/cli/sync.ts +126 -1
- package/src/lib/conflict-index.ts +11 -0
- package/src/lib/conflict.test.ts +33 -0
package/src/cli/sync.test.ts
CHANGED
|
@@ -3783,6 +3783,198 @@ describe("sync", () => {
|
|
|
3783
3783
|
expect(errOutput).not.toContain("stale personal-overlay marker");
|
|
3784
3784
|
});
|
|
3785
3785
|
|
|
3786
|
+
it("directory-overlay marker whose children are already materialized: skips cleanly, no 'rm -rf' advice, children still pull (feedback_d2082110)", async () => {
|
|
3787
|
+
// Reporter feedback_d2082110 (cody@jonesroadbeauty.com): the DPP vault
|
|
3788
|
+
// stored a cloud symlink marker at `skills/cody-copywriter` while the local
|
|
3789
|
+
// path was a real directory holding that overlay's 13 materialized child
|
|
3790
|
+
// objects. The vault stores a directory overlay as a symlink RECORD at the
|
|
3791
|
+
// key itself AND the directory's contents as separate child objects beneath
|
|
3792
|
+
// it. Pre-fix, the pull planner hit the generic dir-vs-object collision
|
|
3793
|
+
// branch and emitted, on EVERY sync cycle, a "manual reconciliation required
|
|
3794
|
+
// (rm -rf the local directory to pull)" warning — advice that here would
|
|
3795
|
+
// DELETE the materialized (and possibly locally-authored) contents to install
|
|
3796
|
+
// an inert link. The marker "never resolved" and the noisy warning recurred.
|
|
3797
|
+
// Fix: recognize that the marker's key ALSO carries child objects in the same
|
|
3798
|
+
// LIST (a real directory never syncs as a single object at its own key), keep
|
|
3799
|
+
// the local directory, skip the marker quietly, and let the children pull via
|
|
3800
|
+
// their own plan items.
|
|
3801
|
+
const markerKey = "skills/cody-copywriter";
|
|
3802
|
+
const childKey = "skills/cody-copywriter/SKILL.md";
|
|
3803
|
+
const localDir = path.join(tmpDir, "companies", "acme", "skills", "cody-copywriter");
|
|
3804
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
3805
|
+
// A child that is already materialized on this device (stands in for the
|
|
3806
|
+
// reporter's 13 present child files) — it must survive untouched.
|
|
3807
|
+
fs.writeFileSync(path.join(localDir, "materialized.md"), "already here");
|
|
3808
|
+
|
|
3809
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3810
|
+
{ key: markerKey, size: 30, lastModified: new Date(), etag: '"overlay-marker"' },
|
|
3811
|
+
{ key: childKey, size: 18, lastModified: new Date(), etag: '"child-obj"' },
|
|
3812
|
+
]);
|
|
3813
|
+
// Deterministic child materialization, independent of the ambient default
|
|
3814
|
+
// downloadFile mock (which earlier tests in this suite may have replaced).
|
|
3815
|
+
// The marker is skipped by the planner, so this Once impl serves the single
|
|
3816
|
+
// child download.
|
|
3817
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
3818
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
3819
|
+
const dir = path.dirname(localPath);
|
|
3820
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
3821
|
+
fs.writeFileSync(localPath, "child content");
|
|
3822
|
+
return { metadata: {} };
|
|
3823
|
+
},
|
|
3824
|
+
);
|
|
3825
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
3826
|
+
|
|
3827
|
+
let crashed = false;
|
|
3828
|
+
let result;
|
|
3829
|
+
try {
|
|
3830
|
+
result = await sync({
|
|
3831
|
+
company: "acme",
|
|
3832
|
+
vaultConfig: mockConfig,
|
|
3833
|
+
hqRoot: tmpDir,
|
|
3834
|
+
});
|
|
3835
|
+
} catch {
|
|
3836
|
+
crashed = true;
|
|
3837
|
+
}
|
|
3838
|
+
const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
3839
|
+
errSpy.mockRestore();
|
|
3840
|
+
|
|
3841
|
+
expect(crashed).toBe(false);
|
|
3842
|
+
expect(result).toBeDefined();
|
|
3843
|
+
// The marker is a benign skip; the child object still downloads.
|
|
3844
|
+
expect(result!.filesDownloaded).toBe(1);
|
|
3845
|
+
expect(result!.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
3846
|
+
// Local directory and its already-materialized contents are untouched...
|
|
3847
|
+
expect(fs.readFileSync(path.join(localDir, "materialized.md"), "utf-8")).toBe(
|
|
3848
|
+
"already here",
|
|
3849
|
+
);
|
|
3850
|
+
// ...and the child materialized under the kept directory.
|
|
3851
|
+
expect(fs.existsSync(path.join(localDir, "SKILL.md"))).toBe(true);
|
|
3852
|
+
// The dangerous, recurring reconciliation advice is GONE...
|
|
3853
|
+
expect(errOutput).not.toContain("rm -rf");
|
|
3854
|
+
expect(errOutput).not.toContain("manual");
|
|
3855
|
+
// ...replaced by a calm, no-action-needed note naming the marker.
|
|
3856
|
+
expect(errOutput).toContain("directory-overlay marker");
|
|
3857
|
+
expect(errOutput).toContain(markerKey);
|
|
3858
|
+
});
|
|
3859
|
+
|
|
3860
|
+
it("company dir-vs-object collision with NO children in the LIST still warns to reconcile (boundary)", async () => {
|
|
3861
|
+
// Boundary guard for the feedback_d2082110 fix: the quiet overlay-marker
|
|
3862
|
+
// skip is scoped to a remote object whose key ALSO has child objects under
|
|
3863
|
+
// it (a directory overlay). A single remote object at a company key that is
|
|
3864
|
+
// locally a real directory but has NO children in the LIST is a genuine
|
|
3865
|
+
// file-vs-directory structural collision the operator must resolve, so the
|
|
3866
|
+
// original warning + skip-local-only classification must remain intact.
|
|
3867
|
+
const key = "skills/lonely-file";
|
|
3868
|
+
const localDir = path.join(tmpDir, "companies", "acme", "skills", "lonely-file");
|
|
3869
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
3870
|
+
fs.writeFileSync(path.join(localDir, "inside.md"), "local content");
|
|
3871
|
+
|
|
3872
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3873
|
+
{ key, size: 40, lastModified: new Date(), etag: '"single-obj"' },
|
|
3874
|
+
]);
|
|
3875
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
3876
|
+
|
|
3877
|
+
let crashed = false;
|
|
3878
|
+
let result;
|
|
3879
|
+
try {
|
|
3880
|
+
result = await sync({
|
|
3881
|
+
company: "acme",
|
|
3882
|
+
vaultConfig: mockConfig,
|
|
3883
|
+
hqRoot: tmpDir,
|
|
3884
|
+
});
|
|
3885
|
+
} catch {
|
|
3886
|
+
crashed = true;
|
|
3887
|
+
}
|
|
3888
|
+
const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
3889
|
+
errSpy.mockRestore();
|
|
3890
|
+
|
|
3891
|
+
expect(crashed).toBe(false);
|
|
3892
|
+
expect(result!.filesDownloaded).toBe(0);
|
|
3893
|
+
expect(result!.filesSkipped).toBe(1);
|
|
3894
|
+
expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
|
|
3895
|
+
// Generic collision path preserved: reconciliation warning still emitted,
|
|
3896
|
+
// NOT the overlay-marker note.
|
|
3897
|
+
expect(errOutput).toContain("manual");
|
|
3898
|
+
expect(errOutput).not.toContain("directory-overlay marker");
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
it("shared-mode leg surfaces the materialization gap and names the shared→all lever (feedback_d2082110)", async () => {
|
|
3902
|
+
// Reporter feedback_d2082110 (cody@jonesroadbeauty.com): four memberships
|
|
3903
|
+
// were configured as `shared` rather than `all`, so not all vault content
|
|
3904
|
+
// materialized on the second device — a SILENT gap the reporter had to
|
|
3905
|
+
// notice and work around by flipping each membership to `all` by hand.
|
|
3906
|
+
// `SyncResult.filesOutOfScope` counted the skipped keys but nothing SURFACED
|
|
3907
|
+
// the gap or the lever. Fix: a `shared`/`custom` leg that skips one or more
|
|
3908
|
+
// keys as out-of-scope emits a single `scope-materialization-gap` event so
|
|
3909
|
+
// full materialization becomes a deliberate, visible choice.
|
|
3910
|
+
const events: Array<Record<string, unknown>> = [];
|
|
3911
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3912
|
+
{ key: "knowledge/present.md", size: 12, lastModified: new Date(), etag: '"in-scope"' },
|
|
3913
|
+
{ key: "projects/secret.md", size: 20, lastModified: new Date(), etag: '"out-of-scope"' },
|
|
3914
|
+
]);
|
|
3915
|
+
// Deterministic materialization of the single in-scope download, independent
|
|
3916
|
+
// of the ambient default downloadFile mock (earlier tests in this suite may
|
|
3917
|
+
// have replaced it — clearAllMocks resets call history, not implementations).
|
|
3918
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
3919
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
3920
|
+
const dir = path.dirname(localPath);
|
|
3921
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
3922
|
+
fs.writeFileSync(localPath, "in-scope content");
|
|
3923
|
+
return { metadata: {} };
|
|
3924
|
+
},
|
|
3925
|
+
);
|
|
3926
|
+
|
|
3927
|
+
const result = await sync({
|
|
3928
|
+
company: "acme",
|
|
3929
|
+
vaultConfig: mockConfig,
|
|
3930
|
+
hqRoot: tmpDir,
|
|
3931
|
+
syncMode: "shared",
|
|
3932
|
+
prefixSet: ["knowledge/"],
|
|
3933
|
+
onEvent: (e) => events.push(e as unknown as Record<string, unknown>),
|
|
3934
|
+
});
|
|
3935
|
+
|
|
3936
|
+
// The in-scope key pulled; the out-of-scope key was NOT materialized.
|
|
3937
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3938
|
+
expect(result.filesOutOfScope).toBe(1);
|
|
3939
|
+
expect(
|
|
3940
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "present.md")),
|
|
3941
|
+
).toBe(true);
|
|
3942
|
+
expect(
|
|
3943
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", "projects", "secret.md")),
|
|
3944
|
+
).toBe(false);
|
|
3945
|
+
|
|
3946
|
+
// The gap is now SURFACED with the count, the active mode, and the lever.
|
|
3947
|
+
const gap = events.find((e) => e.type === "scope-materialization-gap");
|
|
3948
|
+
expect(gap).toBeDefined();
|
|
3949
|
+
expect(gap!.count).toBe(1);
|
|
3950
|
+
expect(gap!.syncMode).toBe("shared");
|
|
3951
|
+
expect(gap!.samplePaths).toEqual(["projects/secret.md"]);
|
|
3952
|
+
});
|
|
3953
|
+
|
|
3954
|
+
it("all-mode leg (default) never emits a materialization gap — no scoping, no noise", async () => {
|
|
3955
|
+
// Boundary for the feedback_d2082110 gap surface: in `all` mode every key is
|
|
3956
|
+
// in scope, so the gap event must stay silent even when the LIST is fully
|
|
3957
|
+
// downloaded. Prevents the guidance from firing on the common full-access
|
|
3958
|
+
// path where there is nothing for the operator to act on.
|
|
3959
|
+
const events: Array<Record<string, unknown>> = [];
|
|
3960
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3961
|
+
{ key: "knowledge/a.md", size: 12, lastModified: new Date(), etag: '"a"' },
|
|
3962
|
+
{ key: "projects/b.md", size: 20, lastModified: new Date(), etag: '"b"' },
|
|
3963
|
+
]);
|
|
3964
|
+
|
|
3965
|
+
const result = await sync({
|
|
3966
|
+
company: "acme",
|
|
3967
|
+
vaultConfig: mockConfig,
|
|
3968
|
+
hqRoot: tmpDir,
|
|
3969
|
+
// syncMode omitted → defaults to "all".
|
|
3970
|
+
onEvent: (e) => events.push(e as unknown as Record<string, unknown>),
|
|
3971
|
+
});
|
|
3972
|
+
|
|
3973
|
+
expect(result.filesDownloaded).toBe(2);
|
|
3974
|
+
expect(result.filesOutOfScope).toBe(0);
|
|
3975
|
+
expect(events.find((e) => e.type === "scope-materialization-gap")).toBeUndefined();
|
|
3976
|
+
});
|
|
3977
|
+
|
|
3786
3978
|
it("pulls a remote symlink record under an .hqinclude dir-only allowlist pattern", async () => {
|
|
3787
3979
|
// Codex round-7 P1 follow-up: pre-fix, computePullPlan called
|
|
3788
3980
|
// shouldSync(localPath) with the default isDir=false. LIST gives
|
package/src/cli/sync.ts
CHANGED
|
@@ -307,6 +307,32 @@ export type SyncProgressEvent =
|
|
|
307
307
|
count: number;
|
|
308
308
|
samplePaths: string[];
|
|
309
309
|
}
|
|
310
|
+
| {
|
|
311
|
+
/**
|
|
312
|
+
* Emitted at most ONCE per PULL leg when the leg ran under a
|
|
313
|
+
* membership-scoped `syncMode` (`"shared"` or `"custom"` — never `"all"`)
|
|
314
|
+
* AND one or more remote keys were skipped as out-of-scope
|
|
315
|
+
* (`filesOutOfScope > 0`). This turns the silent shared-vs-all gap into a
|
|
316
|
+
* VISIBLE, actionable surface. The reporter of feedback_d2082110 lost
|
|
317
|
+
* files across devices precisely because four memberships defaulted to
|
|
318
|
+
* `shared` rather than `all`, so not all vault content materialized on the
|
|
319
|
+
* second device — and nothing told them; they had to notice the gap and
|
|
320
|
+
* flip each membership to `all` by hand. This event names the gap and the
|
|
321
|
+
* lever (raise the membership's access level to `all`) so FULL
|
|
322
|
+
* materialization is a deliberate, visible choice rather than a silent
|
|
323
|
+
* omission.
|
|
324
|
+
*
|
|
325
|
+
* `count` is the number of remote keys skipped as out-of-scope on this
|
|
326
|
+
* leg; `samplePaths` carries up to 10 company-relative keys for display;
|
|
327
|
+
* `syncMode` is the active scoped mode. Distinct from the push-side
|
|
328
|
+
* `scope-excluded` (which reports what a grantee could not PUSH). Not
|
|
329
|
+
* emitted in `all` mode or when `count === 0` — no gap, no noise.
|
|
330
|
+
*/
|
|
331
|
+
type: "scope-materialization-gap";
|
|
332
|
+
count: number;
|
|
333
|
+
samplePaths: string[];
|
|
334
|
+
syncMode: SyncMode;
|
|
335
|
+
}
|
|
310
336
|
| {
|
|
311
337
|
/**
|
|
312
338
|
* Emitted at most ONCE per `share()` push leg when the base ignore
|
|
@@ -856,6 +882,8 @@ async function syncWithOperationLockHeld(
|
|
|
856
882
|
await verifyPlannedJournalTombstones(run, plan);
|
|
857
883
|
executeJournalTombstoneDeletes(run, plan, counters);
|
|
858
884
|
|
|
885
|
+
emitScopeMaterializationGap(run, plan, counters);
|
|
886
|
+
|
|
859
887
|
return finalizePullRun(run, plan, scopeRun, counters);
|
|
860
888
|
}
|
|
861
889
|
|
|
@@ -1170,7 +1198,8 @@ async function executeConflictExecutor(
|
|
|
1170
1198
|
item.action === "skip-personal-mode" ||
|
|
1171
1199
|
item.action === "skip-unchanged" ||
|
|
1172
1200
|
item.action === "skip-local-only" ||
|
|
1173
|
-
item.action === "skip-stale-overlay-marker"
|
|
1201
|
+
item.action === "skip-stale-overlay-marker" ||
|
|
1202
|
+
item.action === "skip-overlay-marker-with-children"
|
|
1174
1203
|
) {
|
|
1175
1204
|
counters.filesSkipped++;
|
|
1176
1205
|
continue;
|
|
@@ -1844,6 +1873,37 @@ function finalizePullRun(
|
|
|
1844
1873
|
};
|
|
1845
1874
|
}
|
|
1846
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* Surface the shared-vs-all materialization gap (feedback_d2082110). When a
|
|
1878
|
+
* pull leg ran under a membership-scoped `syncMode` and skipped one or more
|
|
1879
|
+
* remote keys as out-of-scope, emit a single summary event so the operator
|
|
1880
|
+
* SEES that not all vault content materialized on this device and knows the
|
|
1881
|
+
* lever — raise the membership's access level to `all`. Silent in `all` mode
|
|
1882
|
+
* (nothing is scoped away) and when nothing fell out of scope: no gap, no
|
|
1883
|
+
* noise. Deliberately AFTER the transfer executors, so `filesOutOfScope` is
|
|
1884
|
+
* final; sample keys are read off the plan's `skip-out-of-scope` items (pure,
|
|
1885
|
+
* no I/O). This is the visible complement to the previously-silent
|
|
1886
|
+
* `SyncResult.filesOutOfScope` count.
|
|
1887
|
+
*/
|
|
1888
|
+
function emitScopeMaterializationGap(
|
|
1889
|
+
run: PullRunContext,
|
|
1890
|
+
plan: PullPlan,
|
|
1891
|
+
counters: PullCounters,
|
|
1892
|
+
): void {
|
|
1893
|
+
if (run.syncMode === "all") return;
|
|
1894
|
+
if (counters.filesOutOfScope <= 0) return;
|
|
1895
|
+
const samplePaths = plan.items
|
|
1896
|
+
.filter((item) => item.action === "skip-out-of-scope")
|
|
1897
|
+
.slice(0, 10)
|
|
1898
|
+
.map((item) => item.remoteFile.key);
|
|
1899
|
+
run.emit({
|
|
1900
|
+
type: "scope-materialization-gap",
|
|
1901
|
+
count: counters.filesOutOfScope,
|
|
1902
|
+
samplePaths,
|
|
1903
|
+
syncMode: run.syncMode,
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1847
1907
|
/**
|
|
1848
1908
|
* Decide whether a remote object present in the LIST is a GENUINE RE-CREATE
|
|
1849
1909
|
* written AFTER a FILE_TOMBSTONE — in which case the tombstone is stale and the
|
|
@@ -2034,6 +2094,15 @@ type PullPlanItem =
|
|
|
2034
2094
|
// no-action-needed case is never surfaced with the alarming "rm -rf the
|
|
2035
2095
|
// local directory" reconciliation advice.
|
|
2036
2096
|
| { action: "skip-stale-overlay-marker"; remoteFile: RemoteFile; localPath: string }
|
|
2097
|
+
// A directory-overlay marker (a single vault object at a key that ALSO has
|
|
2098
|
+
// child objects under it in the same LIST — a symlink record for a directory
|
|
2099
|
+
// mirrored from another tree) colliding with a local REAL directory whose
|
|
2100
|
+
// contents are already materialized. The marker can never be renamed over the
|
|
2101
|
+
// directory; its children carry the real content and materialize via their own
|
|
2102
|
+
// plan items. A benign, no-action skip — distinct from skip-local-only so the
|
|
2103
|
+
// case is never surfaced with the alarming (and, for a materialized directory,
|
|
2104
|
+
// data-destroying) "rm -rf the local directory to pull" advice. feedback_d2082110.
|
|
2105
|
+
| { action: "skip-overlay-marker-with-children"; remoteFile: RemoteFile; localPath: string }
|
|
2037
2106
|
// Remote keys refused by ephemeral-mirror policy. The push walker has
|
|
2038
2107
|
// refused to upload these since 5.33.0; the pull walker now refuses to
|
|
2039
2108
|
// download them so legacy litter in cloud staging drains naturally.
|
|
@@ -2176,6 +2245,28 @@ function computePullPlan(
|
|
|
2176
2245
|
localPath: string;
|
|
2177
2246
|
}> = [];
|
|
2178
2247
|
|
|
2248
|
+
// Remote keys that ALSO appear as an ancestor of another remote key — i.e.
|
|
2249
|
+
// keys that carry child objects under `${key}/…` in this same LIST. The
|
|
2250
|
+
// vault stores a directory OVERLAY (a symlink into another tree, e.g. a
|
|
2251
|
+
// company skill mirrored from a shared repo) as a symlink RECORD at the
|
|
2252
|
+
// directory's own key AND stores the directory's contents as separate child
|
|
2253
|
+
// objects beneath it. A REAL directory, by contrast, only ever syncs as
|
|
2254
|
+
// those child objects — never as a single object AT its own key. So a single
|
|
2255
|
+
// remote object sitting at a key that also has children is unambiguously a
|
|
2256
|
+
// directory-overlay marker, not a regular file. Precomputed once (pure path
|
|
2257
|
+
// derivation, no I/O — the planner is synchronous) so the dir-vs-object
|
|
2258
|
+
// collision branch can tell an inert overlay marker apart from a genuine
|
|
2259
|
+
// file-vs-directory structural collision. feedback_d2082110.
|
|
2260
|
+
const remoteKeysWithChildren = new Set<string>();
|
|
2261
|
+
for (const rf of remoteFiles) {
|
|
2262
|
+
const key = toPosixKey(rf.key);
|
|
2263
|
+
let slash = key.indexOf("/");
|
|
2264
|
+
while (slash !== -1) {
|
|
2265
|
+
remoteKeysWithChildren.add(key.slice(0, slash));
|
|
2266
|
+
slash = key.indexOf("/", slash + 1);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2179
2270
|
for (const remoteFile of remoteFiles) {
|
|
2180
2271
|
const invalidKey = classifyVaultKey(
|
|
2181
2272
|
remoteFile.key,
|
|
@@ -2437,6 +2528,30 @@ function computePullPlan(
|
|
|
2437
2528
|
});
|
|
2438
2529
|
continue;
|
|
2439
2530
|
}
|
|
2531
|
+
// Directory-overlay marker vs. a locally-materialized directory:
|
|
2532
|
+
// the remote object at this key carries CHILD objects under it in the
|
|
2533
|
+
// same LIST (`remoteKeysWithChildren`), so it is a symlink/overlay
|
|
2534
|
+
// record for a directory — e.g. the DPP vault's `skills/cody-copywriter`
|
|
2535
|
+
// overlay whose 13 child objects are already materialized locally. The
|
|
2536
|
+
// marker can never be renamed over the real directory, and its children
|
|
2537
|
+
// carry the actual content and materialize via their own plan items, so
|
|
2538
|
+
// the marker is a benign, no-action skip. The generic "rm -rf the local
|
|
2539
|
+
// directory to pull" advice below would tell the operator to DELETE the
|
|
2540
|
+
// materialized (and possibly locally-authored) contents to install an
|
|
2541
|
+
// inert link, and it recurred on EVERY sync cycle. feedback_d2082110.
|
|
2542
|
+
if (remoteKeysWithChildren.has(toPosixKey(remoteFile.key))) {
|
|
2543
|
+
console.error(
|
|
2544
|
+
` Note: ${remoteFile.key} is a directory-overlay marker whose ` +
|
|
2545
|
+
`contents already exist locally as a directory; keeping the ` +
|
|
2546
|
+
`local directory and ignoring the marker (no action needed).`,
|
|
2547
|
+
);
|
|
2548
|
+
items.push({
|
|
2549
|
+
action: "skip-overlay-marker-with-children",
|
|
2550
|
+
remoteFile,
|
|
2551
|
+
localPath,
|
|
2552
|
+
});
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2440
2555
|
console.error(
|
|
2441
2556
|
` Warning: ${remoteFile.key} exists locally as a directory; ` +
|
|
2442
2557
|
`cloud has a single object at this key. Skipping; manual ` +
|
|
@@ -2896,6 +3011,16 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2896
3011
|
if (event.count > event.samplePaths.length) {
|
|
2897
3012
|
console.log(` ... and ${event.count - event.samplePaths.length} more`);
|
|
2898
3013
|
}
|
|
3014
|
+
} else if (event.type === "scope-materialization-gap") {
|
|
3015
|
+
console.warn(
|
|
3016
|
+
` ! ${event.count} item${event.count === 1 ? "" : "s"} in this vault did NOT sync to this device — your access level is "${event.syncMode}", so only shared paths materialize. To pull everything you have access to, set this membership's access level to "all":`,
|
|
3017
|
+
);
|
|
3018
|
+
for (const p of event.samplePaths) {
|
|
3019
|
+
console.warn(` · ${p}`);
|
|
3020
|
+
}
|
|
3021
|
+
if (event.count > event.samplePaths.length) {
|
|
3022
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
3023
|
+
}
|
|
2899
3024
|
} else if (event.type === "ignore-excluded") {
|
|
2900
3025
|
console.warn(
|
|
2901
3026
|
` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
|
|
@@ -136,6 +136,13 @@ export interface PruneConflictIndexResult {
|
|
|
136
136
|
removedMirrors: number;
|
|
137
137
|
/** Rows kept (genuine divergence, or unprovable — fail-safe retained). */
|
|
138
138
|
kept: number;
|
|
139
|
+
/**
|
|
140
|
+
* Up to 10 ORIGINAL (non-mirror) paths of the kept rows, for a
|
|
141
|
+
* post-sync reconcile surface (`conflicts-remaining`). Empty when
|
|
142
|
+
* `kept === 0`. Lets a caller name the preserved conflict variants a human
|
|
143
|
+
* still has to resolve without re-reading the index. feedback_d2082110.
|
|
144
|
+
*/
|
|
145
|
+
keptSamplePaths: string[];
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
/**
|
|
@@ -198,6 +205,7 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
|
|
|
198
205
|
prunedIdentical: 0,
|
|
199
206
|
removedMirrors: 0,
|
|
200
207
|
kept: 0,
|
|
208
|
+
keptSamplePaths: [],
|
|
201
209
|
};
|
|
202
210
|
|
|
203
211
|
const index = readConflictIndex(hqRoot);
|
|
@@ -254,6 +262,9 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
|
|
|
254
262
|
}
|
|
255
263
|
}
|
|
256
264
|
|
|
265
|
+
// Name the preserved divergences for the post-sync reconcile surface.
|
|
266
|
+
result.keptSamplePaths = kept.slice(0, 10).map((e) => e.originalPath);
|
|
267
|
+
|
|
257
268
|
// No row dropped → leave the file (and its mtime) untouched.
|
|
258
269
|
if (kept.length === index.conflicts.length) return result;
|
|
259
270
|
|
package/src/lib/conflict.test.ts
CHANGED
|
@@ -194,6 +194,7 @@ describe("pruneConflictIndex", () => {
|
|
|
194
194
|
prunedIdentical: 0,
|
|
195
195
|
removedMirrors: 0,
|
|
196
196
|
kept: 0,
|
|
197
|
+
keptSamplePaths: [],
|
|
197
198
|
});
|
|
198
199
|
expect(fs.existsSync(getConflictIndexPath(tmpHq))).toBe(false);
|
|
199
200
|
});
|
|
@@ -292,6 +293,7 @@ describe("pruneConflictIndex", () => {
|
|
|
292
293
|
prunedIdentical: 1,
|
|
293
294
|
removedMirrors: 1,
|
|
294
295
|
kept: 1,
|
|
296
|
+
keptSamplePaths: ["dir/real.md"],
|
|
295
297
|
});
|
|
296
298
|
expect(readConflictIndex(tmpHq).conflicts.map((c) => c.id)).toEqual(["real"]);
|
|
297
299
|
});
|
|
@@ -312,4 +314,35 @@ describe("pruneConflictIndex", () => {
|
|
|
312
314
|
pruneConflictIndex(tmpHq);
|
|
313
315
|
expect(fs.statSync(indexPath).mtimeMs).toBe(before);
|
|
314
316
|
});
|
|
317
|
+
|
|
318
|
+
it("names the preserved conflict variants (keptSamplePaths, capped at 10) for the reconcile surface (feedback_d2082110)", () => {
|
|
319
|
+
// Reporter feedback_d2082110 finished a full sync with "20 older preserved
|
|
320
|
+
// conflict entries" still on disk and NO signal they were there. The prune
|
|
321
|
+
// self-heals the ledger but conservatively KEEPS genuine divergences; those
|
|
322
|
+
// were silent. keptSamplePaths surfaces the original (non-mirror) paths so
|
|
323
|
+
// the runner can emit a `conflicts-remaining` reconcile line pointing the
|
|
324
|
+
// operator at `/resolve-conflicts`.
|
|
325
|
+
const rows: ConflictIndexEntry[] = [];
|
|
326
|
+
for (let i = 0; i < 12; i++) {
|
|
327
|
+
const id = `real-${String(i).padStart(2, "0")}`;
|
|
328
|
+
const row = rowFor(id);
|
|
329
|
+
put(row.originalPath, `local-${i}`);
|
|
330
|
+
put(row.conflictPath, `remote-${i}`);
|
|
331
|
+
rows.push(row);
|
|
332
|
+
}
|
|
333
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: rows });
|
|
334
|
+
|
|
335
|
+
const res = pruneConflictIndex(tmpHq);
|
|
336
|
+
expect(res.kept).toBe(12);
|
|
337
|
+
// Capped at 10 samples, drawn from the kept (detectedAt-sorted) rows.
|
|
338
|
+
expect(res.keptSamplePaths).toHaveLength(10);
|
|
339
|
+
expect(res.keptSamplePaths.every((p) => p.startsWith("dir/real-"))).toBe(true);
|
|
340
|
+
// Orphaned/identical rows never contribute to the surfaced sample.
|
|
341
|
+
const orphan = rowFor("orphan");
|
|
342
|
+
put(orphan.originalPath, "x"); // mirror missing → dropped, not sampled
|
|
343
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [orphan] });
|
|
344
|
+
const res2 = pruneConflictIndex(tmpHq);
|
|
345
|
+
expect(res2.kept).toBe(0);
|
|
346
|
+
expect(res2.keptSamplePaths).toEqual([]);
|
|
347
|
+
});
|
|
315
348
|
});
|