@indigoai-us/hq-cloud 5.43.0 → 5.45.0
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.d.ts +12 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +43 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +96 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +63 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +65 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +135 -0
- package/src/bin/sync-runner.ts +51 -3
- package/src/cli/sync.test.ts +81 -0
- package/src/cli/sync.ts +75 -0
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
routeChangeToTarget,
|
|
20
20
|
buildTargetedPushArgv,
|
|
21
21
|
resolvePullScope,
|
|
22
|
+
readPinnedPrefixes,
|
|
22
23
|
} from "./sync-runner.js";
|
|
23
24
|
import type {
|
|
24
25
|
RunnerEvent,
|
|
@@ -3338,4 +3339,138 @@ describe("resolvePullScope", () => {
|
|
|
3338
3339
|
);
|
|
3339
3340
|
expect(scope).toEqual({ syncMode: "all" });
|
|
3340
3341
|
});
|
|
3342
|
+
|
|
3343
|
+
// ── pin union (Phase C: hq files get) ──────────────────────────────────────
|
|
3344
|
+
|
|
3345
|
+
it("unions pinned prefixes into a shared-mode scope", async () => {
|
|
3346
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pins-shared-"));
|
|
3347
|
+
try {
|
|
3348
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
3349
|
+
fs.writeFileSync(
|
|
3350
|
+
path.join(root, ".hq", "pins.json"),
|
|
3351
|
+
JSON.stringify({ version: 1, pins: { acme: ["pinned/dir/"] } }),
|
|
3352
|
+
);
|
|
3353
|
+
const scope = await resolvePullScope(
|
|
3354
|
+
stubClient({
|
|
3355
|
+
listMyMemberships: async () => [membership("cmp_a", "mk_a")],
|
|
3356
|
+
getMembershipSyncConfig: async () => ({
|
|
3357
|
+
membershipId: "mk_a",
|
|
3358
|
+
syncMode: "shared",
|
|
3359
|
+
isDefault: false,
|
|
3360
|
+
}),
|
|
3361
|
+
listMyExplicitGrants: async () =>
|
|
3362
|
+
[{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" }] as never,
|
|
3363
|
+
}),
|
|
3364
|
+
"cmp_a",
|
|
3365
|
+
"acme",
|
|
3366
|
+
root,
|
|
3367
|
+
);
|
|
3368
|
+
expect(scope.syncMode).toBe("shared");
|
|
3369
|
+
// Grant prefix + pinned prefix, coalesced + sorted.
|
|
3370
|
+
expect(scope.prefixSet).toEqual(["knowledge/", "pinned/dir/"]);
|
|
3371
|
+
} finally {
|
|
3372
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
3373
|
+
}
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
it("unions pinned prefixes into a custom-mode scope", async () => {
|
|
3377
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pins-custom-"));
|
|
3378
|
+
try {
|
|
3379
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
3380
|
+
fs.writeFileSync(
|
|
3381
|
+
path.join(root, ".hq", "pins.json"),
|
|
3382
|
+
JSON.stringify({ version: 1, pins: { acme: ["extra/"] } }),
|
|
3383
|
+
);
|
|
3384
|
+
const scope = await resolvePullScope(
|
|
3385
|
+
stubClient({
|
|
3386
|
+
listMyMemberships: async () => [membership("cmp_a", "mk_a")],
|
|
3387
|
+
getMembershipSyncConfig: async () => ({
|
|
3388
|
+
membershipId: "mk_a",
|
|
3389
|
+
syncMode: "custom",
|
|
3390
|
+
customPaths: ["projects/x/"],
|
|
3391
|
+
isDefault: false,
|
|
3392
|
+
}),
|
|
3393
|
+
}),
|
|
3394
|
+
"cmp_a",
|
|
3395
|
+
"acme",
|
|
3396
|
+
root,
|
|
3397
|
+
);
|
|
3398
|
+
expect(scope.syncMode).toBe("custom");
|
|
3399
|
+
expect(scope.prefixSet).toEqual(["extra/", "projects/x/"]);
|
|
3400
|
+
} finally {
|
|
3401
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
3402
|
+
}
|
|
3403
|
+
});
|
|
3404
|
+
|
|
3405
|
+
it("ignores pins for other companies (per-slug isolation)", async () => {
|
|
3406
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pins-iso-"));
|
|
3407
|
+
try {
|
|
3408
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
3409
|
+
fs.writeFileSync(
|
|
3410
|
+
path.join(root, ".hq", "pins.json"),
|
|
3411
|
+
JSON.stringify({ version: 1, pins: { beta: ["beta-only/"] } }),
|
|
3412
|
+
);
|
|
3413
|
+
const scope = await resolvePullScope(
|
|
3414
|
+
stubClient({
|
|
3415
|
+
listMyMemberships: async () => [membership("cmp_a", "mk_a")],
|
|
3416
|
+
getMembershipSyncConfig: async () => ({
|
|
3417
|
+
membershipId: "mk_a",
|
|
3418
|
+
syncMode: "shared",
|
|
3419
|
+
isDefault: false,
|
|
3420
|
+
}),
|
|
3421
|
+
listMyExplicitGrants: async () =>
|
|
3422
|
+
[{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" }] as never,
|
|
3423
|
+
}),
|
|
3424
|
+
"cmp_a",
|
|
3425
|
+
"acme",
|
|
3426
|
+
root,
|
|
3427
|
+
);
|
|
3428
|
+
// beta's pin must not leak into acme's scope.
|
|
3429
|
+
expect(scope.prefixSet).toEqual(["knowledge/"]);
|
|
3430
|
+
} finally {
|
|
3431
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
3432
|
+
}
|
|
3433
|
+
});
|
|
3434
|
+
});
|
|
3435
|
+
|
|
3436
|
+
// ---------------------------------------------------------------------------
|
|
3437
|
+
// readPinnedPrefixes — per-machine pin set reader
|
|
3438
|
+
// ---------------------------------------------------------------------------
|
|
3439
|
+
|
|
3440
|
+
describe("readPinnedPrefixes", () => {
|
|
3441
|
+
let root: string;
|
|
3442
|
+
beforeEach(() => {
|
|
3443
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-readpins-"));
|
|
3444
|
+
});
|
|
3445
|
+
afterEach(() => {
|
|
3446
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
3447
|
+
});
|
|
3448
|
+
|
|
3449
|
+
it("returns [] when the pins file is missing", () => {
|
|
3450
|
+
expect(readPinnedPrefixes(root, "acme")).toEqual([]);
|
|
3451
|
+
});
|
|
3452
|
+
|
|
3453
|
+
it("returns the company's pinned prefixes", () => {
|
|
3454
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
3455
|
+
fs.writeFileSync(
|
|
3456
|
+
path.join(root, ".hq", "pins.json"),
|
|
3457
|
+
JSON.stringify({ version: 1, pins: { acme: ["a/", "b/"], beta: ["c/"] } }),
|
|
3458
|
+
);
|
|
3459
|
+
expect(readPinnedPrefixes(root, "acme")).toEqual(["a/", "b/"]);
|
|
3460
|
+
});
|
|
3461
|
+
|
|
3462
|
+
it("drops empty-string entries (an everything-pin is meaningless here)", () => {
|
|
3463
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
3464
|
+
fs.writeFileSync(
|
|
3465
|
+
path.join(root, ".hq", "pins.json"),
|
|
3466
|
+
JSON.stringify({ version: 1, pins: { acme: ["", "real/"] } }),
|
|
3467
|
+
);
|
|
3468
|
+
expect(readPinnedPrefixes(root, "acme")).toEqual(["real/"]);
|
|
3469
|
+
});
|
|
3470
|
+
|
|
3471
|
+
it("returns [] on a corrupt pins file", () => {
|
|
3472
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
3473
|
+
fs.writeFileSync(path.join(root, ".hq", "pins.json"), "{ not json");
|
|
3474
|
+
expect(readPinnedPrefixes(root, "acme")).toEqual([]);
|
|
3475
|
+
});
|
|
3341
3476
|
});
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -379,6 +379,9 @@ export async function resolvePullScope(
|
|
|
379
379
|
// Company slug — required to normalize grant paths (which may be anchored
|
|
380
380
|
// at `companies/<slug>/` or `<slug>/`) into the company-relative namespace.
|
|
381
381
|
slug: string,
|
|
382
|
+
// Local HQ root — used to read the per-machine pin set (`.hq/pins.json`).
|
|
383
|
+
// When omitted, pins are simply not unioned (no behavior change).
|
|
384
|
+
hqRoot?: string,
|
|
382
385
|
): Promise<PullScope> {
|
|
383
386
|
if (!client.getMembershipSyncConfig) return { syncMode: "all" };
|
|
384
387
|
try {
|
|
@@ -387,6 +390,14 @@ export async function resolvePullScope(
|
|
|
387
390
|
if (!m) return { syncMode: "all" };
|
|
388
391
|
const cfg = await client.getMembershipSyncConfig(m.membershipKey);
|
|
389
392
|
if (cfg.syncMode === "all") return { syncMode: "all" };
|
|
393
|
+
|
|
394
|
+
// Pins are company-relative prefixes a user explicitly materialized via
|
|
395
|
+
// `hq files get`. They're unioned into the scope so a scoped pull keeps
|
|
396
|
+
// them instead of pruning them as out-of-scope orphans. Pins only WIDEN
|
|
397
|
+
// scope, never narrow — and `all` mode (handled above) ignores them since
|
|
398
|
+
// it pulls everything anyway.
|
|
399
|
+
const pinPrefixes = hqRoot ? readPinnedPrefixes(hqRoot, slug) : [];
|
|
400
|
+
|
|
390
401
|
if (cfg.syncMode === "custom") {
|
|
391
402
|
const customPrefixes = (cfg.customPaths ?? []).map((p) =>
|
|
392
403
|
grantPathToPrefix(p, slug),
|
|
@@ -395,7 +406,10 @@ export async function resolvePullScope(
|
|
|
395
406
|
// `coalescePrefixes` (which drops empties) to "nothing", which would
|
|
396
407
|
// prune the whole tree. An everything-scope is semantically `all`.
|
|
397
408
|
if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
|
|
398
|
-
return {
|
|
409
|
+
return {
|
|
410
|
+
syncMode: "custom",
|
|
411
|
+
prefixSet: coalescePrefixes([...customPrefixes, ...pinPrefixes]),
|
|
412
|
+
};
|
|
399
413
|
}
|
|
400
414
|
// shared: scope to the caller's explicit grants. Real grant paths are
|
|
401
415
|
// inconsistent — full (`companies/<slug>/x/*`), slug-anchored
|
|
@@ -416,13 +430,42 @@ export async function resolvePullScope(
|
|
|
416
430
|
// `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
|
|
417
431
|
// treat any such grant as full-access `all` rather than risk pruning.
|
|
418
432
|
if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
|
|
419
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
syncMode: "shared",
|
|
435
|
+
prefixSet: coalescePrefixes([...sharedPrefixes, ...pinPrefixes]),
|
|
436
|
+
};
|
|
420
437
|
} catch {
|
|
421
438
|
// Degrade to `all` — never prune on a resolution failure.
|
|
422
439
|
return { syncMode: "all" };
|
|
423
440
|
}
|
|
424
441
|
}
|
|
425
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Read the per-machine pin set (`<hqRoot>/.hq/pins.json`) and return the
|
|
445
|
+
* company-relative pinned prefixes for `slug`. These are prefixes the user
|
|
446
|
+
* materialized on demand via `hq files get` that must survive a scoped pull.
|
|
447
|
+
*
|
|
448
|
+
* Tolerant by construction: a missing, unreadable, or malformed file yields
|
|
449
|
+
* `[]` (no pins) — pins only ever widen scope, so "no pins" is the safe
|
|
450
|
+
* default. Empty-string entries are dropped (an everything-pin is meaningless
|
|
451
|
+
* here; `all` mode already covers that case).
|
|
452
|
+
*/
|
|
453
|
+
export function readPinnedPrefixes(hqRoot: string, slug: string): string[] {
|
|
454
|
+
try {
|
|
455
|
+
const raw = fs.readFileSync(path.join(hqRoot, ".hq", "pins.json"), "utf-8");
|
|
456
|
+
const parsed = JSON.parse(raw) as { pins?: Record<string, unknown> };
|
|
457
|
+
const list = parsed?.pins?.[slug];
|
|
458
|
+
if (Array.isArray(list)) {
|
|
459
|
+
return list.filter(
|
|
460
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
/* missing / unreadable / malformed → no pins */
|
|
465
|
+
}
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
|
|
426
469
|
/**
|
|
427
470
|
* Backoff schedule (in ms) between attempts 2 and 3 of
|
|
428
471
|
* `listMembershipsWithRetry`. Short on purpose — memberships is a single
|
|
@@ -1259,7 +1302,12 @@ export async function runRunner(
|
|
|
1259
1302
|
const pullScope: PullScope =
|
|
1260
1303
|
target.personalMode === true
|
|
1261
1304
|
? { syncMode: "all" }
|
|
1262
|
-
: await resolvePullScope(
|
|
1305
|
+
: await resolvePullScope(
|
|
1306
|
+
client,
|
|
1307
|
+
target.uid,
|
|
1308
|
+
target.slug,
|
|
1309
|
+
parsed.hqRoot,
|
|
1310
|
+
);
|
|
1263
1311
|
pullResult = await syncFn({
|
|
1264
1312
|
company: target.uid,
|
|
1265
1313
|
vaultConfig,
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -1233,6 +1233,87 @@ describe("sync", () => {
|
|
|
1233
1233
|
);
|
|
1234
1234
|
});
|
|
1235
1235
|
|
|
1236
|
+
it("reports new files to POST /v1/notify/file-added with company + file metadata", async () => {
|
|
1237
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
|
|
1238
|
+
if (key === "docs/handoff.md") {
|
|
1239
|
+
return {
|
|
1240
|
+
lastModified: new Date(),
|
|
1241
|
+
etag: '"abc123"',
|
|
1242
|
+
size: 42,
|
|
1243
|
+
metadata: { "created-by": "alice@example.com" } as Record<string, string>,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
if (key === "knowledge/readme.md") {
|
|
1247
|
+
return {
|
|
1248
|
+
lastModified: new Date(),
|
|
1249
|
+
etag: '"def456"',
|
|
1250
|
+
size: 100,
|
|
1251
|
+
metadata: {} as Record<string, string>,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
await sync({
|
|
1258
|
+
company: "acme",
|
|
1259
|
+
vaultConfig: mockConfig,
|
|
1260
|
+
hqRoot: tmpDir,
|
|
1261
|
+
onEvent: () => {},
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
const calls = vi.mocked(globalThis.fetch).mock.calls as Array<
|
|
1265
|
+
[string, RequestInit?]
|
|
1266
|
+
>;
|
|
1267
|
+
const post = calls.find(([u]) =>
|
|
1268
|
+
String(u).includes("/v1/notify/file-added"),
|
|
1269
|
+
);
|
|
1270
|
+
expect(post).toBeDefined();
|
|
1271
|
+
expect(String(post![0])).toBe("https://vault-api.test/v1/notify/file-added");
|
|
1272
|
+
const init = post![1]!;
|
|
1273
|
+
expect(init.method).toBe("POST");
|
|
1274
|
+
expect((init.headers as Record<string, string>).Authorization).toMatch(
|
|
1275
|
+
/^Bearer /,
|
|
1276
|
+
);
|
|
1277
|
+
const body = JSON.parse(String(init.body));
|
|
1278
|
+
expect(body.companySlug).toBe("acme");
|
|
1279
|
+
expect(body.companyUid).toBe("cmp_01ABCDEF");
|
|
1280
|
+
expect(body.files).toEqual(
|
|
1281
|
+
expect.arrayContaining([
|
|
1282
|
+
{ path: "docs/handoff.md", bytes: 42, addedBy: "alice@example.com" },
|
|
1283
|
+
{ path: "knowledge/readme.md", bytes: 100 }, // null addedBy omitted
|
|
1284
|
+
]),
|
|
1285
|
+
);
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it("never lets a file-added report failure break the sync (best-effort)", async () => {
|
|
1289
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
|
|
1290
|
+
// Make ONLY the notify report throw; delegate everything else to the
|
|
1291
|
+
// default mock so the sync itself still runs.
|
|
1292
|
+
const base = vi.mocked(globalThis.fetch).getMockImplementation()!;
|
|
1293
|
+
vi.mocked(globalThis.fetch).mockImplementation(
|
|
1294
|
+
async (url: unknown, init?: unknown) => {
|
|
1295
|
+
if (String(url).includes("/v1/notify/file-added")) {
|
|
1296
|
+
throw new Error("notify endpoint down");
|
|
1297
|
+
}
|
|
1298
|
+
return base(url as string, init as RequestInit);
|
|
1299
|
+
},
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1302
|
+
const newFilesEvents: Array<{ type: string }> = [];
|
|
1303
|
+
await expect(
|
|
1304
|
+
sync({
|
|
1305
|
+
company: "acme",
|
|
1306
|
+
vaultConfig: mockConfig,
|
|
1307
|
+
hqRoot: tmpDir,
|
|
1308
|
+
onEvent: (e) => {
|
|
1309
|
+
if (e.type === "new-files") newFilesEvents.push(e);
|
|
1310
|
+
},
|
|
1311
|
+
}),
|
|
1312
|
+
).resolves.toBeDefined();
|
|
1313
|
+
// Sync still completed and emitted its new-files event.
|
|
1314
|
+
expect(newFilesEvents).toHaveLength(1);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1236
1317
|
it("sets addedBy to null when HeadObject fails (best-effort)", async () => {
|
|
1237
1318
|
vi.mocked(s3Module.headRemoteFile).mockRejectedValue(new Error("S3 transient error"));
|
|
1238
1319
|
|
package/src/cli/sync.ts
CHANGED
|
@@ -382,6 +382,73 @@ export function resolveAutoPruneCap(): number {
|
|
|
382
382
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/** Max time to wait on the best-effort new-files notification POST. */
|
|
386
|
+
const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Best-effort report of the files that were new to this drive during the sync,
|
|
390
|
+
* so the HQ Sync app can show a persistent cross-session "new files" history.
|
|
391
|
+
*
|
|
392
|
+
* POSTs to `${apiUrl}/v1/notify/file-added`, which writes per-recipient
|
|
393
|
+
* FILE_EVENT rows for the calling user (the one the files are new for). Fully
|
|
394
|
+
* non-fatal: any error, non-2xx, or timeout is swallowed — the durable signal
|
|
395
|
+
* is the synced file itself; this is only a notification mirror. Bounded by a
|
|
396
|
+
* 5s timeout so a hung endpoint can't stall sync completion. No-op when there
|
|
397
|
+
* are no new files.
|
|
398
|
+
*/
|
|
399
|
+
async function reportNewFilesToNotify(
|
|
400
|
+
vaultConfig: VaultServiceConfig,
|
|
401
|
+
companyUid: string,
|
|
402
|
+
companySlug: string,
|
|
403
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
if (files.length === 0) return;
|
|
406
|
+
try {
|
|
407
|
+
const token =
|
|
408
|
+
typeof vaultConfig.authToken === "function"
|
|
409
|
+
? await vaultConfig.authToken()
|
|
410
|
+
: vaultConfig.authToken;
|
|
411
|
+
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const timer = setTimeout(
|
|
414
|
+
() => controller.abort(),
|
|
415
|
+
NOTIFY_FILE_ADDED_TIMEOUT_MS,
|
|
416
|
+
);
|
|
417
|
+
try {
|
|
418
|
+
await fetch(`${base}/v1/notify/file-added`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: {
|
|
421
|
+
Authorization: `Bearer ${token}`,
|
|
422
|
+
"Content-Type": "application/json",
|
|
423
|
+
},
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
companyUid,
|
|
426
|
+
companySlug,
|
|
427
|
+
files: files.map((f) => ({
|
|
428
|
+
path: f.path,
|
|
429
|
+
bytes: f.bytes,
|
|
430
|
+
...(f.addedBy ? { addedBy: f.addedBy } : {}),
|
|
431
|
+
})),
|
|
432
|
+
}),
|
|
433
|
+
signal: controller.signal,
|
|
434
|
+
});
|
|
435
|
+
} finally {
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
}
|
|
438
|
+
} catch (err) {
|
|
439
|
+
// Best-effort: never let notification reporting affect the sync result.
|
|
440
|
+
try {
|
|
441
|
+
console.error(
|
|
442
|
+
`[hq-sync] new-files notify report failed (non-fatal): ${
|
|
443
|
+
err instanceof Error ? err.message : String(err)
|
|
444
|
+
}`,
|
|
445
|
+
);
|
|
446
|
+
} catch {
|
|
447
|
+
// swallow — logging must never break sync
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
385
452
|
/**
|
|
386
453
|
* Sync (pull) all allowed files from the entity vault.
|
|
387
454
|
*/
|
|
@@ -932,6 +999,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
932
999
|
}
|
|
933
1000
|
emit({ type: "new-files", files: enrichedNewFiles });
|
|
934
1001
|
|
|
1002
|
+
// Report new files to the notification service so they persist as a
|
|
1003
|
+
// cross-session "new files" history in the HQ Sync app (POST
|
|
1004
|
+
// /v1/notify/file-added → per-recipient FILE_EVENT rows for THIS user, who is
|
|
1005
|
+
// the one the files are new for). Best-effort and bounded: a failure or a
|
|
1006
|
+
// hung request must never delay or break the sync — the durable signal is the
|
|
1007
|
+
// synced file itself, this is only a notification mirror.
|
|
1008
|
+
await reportNewFilesToNotify(vaultConfig, ctx.uid, ctx.slug, enrichedNewFiles);
|
|
1009
|
+
|
|
935
1010
|
// Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
|
|
936
1011
|
// a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
|
|
937
1012
|
// (guest sessions with `allowedPrefixes`, role downgrade, custom
|