@indigoai-us/hq-cloud 6.14.37 → 6.14.40
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.test.js +44 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -9
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/share.d.ts +13 -14
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +61 -16
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +141 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +8 -5
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +68 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +154 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/readlink-safe.d.ts +11 -0
- package/dist/lib/readlink-safe.d.ts.map +1 -0
- package/dist/lib/readlink-safe.js +27 -0
- package/dist/lib/readlink-safe.js.map +1 -0
- package/dist/lib/readlink-safe.test.d.ts +2 -0
- package/dist/lib/readlink-safe.test.d.ts.map +1 -0
- package/dist/lib/readlink-safe.test.js +34 -0
- package/dist/lib/readlink-safe.test.js.map +1 -0
- package/dist/skill-telemetry.d.ts +21 -5
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +238 -31
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +235 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/package.json +2 -2
- package/src/bin/sync-runner.test.ts +52 -0
- package/src/cli/reindex.ts +1 -9
- package/src/cli/share.test.ts +180 -0
- package/src/cli/share.ts +75 -30
- package/src/cli/sync.test.ts +167 -0
- package/src/cli/sync.ts +78 -15
- package/src/lib/readlink-safe.test.ts +43 -0
- package/src/lib/readlink-safe.ts +29 -0
- package/src/skill-telemetry.test.ts +286 -0
- package/src/skill-telemetry.ts +297 -31
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +124 -0
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/share.ts
CHANGED
|
@@ -72,6 +72,7 @@ import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
|
72
72
|
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
73
73
|
import { VaultAuthError } from "../vault-client.js";
|
|
74
74
|
import { describeError } from "../lib/describe-error.js";
|
|
75
|
+
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
78
|
* Push-side fresh-collision convergence probe.
|
|
@@ -725,7 +726,7 @@ export interface ShareOptions {
|
|
|
725
726
|
}
|
|
726
727
|
|
|
727
728
|
/** Why an explicitly-named push path could not be shipped. */
|
|
728
|
-
export type UnreachablePathReason = "missing" | "outside-company";
|
|
729
|
+
export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link";
|
|
729
730
|
|
|
730
731
|
/**
|
|
731
732
|
* Thrown by `share()` when a caller-named path cannot be pushed and
|
|
@@ -743,9 +744,13 @@ export class UnreachablePushPathsError extends Error {
|
|
|
743
744
|
const paths = [...unreachable.keys()];
|
|
744
745
|
const lines = paths.map((p) => {
|
|
745
746
|
const reason = unreachable.get(p);
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
747
|
+
if (reason === "outside-company") {
|
|
748
|
+
return ` · ${p} — resolves outside the company folder (${syncRoot})`;
|
|
749
|
+
}
|
|
750
|
+
if (reason === "unreadable-link") {
|
|
751
|
+
return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
|
|
752
|
+
}
|
|
753
|
+
return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
|
|
749
754
|
});
|
|
750
755
|
super(
|
|
751
756
|
`${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
|
|
@@ -1826,7 +1831,17 @@ async function executeDeletes(
|
|
|
1826
1831
|
}
|
|
1827
1832
|
fs.unlinkSync(localPath);
|
|
1828
1833
|
} else if (lstat.isSymbolicLink()) {
|
|
1829
|
-
const
|
|
1834
|
+
const target = readlinkOrNull(localPath);
|
|
1835
|
+
if (target === null) {
|
|
1836
|
+
run.emit({
|
|
1837
|
+
type: "not-shipped",
|
|
1838
|
+
reason: "unreadable-link",
|
|
1839
|
+
count: 1,
|
|
1840
|
+
samplePaths: [relativePath],
|
|
1841
|
+
});
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
const localHash = hashSymlinkTarget(target);
|
|
1830
1845
|
if (entry?.hash && entry.hash !== localHash) {
|
|
1831
1846
|
run.emit({
|
|
1832
1847
|
type: "error",
|
|
@@ -1992,12 +2007,27 @@ function collectFatalUnreachablePaths(
|
|
|
1992
2007
|
*/
|
|
1993
2008
|
function emitUnreachablePathEvent(run: PushRunContext): void {
|
|
1994
2009
|
if (run.unreachablePaths.size === 0) return;
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2010
|
+
const unreadableLinks: string[] = [];
|
|
2011
|
+
const unreachablePaths: string[] = [];
|
|
2012
|
+
for (const [namedPath, reason] of run.unreachablePaths) {
|
|
2013
|
+
(reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
|
|
2014
|
+
}
|
|
2015
|
+
if (unreachablePaths.length > 0) {
|
|
2016
|
+
run.emit({
|
|
2017
|
+
type: "not-shipped",
|
|
2018
|
+
reason: "unreachable-path",
|
|
2019
|
+
count: unreachablePaths.length,
|
|
2020
|
+
samplePaths: unreachablePaths.slice(0, 10),
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
if (unreadableLinks.length > 0) {
|
|
2024
|
+
run.emit({
|
|
2025
|
+
type: "not-shipped",
|
|
2026
|
+
reason: "unreadable-link",
|
|
2027
|
+
count: unreadableLinks.length,
|
|
2028
|
+
samplePaths: unreadableLinks.slice(0, 10),
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2001
2031
|
}
|
|
2002
2032
|
|
|
2003
2033
|
/** First up-to-`limit` members of an iterable, for bounded event payloads. */
|
|
@@ -2133,6 +2163,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
2133
2163
|
console.warn(
|
|
2134
2164
|
` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
|
|
2135
2165
|
);
|
|
2166
|
+
} else if (event.reason === "unreadable-link") {
|
|
2167
|
+
console.warn(
|
|
2168
|
+
` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
|
|
2169
|
+
);
|
|
2136
2170
|
} else {
|
|
2137
2171
|
console.warn(
|
|
2138
2172
|
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
@@ -2166,22 +2200,21 @@ type CollectedEntry =
|
|
|
2166
2200
|
* They turn two previously-SILENT outcomes into surfaced signals — without
|
|
2167
2201
|
* changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
|
|
2168
2202
|
*
|
|
2169
|
-
* - `onUnreachablePath`: a path
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
* (`"
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2203
|
+
* - `onUnreachablePath`: a path that cannot be shipped. It is normally the
|
|
2204
|
+
* caller's explicit spelling (`"outside-company"` or `"missing"`), and is
|
|
2205
|
+
* the company-relative key for an unreadable link discovered during a
|
|
2206
|
+
* directory walk (`"unreadable-link"`). Pre-fix this was a bare
|
|
2207
|
+
* `console.error` warn-skip that still let the push report "Pushed 0 file(s)"
|
|
2208
|
+
* — a false success. The caller can now turn a non-empty set into a real
|
|
2209
|
+
* error / nonzero exit.
|
|
2176
2210
|
*
|
|
2177
|
-
* SPELLING CONTRACT:
|
|
2211
|
+
* SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token,
|
|
2178
2212
|
* verbatim — never the resolved absolute path, and never normalized. A
|
|
2179
2213
|
* relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
|
|
2180
2214
|
* an absolute path is reported absolute because that is what was passed.
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
* below pass the loop variable `p` for exactly this reason.
|
|
2215
|
+
* An unreadable link discovered during a recursive walk instead uses its
|
|
2216
|
+
* company-relative vault key, because it has no separate caller token.
|
|
2217
|
+
* This keeps UI output actionable without exposing host-specific paths.
|
|
2185
2218
|
* - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
|
|
2186
2219
|
* descended because its target resolves OUTSIDE the company folder (e.g.
|
|
2187
2220
|
* `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
|
|
@@ -2189,7 +2222,7 @@ type CollectedEntry =
|
|
|
2189
2222
|
* created under such a link from vanishing from every push bucket silently.
|
|
2190
2223
|
*/
|
|
2191
2224
|
interface CollectHooks {
|
|
2192
|
-
onUnreachablePath?: (namedPath: string, reason:
|
|
2225
|
+
onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void;
|
|
2193
2226
|
onLinkedSubtree?: (rel: string) => void;
|
|
2194
2227
|
}
|
|
2195
2228
|
|
|
@@ -2517,6 +2550,12 @@ function collectFiles(
|
|
|
2517
2550
|
// whole branch). The filter is pure path lookup with no I/O,
|
|
2518
2551
|
// so two calls are free.
|
|
2519
2552
|
if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
|
|
2553
|
+
const target = readlinkOrNull(absolutePath);
|
|
2554
|
+
if (target === null) {
|
|
2555
|
+
console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
|
|
2556
|
+
hooks.onUnreachablePath?.(p, "unreadable-link");
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2520
2559
|
// A directory symlink whose target lives outside the company folder is
|
|
2521
2560
|
// recorded here but never descended — its contents ship via their own
|
|
2522
2561
|
// repo, not the vault. Surface it so files created under such a link
|
|
@@ -2526,7 +2565,7 @@ function collectFiles(
|
|
|
2526
2565
|
kind: "symlink",
|
|
2527
2566
|
absolutePath,
|
|
2528
2567
|
relativePath,
|
|
2529
|
-
target
|
|
2568
|
+
target,
|
|
2530
2569
|
});
|
|
2531
2570
|
continue;
|
|
2532
2571
|
}
|
|
@@ -2602,11 +2641,17 @@ function walkDir(
|
|
|
2602
2641
|
// private/knowledge-{co}/), causing per-company knowledge repos
|
|
2603
2642
|
// to be uploaded into every vault that links them. Recording
|
|
2604
2643
|
// and not following preserves the link topology while avoiding
|
|
2605
|
-
// that duplication. readlinkSync on a Dirent-known link cannot
|
|
2606
|
-
// fail under normal conditions; let the throw propagate if it
|
|
2607
|
-
// somehow does (race with rm, EPERM) — the operator needs to
|
|
2608
|
-
// see it rather than us silently dropping the link again.
|
|
2609
2644
|
const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2645
|
+
// On win32, a Dirent-known link is not sufficient proof that readlink
|
|
2646
|
+
// will succeed (notably for some reparse points). Never fall through to
|
|
2647
|
+
// normal file/directory handling here: doing so would dereference the
|
|
2648
|
+
// link and duplicate its target under this vault key.
|
|
2649
|
+
const target = readlinkOrNull(absolutePath);
|
|
2650
|
+
if (target === null) {
|
|
2651
|
+
console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
|
|
2652
|
+
hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2610
2655
|
// The link is recorded but its (external) target is not descended, so
|
|
2611
2656
|
// any files under it are NOT uploaded. Report the subtree so a full
|
|
2612
2657
|
// `sync now` no longer drops it from every bucket without a trace.
|
|
@@ -2615,7 +2660,7 @@ function walkDir(
|
|
|
2615
2660
|
kind: "symlink",
|
|
2616
2661
|
absolutePath,
|
|
2617
2662
|
relativePath: linkRelative,
|
|
2618
|
-
target
|
|
2663
|
+
target,
|
|
2619
2664
|
});
|
|
2620
2665
|
continue;
|
|
2621
2666
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -10,6 +10,13 @@ import { clearContextCache } from "../context.js";
|
|
|
10
10
|
import type { VaultServiceConfig } from "../types.js";
|
|
11
11
|
import { lockPathFor } from "../operation-lock.js";
|
|
12
12
|
|
|
13
|
+
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
14
|
+
// inject a win32 EINVAL without needing a real Windows reparse point.
|
|
15
|
+
vi.mock("fs", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import("fs")>();
|
|
17
|
+
return { ...actual };
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
// Mock s3 module at the top level
|
|
14
21
|
vi.mock("../s3.js", async (importOriginal) => {
|
|
15
22
|
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
@@ -67,6 +74,12 @@ const mockConfig: VaultServiceConfig = {
|
|
|
67
74
|
region: "us-east-1",
|
|
68
75
|
};
|
|
69
76
|
|
|
77
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
78
|
+
const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
|
|
79
|
+
err.code = code;
|
|
80
|
+
return err;
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
const mockEntity = {
|
|
71
84
|
uid: "cmp_01ABCDEF",
|
|
72
85
|
slug: "acme",
|
|
@@ -383,6 +396,52 @@ describe("sync", () => {
|
|
|
383
396
|
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
384
397
|
});
|
|
385
398
|
|
|
399
|
+
it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
|
|
400
|
+
const linkKey = "policies/downloaded-unreadable-link";
|
|
401
|
+
const linkPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
402
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
403
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
404
|
+
]);
|
|
405
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
406
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
407
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
408
|
+
fs.symlinkSync("missing-target.md", localPath);
|
|
409
|
+
return { metadata: {} };
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
413
|
+
const readlinkSpy = vi
|
|
414
|
+
.spyOn(fs, "readlinkSync")
|
|
415
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
416
|
+
if (candidate === linkPath) throw errnoError("EINVAL");
|
|
417
|
+
return realReadlinkSync(candidate);
|
|
418
|
+
}) as typeof fs.readlinkSync);
|
|
419
|
+
const events: SyncProgressEvent[] = [];
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const result = await sync({
|
|
423
|
+
company: "acme",
|
|
424
|
+
vaultConfig: mockConfig,
|
|
425
|
+
hqRoot: tmpDir,
|
|
426
|
+
onEvent: (event) => events.push(event),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(result.filesDownloaded).toBe(0);
|
|
430
|
+
expect(result.filesSkipped).toBe(1);
|
|
431
|
+
expect(events).toContainEqual({
|
|
432
|
+
type: "not-shipped",
|
|
433
|
+
reason: "unreadable-link",
|
|
434
|
+
count: 1,
|
|
435
|
+
samplePaths: [linkKey],
|
|
436
|
+
});
|
|
437
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
438
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
439
|
+
expect(journal.files[linkKey]).toBeUndefined();
|
|
440
|
+
} finally {
|
|
441
|
+
readlinkSpy.mockRestore();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
386
445
|
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
387
446
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
388
447
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -505,6 +564,73 @@ describe("sync", () => {
|
|
|
505
564
|
expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
506
565
|
});
|
|
507
566
|
|
|
567
|
+
it("defers a conflict whose downloaded link target cannot be read", async () => {
|
|
568
|
+
const linkKey = "docs/handoff.md";
|
|
569
|
+
const localPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
570
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
571
|
+
fs.writeFileSync(localPath, "local version");
|
|
572
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
573
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"remote-link"' },
|
|
574
|
+
]);
|
|
575
|
+
let mirrorPath = "";
|
|
576
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
577
|
+
async (_ctx: unknown, _key: string, destination: string) => {
|
|
578
|
+
mirrorPath = destination;
|
|
579
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
580
|
+
fs.symlinkSync("missing-target.md", destination);
|
|
581
|
+
return { metadata: {} };
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
fs.writeFileSync(
|
|
585
|
+
journalPath,
|
|
586
|
+
JSON.stringify({
|
|
587
|
+
version: "1",
|
|
588
|
+
lastSync: new Date().toISOString(),
|
|
589
|
+
files: {
|
|
590
|
+
[linkKey]: {
|
|
591
|
+
hash: "stale-hash",
|
|
592
|
+
size: 20,
|
|
593
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
594
|
+
direction: "down",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
600
|
+
const readlinkSpy = vi
|
|
601
|
+
.spyOn(fs, "readlinkSync")
|
|
602
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
603
|
+
if (candidate === mirrorPath) throw errnoError("EINVAL");
|
|
604
|
+
return realReadlinkSync(candidate);
|
|
605
|
+
}) as typeof fs.readlinkSync);
|
|
606
|
+
const events: SyncProgressEvent[] = [];
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const result = await sync({
|
|
610
|
+
company: "acme",
|
|
611
|
+
onConflict: "keep",
|
|
612
|
+
vaultConfig: mockConfig,
|
|
613
|
+
hqRoot: tmpDir,
|
|
614
|
+
onEvent: (event) => events.push(event),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(result.conflicts).toBe(0);
|
|
618
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
619
|
+
expect(events).toContainEqual({
|
|
620
|
+
type: "not-shipped",
|
|
621
|
+
reason: "unreadable-link",
|
|
622
|
+
count: 1,
|
|
623
|
+
samplePaths: [linkKey],
|
|
624
|
+
});
|
|
625
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
626
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
|
|
627
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
628
|
+
expect(journal.files[linkKey].hash).toBe("stale-hash");
|
|
629
|
+
} finally {
|
|
630
|
+
readlinkSpy.mockRestore();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
508
634
|
it("still writes a `.conflict-*` mirror when remote genuinely diverges from local", async () => {
|
|
509
635
|
// Guard the other side of the convergence branch: when the probe bytes
|
|
510
636
|
// differ from local, it remains a real conflict — counted, kept, and the
|
|
@@ -4131,6 +4257,47 @@ describe("sync", () => {
|
|
|
4131
4257
|
expect(result.conflicts).toBe(0);
|
|
4132
4258
|
});
|
|
4133
4259
|
|
|
4260
|
+
it("defers an unreadable local symlink instead of aborting the pull plan", async () => {
|
|
4261
|
+
const linkKey = "policies/unreadable-link";
|
|
4262
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4263
|
+
const linkPath = path.join(companyRoot, linkKey);
|
|
4264
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
4265
|
+
fs.symlinkSync("target.md", linkPath);
|
|
4266
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4267
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
4268
|
+
]);
|
|
4269
|
+
|
|
4270
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
4271
|
+
const readlinkSpy = vi
|
|
4272
|
+
.spyOn(fs, "readlinkSync")
|
|
4273
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
4274
|
+
if (candidate === linkPath) throw errnoError("EINVAL");
|
|
4275
|
+
return realReadlinkSync(candidate);
|
|
4276
|
+
}) as typeof fs.readlinkSync);
|
|
4277
|
+
|
|
4278
|
+
try {
|
|
4279
|
+
const events: SyncProgressEvent[] = [];
|
|
4280
|
+
const result = await sync({
|
|
4281
|
+
company: "acme",
|
|
4282
|
+
vaultConfig: mockConfig,
|
|
4283
|
+
hqRoot: tmpDir,
|
|
4284
|
+
onEvent: (event) => events.push(event),
|
|
4285
|
+
});
|
|
4286
|
+
|
|
4287
|
+
expect(result.filesDownloaded).toBe(0);
|
|
4288
|
+
expect(result.filesSkipped).toBe(1);
|
|
4289
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
4290
|
+
expect(events).toContainEqual({
|
|
4291
|
+
type: "not-shipped",
|
|
4292
|
+
reason: "unreadable-link",
|
|
4293
|
+
count: 1,
|
|
4294
|
+
samplePaths: [linkKey],
|
|
4295
|
+
});
|
|
4296
|
+
} finally {
|
|
4297
|
+
readlinkSpy.mockRestore();
|
|
4298
|
+
}
|
|
4299
|
+
});
|
|
4300
|
+
|
|
4134
4301
|
it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
|
|
4135
4302
|
// Codex round-11 P2 follow-up: pre-fix, the conflict executor
|
|
4136
4303
|
// built the resolveConflict prompt with `fs.statSync(localPath).
|
package/src/cli/sync.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SyncJournal,
|
|
14
14
|
} from "../types.js";
|
|
15
15
|
import { VaultAuthError, VaultClient, type SyncMode } from "../vault-client.js";
|
|
16
|
+
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
16
17
|
import {
|
|
17
18
|
emitCloudTelemetry,
|
|
18
19
|
type TelemetryClaims,
|
|
@@ -436,11 +437,11 @@ export type SyncProgressEvent =
|
|
|
436
437
|
}
|
|
437
438
|
| {
|
|
438
439
|
/**
|
|
439
|
-
* Emitted
|
|
440
|
-
*
|
|
441
|
-
* vanish from every push bucket without a trace (feedback_258e4a86 /
|
|
440
|
+
* Emitted when local content is deliberately not transferred and would
|
|
441
|
+
* otherwise vanish from sync output without a trace (feedback_258e4a86 /
|
|
442
442
|
* feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
|
|
443
|
-
* named nothing that was skipped).
|
|
443
|
+
* named nothing that was skipped). Push batches by reason; pull emits an
|
|
444
|
+
* unreadable link by its remote key. `reason` distinguishes the cause:
|
|
444
445
|
*
|
|
445
446
|
* - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
|
|
446
447
|
* disk but the resolver could not place it under the company folder,
|
|
@@ -450,12 +451,15 @@ export type SyncProgressEvent =
|
|
|
450
451
|
* the company folder was recorded as a link but its contents were not
|
|
451
452
|
* descended/uploaded. They sync via their own repo, not the vault;
|
|
452
453
|
* this is informational, not an error.
|
|
454
|
+
* - `"unreadable-link"`: the OS identified a local symbolic link but
|
|
455
|
+
* did not expose a readable target. The link is skipped rather than
|
|
456
|
+
* dereferenced, and the company leg remains complete.
|
|
453
457
|
*
|
|
454
458
|
* `count` is the number of distinct paths for that reason; `samplePaths`
|
|
455
459
|
* carries up to 10 for display. Not emitted when `count === 0`.
|
|
456
460
|
*/
|
|
457
461
|
type: "not-shipped";
|
|
458
|
-
reason: "unreachable-path" | "linked-subtree";
|
|
462
|
+
reason: "unreachable-path" | "unreadable-link" | "linked-subtree";
|
|
459
463
|
count: number;
|
|
460
464
|
samplePaths: string[];
|
|
461
465
|
};
|
|
@@ -1261,6 +1265,19 @@ async function executeConflictExecutor(
|
|
|
1261
1265
|
counters.filesSkipped++;
|
|
1262
1266
|
continue;
|
|
1263
1267
|
}
|
|
1268
|
+
if (item.action === "skip-unreadable-link") {
|
|
1269
|
+
// An unreadable local link must stay non-fatal: the runner treats a
|
|
1270
|
+
// generic error event as a failed company leg. Surface the exact remote
|
|
1271
|
+
// key through the recoverable not-shipped channel instead.
|
|
1272
|
+
counters.filesSkipped++;
|
|
1273
|
+
run.emit({
|
|
1274
|
+
type: "not-shipped",
|
|
1275
|
+
reason: "unreadable-link",
|
|
1276
|
+
count: 1,
|
|
1277
|
+
samplePaths: [item.remoteFile.key],
|
|
1278
|
+
});
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1264
1281
|
if (item.action === "skip-excluded-policy") {
|
|
1265
1282
|
continue;
|
|
1266
1283
|
}
|
|
@@ -1408,10 +1425,23 @@ async function executeConflictItem(
|
|
|
1408
1425
|
try {
|
|
1409
1426
|
const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
|
|
1410
1427
|
remoteFetched = true;
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1428
|
+
if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
|
|
1429
|
+
const target = readlinkOrNull(conflictAbs);
|
|
1430
|
+
if (target === null) {
|
|
1431
|
+
counters.filesSkipped++;
|
|
1432
|
+
run.emit({
|
|
1433
|
+
type: "not-shipped",
|
|
1434
|
+
reason: "unreadable-link",
|
|
1435
|
+
count: 1,
|
|
1436
|
+
samplePaths: [remoteFile.key],
|
|
1437
|
+
});
|
|
1438
|
+
return null;
|
|
1439
|
+
} else {
|
|
1440
|
+
converged = hashSymlinkTarget(target) === item.localHash;
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
|
|
1444
|
+
}
|
|
1415
1445
|
} catch (probeErr) {
|
|
1416
1446
|
if (probeErr instanceof VaultAuthError) throw probeErr;
|
|
1417
1447
|
run.emit({
|
|
@@ -1635,9 +1665,23 @@ async function downloadOne(
|
|
|
1635
1665
|
|
|
1636
1666
|
const localLstat = fs.lstatSync(localPath);
|
|
1637
1667
|
const isLocalSymlink = localLstat.isSymbolicLink();
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1668
|
+
let hash: string;
|
|
1669
|
+
if (isLocalSymlink) {
|
|
1670
|
+
const target = readlinkOrNull(localPath);
|
|
1671
|
+
if (target === null) {
|
|
1672
|
+
counters.filesSkipped++;
|
|
1673
|
+
run.emit({
|
|
1674
|
+
type: "not-shipped",
|
|
1675
|
+
reason: "unreadable-link",
|
|
1676
|
+
count: 1,
|
|
1677
|
+
samplePaths: [remoteFile.key],
|
|
1678
|
+
});
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
hash = hashSymlinkTarget(target);
|
|
1682
|
+
} else {
|
|
1683
|
+
hash = contentHash ?? hashFile(localPath);
|
|
1684
|
+
}
|
|
1641
1685
|
const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
|
|
1642
1686
|
|
|
1643
1687
|
updateEntry(
|
|
@@ -2166,6 +2210,11 @@ type PullPlanItem =
|
|
|
2166
2210
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
2167
2211
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
2168
2212
|
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
2213
|
+
// A local symlink was classified as a link but Windows (or a filesystem
|
|
2214
|
+
// race) would not expose its target. This stays distinct from generic
|
|
2215
|
+
// skip-local-only so the executor can report the exact path without making
|
|
2216
|
+
// the whole company leg fail.
|
|
2217
|
+
| { action: "skip-unreadable-link"; remoteFile: RemoteFile; localPath: string }
|
|
2169
2218
|
// A stale personal-overlay symlink marker in the vault sitting at a key that
|
|
2170
2219
|
// is ALSO a real, release-shipped core directory (e.g. an old
|
|
2171
2220
|
// `core/knowledge/public/agent-browser` overlay marker after a release
|
|
@@ -2800,9 +2849,19 @@ function computePullPlan(
|
|
|
2800
2849
|
) {
|
|
2801
2850
|
localHash = journalEntry.hash;
|
|
2802
2851
|
} else {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2852
|
+
if (isLocalSymlink) {
|
|
2853
|
+
const target = readlinkOrNull(localPath);
|
|
2854
|
+
if (target === null) {
|
|
2855
|
+
// We cannot prove whether the local link diverged, so never
|
|
2856
|
+
// overwrite or tombstone it from this plan. A later pass can retry
|
|
2857
|
+
// once the OS exposes a readable target.
|
|
2858
|
+
items.push({ action: "skip-unreadable-link", remoteFile, localPath });
|
|
2859
|
+
continue;
|
|
2860
|
+
}
|
|
2861
|
+
localHash = hashSymlinkTarget(target);
|
|
2862
|
+
} else {
|
|
2863
|
+
localHash = hashFile(localPath);
|
|
2864
|
+
}
|
|
2806
2865
|
}
|
|
2807
2866
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
2808
2867
|
plannedLocalSnapshot = {
|
|
@@ -3329,6 +3388,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
3329
3388
|
console.warn(
|
|
3330
3389
|
` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
|
|
3331
3390
|
);
|
|
3391
|
+
} else if (event.reason === "unreadable-link") {
|
|
3392
|
+
console.warn(
|
|
3393
|
+
` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
|
|
3394
|
+
);
|
|
3332
3395
|
} else {
|
|
3333
3396
|
console.warn(
|
|
3334
3397
|
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readlinkOrNull } from "./readlink-safe.js";
|
|
3
|
+
|
|
4
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
5
|
+
const err = new Error(`${code}: scripted readlink failure`) as NodeJS.ErrnoException;
|
|
6
|
+
err.code = code;
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("readlinkOrNull", () => {
|
|
11
|
+
it("uses the filesystem reader by default", () => {
|
|
12
|
+
expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns the target string verbatim when readlink succeeds", () => {
|
|
16
|
+
expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe(
|
|
17
|
+
"../target with spaces",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
|
|
22
|
+
expect(readlinkOrNull("/test/link", () => {
|
|
23
|
+
throw errnoError(code);
|
|
24
|
+
})).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rethrows non-filesystem failures", () => {
|
|
28
|
+
const defect = new Error("unexpected test defect");
|
|
29
|
+
|
|
30
|
+
expect(() => readlinkOrNull("/test/link", () => {
|
|
31
|
+
throw defect;
|
|
32
|
+
})).toThrow(defect);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rethrows code-bearing programming errors", () => {
|
|
36
|
+
const defect = new TypeError("path must be a string") as TypeError & { code: string };
|
|
37
|
+
defect.code = "ERR_INVALID_ARG_TYPE";
|
|
38
|
+
|
|
39
|
+
expect(() => readlinkOrNull("/test/link", () => {
|
|
40
|
+
throw defect;
|
|
41
|
+
})).toThrow(defect);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a link target without turning an expected filesystem race or an
|
|
5
|
+
* unsupported link shape into a caller-wide failure.
|
|
6
|
+
*
|
|
7
|
+
* A `null` result is deliberately distinct from an empty target string: it
|
|
8
|
+
* means the caller must preserve the link boundary and choose its conservative
|
|
9
|
+
* skip/defer path. Non-filesystem exceptions still propagate so programming
|
|
10
|
+
* errors cannot be mistaken for an unreadable link.
|
|
11
|
+
*/
|
|
12
|
+
export function readlinkOrNull(
|
|
13
|
+
linkPath: string,
|
|
14
|
+
readlink: (path: string) => string = (path) => fs.readlinkSync(path, "utf8"),
|
|
15
|
+
): string | null {
|
|
16
|
+
try {
|
|
17
|
+
return readlink(linkPath);
|
|
18
|
+
} catch (err: unknown) {
|
|
19
|
+
const code =
|
|
20
|
+
err && typeof err === "object" && "code" in err
|
|
21
|
+
? (err as { code?: unknown }).code
|
|
22
|
+
: undefined;
|
|
23
|
+
// Node system errors use POSIX-style errno names such as EINVAL and
|
|
24
|
+
// ENOENT. Runtime/programming errors use ERR_* codes; those must remain
|
|
25
|
+
// loud rather than being misclassified as an unreadable link.
|
|
26
|
+
if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code)) return null;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|