@indigoai-us/hq-cloud 6.11.8 → 6.11.10
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.map +1 -1
- package/dist/bin/sync-runner.js +4 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +72 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.test.js +44 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +25 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/company-resolver.d.ts +77 -0
- package/dist/company-resolver.d.ts.map +1 -0
- package/dist/company-resolver.js +124 -0
- package/dist/company-resolver.js.map +1 -0
- package/dist/company-resolver.test.d.ts +7 -0
- package/dist/company-resolver.test.d.ts.map +1 -0
- package/dist/company-resolver.test.js +120 -0
- package/dist/company-resolver.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/personal-vault.d.ts +24 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +36 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +46 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +22 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +101 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.test.js +18 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.test.js +37 -4
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/logger.test.js +1 -0
- package/dist/sync/logger.test.js.map +1 -1
- package/dist/sync/metrics.test.js +1 -0
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +1 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +26 -0
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-event.d.ts +23 -11
- package/dist/sync/push-event.d.ts.map +1 -1
- package/dist/sync/push-event.js +15 -8
- package/dist/sync/push-event.js.map +1 -1
- package/dist/sync/push-event.test.js +39 -3
- package/dist/sync/push-event.test.js.map +1 -1
- package/dist/sync/push-receiver.test.js +1 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +18 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +28 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +93 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +4 -2
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +25 -9
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +65 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +90 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.test.ts +53 -0
- package/src/cli/rescue-classify-ordering.test.ts +28 -0
- package/src/company-resolver.test.ts +136 -0
- package/src/company-resolver.ts +147 -0
- package/src/index.ts +1 -0
- package/src/personal-vault.test.ts +56 -0
- package/src/personal-vault.ts +36 -1
- package/src/skill-telemetry.test.ts +126 -1
- package/src/skill-telemetry.ts +26 -3
- package/src/sync/event-sync.test.ts +21 -0
- package/src/sync/feature-flags.test.ts +40 -4
- package/src/sync/index.ts +5 -1
- package/src/sync/logger.test.ts +1 -0
- package/src/sync/metrics.test.ts +1 -0
- package/src/sync/pull-scope.ts +26 -1
- package/src/sync/push-event.test.ts +45 -3
- package/src/sync/push-event.ts +28 -12
- package/src/sync/push-receiver.test.ts +1 -0
- package/src/telemetry.test.ts +118 -1
- package/src/telemetry.ts +50 -2
- package/src/vault-client.ts +4 -2
- package/src/watcher.test.ts +81 -0
- package/src/watcher.ts +27 -9
- package/test/e2e/sync/cross-tenant-isolation.test.ts +2 -0
package/src/personal-vault.ts
CHANGED
|
@@ -126,7 +126,8 @@ export function computePersonalVaultPaths(
|
|
|
126
126
|
? computePersonalCompanySubdirs(hqRoot, opts.teamSyncedSlugs)
|
|
127
127
|
: [];
|
|
128
128
|
const continuity = computeContinuityPointerPaths(hqRoot);
|
|
129
|
-
|
|
129
|
+
const agency = computeAgencySyncPaths(hqRoot);
|
|
130
|
+
return [...topLevel, ...manifest, ...companySubdirs, ...continuity, ...agency];
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/**
|
|
@@ -213,6 +214,40 @@ export function computeContinuityPointerPaths(hqRoot: string): string[] {
|
|
|
213
214
|
return out;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Fixed relative path (forward-slash, hq-root-relative) of the agency
|
|
219
|
+
* workspace subtree. See {@link computeAgencySyncPaths}.
|
|
220
|
+
*/
|
|
221
|
+
export const AGENCY_SYNC_REL = "workspace/agency";
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compute the absolute path of the agency-workspace carve-out:
|
|
225
|
+
* `workspace/agency/` (the whole subtree).
|
|
226
|
+
*
|
|
227
|
+
* Like the session-continuity pointer above, this pierces the `workspace/`
|
|
228
|
+
* top-level exclusion for ONE specific subtree. hq-pack-agency teams keep
|
|
229
|
+
* their cross-session chat.jsonl inboxes and roster/state under
|
|
230
|
+
* `workspace/agency/<company>/<team>/`; that state must travel across machines
|
|
231
|
+
* so an agency team picked up on machine B sees the same conversation and
|
|
232
|
+
* roster it had on machine A. The rest of `workspace/` (session scratch,
|
|
233
|
+
* locks, reports, full thread history) stays machine-local.
|
|
234
|
+
*
|
|
235
|
+
* Returns the directory path when it exists; share()'s per-file walk handles
|
|
236
|
+
* recursion, and the nested personal-vault exclusions still apply to files
|
|
237
|
+
* inside it (e.g. a stray `node_modules/` or `.env`). Fail-soft: a missing or
|
|
238
|
+
* unreadable `workspace/agency/` returns []. Callers tolerate empty arrays —
|
|
239
|
+
* same contract as the manifest + continuity special-cases.
|
|
240
|
+
*/
|
|
241
|
+
export function computeAgencySyncPaths(hqRoot: string): string[] {
|
|
242
|
+
const agencyDir = path.join(hqRoot, "workspace", "agency");
|
|
243
|
+
try {
|
|
244
|
+
if (fs.statSync(agencyDir).isDirectory()) return [agencyDir];
|
|
245
|
+
} catch {
|
|
246
|
+
// No agency workspace on this machine — nothing to carry.
|
|
247
|
+
}
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
|
|
216
251
|
/**
|
|
217
252
|
* Discover `companies/{slug}/` subdirs that should sync to the personal
|
|
218
253
|
* bucket as a fallback for companies the operator has not designated as
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
@@ -923,3 +923,128 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
923
923
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
924
924
|
});
|
|
925
925
|
});
|
|
926
|
+
|
|
927
|
+
// ── companyUid edge attribution (US-002) ────────────────────────────────────────
|
|
928
|
+
|
|
929
|
+
describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
|
|
930
|
+
const COMPANY_UID = "cmp_01INDIGOSKILL";
|
|
931
|
+
|
|
932
|
+
function stubClient(captured: SkillInvocationBatch[]) {
|
|
933
|
+
return {
|
|
934
|
+
async getTelemetryOptIn() {
|
|
935
|
+
return { enabled: true, updatedAt: null };
|
|
936
|
+
},
|
|
937
|
+
async postSkillInvocations(batch: SkillInvocationBatch) {
|
|
938
|
+
captured.push(batch);
|
|
939
|
+
return { ok: true, written: batch.events.length, skipped: [] };
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function row(obj: Record<string, unknown>): string {
|
|
945
|
+
return JSON.stringify(obj);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Write a manifest mapping `repos/private/hq-cloud` → COMPANY_UID under hqRoot. */
|
|
949
|
+
async function writeManifest(hqRoot: string): Promise<void> {
|
|
950
|
+
await fs.mkdir(path.join(hqRoot, "companies"), { recursive: true });
|
|
951
|
+
await fs.writeFile(
|
|
952
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
953
|
+
`companies:\n indigo:\n repos:\n - repos/private/hq-cloud\n cloud_uid: ${COMPANY_UID}\n`,
|
|
954
|
+
"utf-8",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
it("stamps companyUid when cwd is inside a company repo, omits it otherwise", async () => {
|
|
959
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-co-"));
|
|
960
|
+
const hqRoot = tmp; // real dir so the manifest's repo path resolves under it
|
|
961
|
+
await writeManifest(hqRoot);
|
|
962
|
+
const projects = path.join(tmp, "projects");
|
|
963
|
+
const dir = path.join(projects, "-p");
|
|
964
|
+
await fs.mkdir(dir, { recursive: true });
|
|
965
|
+
|
|
966
|
+
const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
|
|
967
|
+
const atRoot = hqRoot; // inside scope, but not inside any mapped repo
|
|
968
|
+
await fs.writeFile(
|
|
969
|
+
path.join(dir, "s.jsonl"),
|
|
970
|
+
[
|
|
971
|
+
// cwd inside the company repo → attributed.
|
|
972
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:00:00Z", cwd: inRepo, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
|
|
973
|
+
// cwd at hqRoot (passes scope) but maps to no repo → unattributed.
|
|
974
|
+
row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-10T10:01:00Z", cwd: atRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
|
|
975
|
+
].join("\n") + "\n",
|
|
976
|
+
"utf-8",
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
const captured: SkillInvocationBatch[] = [];
|
|
980
|
+
const result = await collectAndSendSkillTelemetry({
|
|
981
|
+
client: stubClient(captured),
|
|
982
|
+
machineId: "m",
|
|
983
|
+
installerVersion: "t",
|
|
984
|
+
hqRoot,
|
|
985
|
+
claudeProjectsRoot: projects,
|
|
986
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
987
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
expect(result.eventsSent).toBe(2);
|
|
991
|
+
const events = captured.flatMap((b) => b.events);
|
|
992
|
+
const bySkill = new Map(events.map((e) => [e.skill, e]));
|
|
993
|
+
expect(bySkill.get("deploy")!.companyUid).toBe(COMPANY_UID);
|
|
994
|
+
expect("companyUid" in bySkill.get("indigo:hello-world")!).toBe(false);
|
|
995
|
+
// Never the reserved sentinel.
|
|
996
|
+
for (const e of events) expect(e.companyUid).not.toBe("unattributed");
|
|
997
|
+
|
|
998
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it("parses the manifest once per run (cache), not per event", async () => {
|
|
1002
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-cache-"));
|
|
1003
|
+
const hqRoot = tmp;
|
|
1004
|
+
await writeManifest(hqRoot);
|
|
1005
|
+
const projects = path.join(tmp, "projects");
|
|
1006
|
+
const dir = path.join(projects, "-p");
|
|
1007
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1008
|
+
const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
|
|
1009
|
+
await fs.writeFile(
|
|
1010
|
+
path.join(dir, "s.jsonl"),
|
|
1011
|
+
Array.from({ length: 4 }, (_, i) =>
|
|
1012
|
+
row({ type: "user", sessionId: "s1", timestamp: `2026-06-10T10:0${i}:00Z`, cwd: inRepo, uuid: `u${i}`, message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
|
|
1013
|
+
).join("\n") + "\n",
|
|
1014
|
+
"utf-8",
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
1018
|
+
const realReadFile = fs.readFile;
|
|
1019
|
+
let manifestReads = 0;
|
|
1020
|
+
const spy = vi
|
|
1021
|
+
.spyOn(fs, "readFile")
|
|
1022
|
+
.mockImplementation((async (p: Parameters<typeof realReadFile>[0], ...rest: unknown[]) => {
|
|
1023
|
+
if (typeof p === "string" && p === manifestPath) manifestReads++;
|
|
1024
|
+
// @ts-expect-error pass-through to the real implementation
|
|
1025
|
+
return realReadFile(p, ...rest);
|
|
1026
|
+
}) as typeof realReadFile);
|
|
1027
|
+
|
|
1028
|
+
const captured: SkillInvocationBatch[] = [];
|
|
1029
|
+
try {
|
|
1030
|
+
await collectAndSendSkillTelemetry({
|
|
1031
|
+
client: stubClient(captured),
|
|
1032
|
+
machineId: "m",
|
|
1033
|
+
installerVersion: "t",
|
|
1034
|
+
hqRoot,
|
|
1035
|
+
claudeProjectsRoot: projects,
|
|
1036
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
1037
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
1038
|
+
});
|
|
1039
|
+
} finally {
|
|
1040
|
+
spy.mockRestore();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
expect(manifestReads).toBe(1);
|
|
1044
|
+
const events = captured.flatMap((b) => b.events);
|
|
1045
|
+
expect(events).toHaveLength(4);
|
|
1046
|
+
for (const e of events) expect(e.companyUid).toBe(COMPANY_UID);
|
|
1047
|
+
|
|
1048
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1049
|
+
});
|
|
1050
|
+
});
|
package/src/skill-telemetry.ts
CHANGED
|
@@ -59,6 +59,11 @@ import { promises as fs } from "node:fs";
|
|
|
59
59
|
import * as os from "node:os";
|
|
60
60
|
import * as path from "node:path";
|
|
61
61
|
|
|
62
|
+
import {
|
|
63
|
+
buildRepoCompanyMap,
|
|
64
|
+
resolveCompanyForCwd,
|
|
65
|
+
type RepoCompanyMap,
|
|
66
|
+
} from "./company-resolver.js";
|
|
62
67
|
import type {
|
|
63
68
|
SkillInvocationBatch,
|
|
64
69
|
SkillInvocationIngestResult,
|
|
@@ -533,8 +538,15 @@ export function extractCodexSkillToolEvents(
|
|
|
533
538
|
];
|
|
534
539
|
}
|
|
535
540
|
|
|
536
|
-
/** Shape the event for the wire. Drops raw args unless explicitly enabled.
|
|
537
|
-
|
|
541
|
+
/** Shape the event for the wire. Drops raw args unless explicitly enabled.
|
|
542
|
+
*
|
|
543
|
+
* `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
|
|
544
|
+
* (`resolveCompanyForCwd`) and passes that `cmp_*` uid here. It is on the
|
|
545
|
+
* server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
|
|
546
|
+
* skill-invocations.ts`). When undefined (cwd maps to no company repo) the
|
|
547
|
+
* field is OMITTED — the server treats absence as unattributed/personal. The
|
|
548
|
+
* reserved value `unattributed` is never produced. */
|
|
549
|
+
function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
|
|
538
550
|
const row: Record<string, unknown> = {
|
|
539
551
|
skill: ev.skill,
|
|
540
552
|
source: ev.source,
|
|
@@ -544,6 +556,7 @@ function toWireRow(ev: SkillEvent): Record<string, unknown> {
|
|
|
544
556
|
if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
|
|
545
557
|
if (ev.uuid !== undefined) row.uuid = ev.uuid;
|
|
546
558
|
if (ev.cwd !== undefined) row.cwd = ev.cwd;
|
|
559
|
+
if (companyUid !== undefined) row.companyUid = companyUid;
|
|
547
560
|
// INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
|
|
548
561
|
// the guarded branch documents the (currently disabled) egress path.
|
|
549
562
|
if (INCLUDE_ARGS_PREVIEW) {
|
|
@@ -643,6 +656,13 @@ export async function collectAndSendSkillTelemetry(
|
|
|
643
656
|
const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
644
657
|
const scopeCwd = opts.hqRoot !== undefined ? normalizePath(opts.hqRoot) : undefined;
|
|
645
658
|
|
|
659
|
+
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
660
|
+
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
661
|
+
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
662
|
+
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
663
|
+
? await buildRepoCompanyMap(opts.hqRoot)
|
|
664
|
+
: { entries: [] };
|
|
665
|
+
|
|
646
666
|
// 1. Opt-in check — reuse the same gate as token telemetry.
|
|
647
667
|
let enabled: boolean;
|
|
648
668
|
let optInSource: CollectSkillTelemetryResult["optInSource"];
|
|
@@ -788,7 +808,10 @@ export async function collectAndSendSkillTelemetry(
|
|
|
788
808
|
continue;
|
|
789
809
|
}
|
|
790
810
|
}
|
|
791
|
-
|
|
811
|
+
// Resolve cwd → owning company (cmp_* uid) from the per-run map.
|
|
812
|
+
// Unresolved → undefined → companyUid omitted (unattributed/personal).
|
|
813
|
+
const companyUid = resolveCompanyForCwd(ev.cwd, repoCompanyMap);
|
|
814
|
+
sourced.push({ row: toWireRow(ev, companyUid), filePath, endOffset });
|
|
792
815
|
fileScans[filePath].eventCount++;
|
|
793
816
|
}
|
|
794
817
|
}
|
|
@@ -376,6 +376,7 @@ describe("createRefreshingSqsClient", () => {
|
|
|
376
376
|
function pushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
377
377
|
return {
|
|
378
378
|
relativePath: "companies/indigo/docs/a.md",
|
|
379
|
+
kind: "upsert",
|
|
379
380
|
contentHash: `sha256:${"a".repeat(64)}`,
|
|
380
381
|
mtime: "2026-06-10T12:00:00.000Z",
|
|
381
382
|
originDeviceId: "peer-device",
|
|
@@ -469,6 +470,25 @@ describe("startEventSync", () => {
|
|
|
469
470
|
true,
|
|
470
471
|
);
|
|
471
472
|
|
|
473
|
+
// Peer delete tombstone → bridged into syncFn. The runner handles safety by
|
|
474
|
+
// running a targeted pull; it does not unlink directly from this payload.
|
|
475
|
+
await waitForPoll();
|
|
476
|
+
deliverNext!(
|
|
477
|
+
JSON.stringify({
|
|
478
|
+
relativePath: "companies/indigo/deleted.md",
|
|
479
|
+
kind: "delete",
|
|
480
|
+
originDeviceId: "peer-device",
|
|
481
|
+
originTenantId: "prs_tenant",
|
|
482
|
+
sequenceNumber: 3,
|
|
483
|
+
eventTimestamp: "2026-06-10T12:01:00.000Z",
|
|
484
|
+
} satisfies PushEvent),
|
|
485
|
+
);
|
|
486
|
+
await vi.waitFor(() => expect(syncCalls).toHaveLength(2));
|
|
487
|
+
expect(syncCalls[1]).toMatchObject({
|
|
488
|
+
kind: "delete",
|
|
489
|
+
relativePath: "companies/indigo/deleted.md",
|
|
490
|
+
});
|
|
491
|
+
|
|
472
492
|
await handles!.dispose();
|
|
473
493
|
});
|
|
474
494
|
|
|
@@ -522,6 +542,7 @@ describe("startEventSync", () => {
|
|
|
522
542
|
await vi.waitFor(() => expect(published).toHaveLength(1));
|
|
523
543
|
const ev = published[0];
|
|
524
544
|
expect(ev.relativePath).toBe("docs/self.ts");
|
|
545
|
+
expect(ev.kind).toBe("upsert");
|
|
525
546
|
expect(ev.originDeviceId).toBe("this-device");
|
|
526
547
|
expect(ev.originTenantId).toBe("prs_tenant");
|
|
527
548
|
// Wall-clock sequence numbers (restart-safe monotonicity).
|
|
@@ -49,6 +49,7 @@ import type { TreeChangeBatch } from "../watcher.js";
|
|
|
49
49
|
function fakePushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
50
50
|
return {
|
|
51
51
|
relativePath: "personal/notes/a.md",
|
|
52
|
+
kind: "upsert",
|
|
52
53
|
contentHash: `sha256:${"a".repeat(64)}`,
|
|
53
54
|
mtime: "2026-05-21T12:00:00.000Z",
|
|
54
55
|
originDeviceId: "device-1",
|
|
@@ -178,6 +179,34 @@ describe("HttpPushTransport — POST /sync/push", () => {
|
|
|
178
179
|
expect(decodePushEvent(init.body)).toEqual(event);
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
it("POSTs a delete tombstone without contentHash or mtime", async () => {
|
|
183
|
+
const { fetch, calls } = makeFetch({ ok: true, status: 200 });
|
|
184
|
+
const transport = new HttpPushTransport({
|
|
185
|
+
apiUrl: "https://vault-api.example.com/",
|
|
186
|
+
authToken: "tok-123",
|
|
187
|
+
fetchImpl: fetch,
|
|
188
|
+
});
|
|
189
|
+
const event = fakePushEvent({
|
|
190
|
+
kind: "delete",
|
|
191
|
+
contentHash: undefined,
|
|
192
|
+
mtime: undefined,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await transport.publish(event);
|
|
196
|
+
|
|
197
|
+
expect(calls).toHaveLength(1);
|
|
198
|
+
const body = (calls[0].init as { body: string }).body;
|
|
199
|
+
expect(JSON.parse(body)).toEqual({
|
|
200
|
+
relativePath: "personal/notes/a.md",
|
|
201
|
+
kind: "delete",
|
|
202
|
+
originDeviceId: "device-1",
|
|
203
|
+
originTenantId: "indigo",
|
|
204
|
+
sequenceNumber: 0,
|
|
205
|
+
eventTimestamp: "2026-05-21T12:00:01.000Z",
|
|
206
|
+
});
|
|
207
|
+
expect(decodePushEvent(body)).toEqual(JSON.parse(body));
|
|
208
|
+
});
|
|
209
|
+
|
|
181
210
|
it("resolves the auth token per-request via an async getter (self-heals across refresh)", async () => {
|
|
182
211
|
const { fetch, calls } = makeFetch();
|
|
183
212
|
let n = 0;
|
|
@@ -287,6 +316,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
287
316
|
expect(published).toHaveLength(1);
|
|
288
317
|
const e = published[0];
|
|
289
318
|
expect(e.relativePath).toBe(REL);
|
|
319
|
+
expect(e.kind).toBe("upsert");
|
|
290
320
|
expect(e.originTenantId).toBe("indigo");
|
|
291
321
|
expect(e.originDeviceId).toBe("dev-1");
|
|
292
322
|
const expectedHex = createHash("sha256").update("hello world").digest("hex");
|
|
@@ -350,7 +380,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
350
380
|
expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: REL });
|
|
351
381
|
});
|
|
352
382
|
|
|
353
|
-
it("a missing file
|
|
383
|
+
it("a missing file emits a delete tombstone, and other paths still ship", async () => {
|
|
354
384
|
const published: PushEvent[] = [];
|
|
355
385
|
const onError = vi.fn();
|
|
356
386
|
const emitter = new PushEventEmitter({
|
|
@@ -362,9 +392,15 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
362
392
|
});
|
|
363
393
|
// REL exists; "ghost.md" does not.
|
|
364
394
|
await emitter.emitForBatch(batchFor(REL, "ghost.md"));
|
|
365
|
-
expect(published.map((e) => e.relativePath)).toEqual([
|
|
366
|
-
|
|
367
|
-
|
|
395
|
+
expect(published.map((e) => e.relativePath).sort()).toEqual([
|
|
396
|
+
REL,
|
|
397
|
+
"ghost.md",
|
|
398
|
+
].sort());
|
|
399
|
+
const tombstone = published.find((e) => e.relativePath === "ghost.md");
|
|
400
|
+
expect(tombstone).toMatchObject({ kind: "delete" });
|
|
401
|
+
expect(tombstone).not.toHaveProperty("contentHash");
|
|
402
|
+
expect(tombstone).not.toHaveProperty("mtime");
|
|
403
|
+
expect(onError).not.toHaveBeenCalled();
|
|
368
404
|
});
|
|
369
405
|
|
|
370
406
|
it("end-to-end: emit → HttpPushTransport POST with a valid schema (mocked fetch)", async () => {
|
package/src/sync/index.ts
CHANGED
|
@@ -13,7 +13,11 @@ export {
|
|
|
13
13
|
encodePushEvent,
|
|
14
14
|
decodePushEvent,
|
|
15
15
|
} from "./push-event.js";
|
|
16
|
-
export type {
|
|
16
|
+
export type {
|
|
17
|
+
PushEvent,
|
|
18
|
+
PushEventInput,
|
|
19
|
+
PushEventDecodeIssue,
|
|
20
|
+
} from "./push-event.js";
|
|
17
21
|
|
|
18
22
|
export { NoopPushTransport, HttpPushTransport } from "./push-transport.js";
|
|
19
23
|
export type {
|
package/src/sync/logger.test.ts
CHANGED
|
@@ -70,6 +70,7 @@ async function until(predicate: () => boolean, timeoutMs = 1000): Promise<void>
|
|
|
70
70
|
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
71
71
|
return {
|
|
72
72
|
relativePath: "companies/indigo/notes.md",
|
|
73
|
+
kind: "upsert",
|
|
73
74
|
contentHash:
|
|
74
75
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
75
76
|
mtime: "2026-05-21T12:34:56.000Z",
|
package/src/sync/metrics.test.ts
CHANGED
|
@@ -82,6 +82,7 @@ const QUEUE_URL =
|
|
|
82
82
|
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
83
83
|
return {
|
|
84
84
|
relativePath: "companies/indigo/notes.md",
|
|
85
|
+
kind: "upsert",
|
|
85
86
|
contentHash:
|
|
86
87
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
87
88
|
mtime: "2026-05-21T12:34:56.000Z",
|
package/src/sync/pull-scope.ts
CHANGED
|
@@ -35,7 +35,7 @@ import type {
|
|
|
35
35
|
*/
|
|
36
36
|
export interface PullScopeClient {
|
|
37
37
|
listMyMemberships(): Promise<
|
|
38
|
-
Array<{ companyUid: string; membershipKey: string }>
|
|
38
|
+
Array<{ companyUid: string; membershipKey: string; role?: string }>
|
|
39
39
|
>;
|
|
40
40
|
getMembershipSyncConfig?: (
|
|
41
41
|
membershipId: string,
|
|
@@ -82,6 +82,31 @@ export async function resolvePullScope(
|
|
|
82
82
|
const memberships = await client.listMyMemberships();
|
|
83
83
|
const m = memberships.find((x) => x.companyUid === companyUid);
|
|
84
84
|
if (!m) return { syncMode: "all" };
|
|
85
|
+
// OWNER role-bypass (feedback_67bdb8a4 / feedback_a46c3b37). An owner's
|
|
86
|
+
// effective vault access is the WHOLE company by role — the data plane
|
|
87
|
+
// resolves owner to `admin` on every key (`resolveEffectivePermission`'s
|
|
88
|
+
// role bypass; presign's `viewOthersFiles` is unconditional for owners).
|
|
89
|
+
// But the scope resolution below derives `shared` scope from
|
|
90
|
+
// `listMyExplicitGrants`, and that endpoint DELIBERATELY EXCLUDES the
|
|
91
|
+
// owner/admin role bypass — it answers "what is explicitly shared with
|
|
92
|
+
// me", not "what can I touch by role" (see hq-pro files-grants handler).
|
|
93
|
+
// An owner who owns by role (not by explicit grant) therefore resolves to
|
|
94
|
+
// an empty/sparse prefixSet, so the push uploads NOTHING (every path is
|
|
95
|
+
// filtered out by `wrapFilterWithScope`) and any in-role write — including
|
|
96
|
+
// a brand-new top-level project prefix the owner just created — is rejected
|
|
97
|
+
// by the server as `403 SCOPE_EXCEEDS_PARENT`. An owner has no meaningful
|
|
98
|
+
// "shared subset": they own everything, so their effective sync scope IS
|
|
99
|
+
// the full vault. Resolve them to `all` directly — restoring the pre-
|
|
100
|
+
// pull-scope-refactor behavior (owners always journaled the whole company).
|
|
101
|
+
// This is checked BEFORE the sync-config fetch so it holds even if a stray
|
|
102
|
+
// `shared` row exists for the owner. A `custom` choice would also be
|
|
103
|
+
// overridden here, which is intentional: an owner cannot be scoped below
|
|
104
|
+
// their role on the sync path without the server rejecting their own
|
|
105
|
+
// writes. Non-owner roles fall through to the grant-based resolution
|
|
106
|
+
// below — a member/guest's explicit grants ARE their full footprint, and a
|
|
107
|
+
// (conditionally bypassed) admin stays grant-scoped so a company that set
|
|
108
|
+
// `admin.viewOthersFiles=false` keeps its admins narrowed.
|
|
109
|
+
if (m.role === "owner") return { syncMode: "all" };
|
|
85
110
|
// Agent memberships (`agt_…#cmp_…`) belong to an agent identity that owns NO
|
|
86
111
|
// person entity. hq-pro's per-membership sync-config endpoint sits behind an
|
|
87
112
|
// up-front person-gate, so an agent caller is rejected with
|
|
@@ -19,12 +19,14 @@ import {
|
|
|
19
19
|
decodePushEvent,
|
|
20
20
|
encodePushEvent,
|
|
21
21
|
type PushEvent,
|
|
22
|
+
type PushEventInput,
|
|
22
23
|
} from "../../src/sync/index.js";
|
|
23
24
|
|
|
24
25
|
// A canonical, valid PushEvent. All other tests derive from this fixture so
|
|
25
26
|
// any single field mutation can't accidentally pass for the wrong reason.
|
|
26
27
|
const validFixture: PushEvent = {
|
|
27
28
|
relativePath: "docs/architecture/overview.md",
|
|
29
|
+
kind: "upsert",
|
|
28
30
|
contentHash:
|
|
29
31
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
30
32
|
mtime: "2026-05-18T12:34:56.789Z",
|
|
@@ -34,6 +36,15 @@ const validFixture: PushEvent = {
|
|
|
34
36
|
eventTimestamp: "2026-05-18T12:35:00.000Z",
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
const deleteFixture: PushEvent = {
|
|
40
|
+
relativePath: "docs/architecture/overview.md",
|
|
41
|
+
kind: "delete",
|
|
42
|
+
originDeviceId: "device-laptop-a",
|
|
43
|
+
originTenantId: "tenant-indigo",
|
|
44
|
+
sequenceNumber: 43,
|
|
45
|
+
eventTimestamp: "2026-05-18T12:36:00.000Z",
|
|
46
|
+
};
|
|
47
|
+
|
|
37
48
|
describe("PushEvent encode/decode", () => {
|
|
38
49
|
// ── Acceptance #1: round-trip ──────────────────────────────────────────
|
|
39
50
|
it("round-trips a known-good fixture through encode → decode", () => {
|
|
@@ -55,6 +66,21 @@ describe("PushEvent encode/decode", () => {
|
|
|
55
66
|
expect(fromString).toEqual(validFixture);
|
|
56
67
|
});
|
|
57
68
|
|
|
69
|
+
it("round-trips a delete tombstone without contentHash or mtime", () => {
|
|
70
|
+
const encoded = encodePushEvent(deleteFixture);
|
|
71
|
+
expect(JSON.parse(encoded)).toEqual(deleteFixture);
|
|
72
|
+
expect(decodePushEvent(encoded)).toEqual(deleteFixture);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("defaults an absent kind to upsert for backward compatibility", () => {
|
|
76
|
+
const legacy = { ...validFixture } as PushEventInput;
|
|
77
|
+
delete legacy.kind;
|
|
78
|
+
|
|
79
|
+
const decoded = decodePushEvent(legacy);
|
|
80
|
+
expect(decoded).toEqual(validFixture);
|
|
81
|
+
expect(JSON.parse(encodePushEvent(legacy))).toEqual(validFixture);
|
|
82
|
+
});
|
|
83
|
+
|
|
58
84
|
// ── Acceptance #2: unknown fields dropped ──────────────────────────────
|
|
59
85
|
it("drops unknown extra fields silently (does not throw)", () => {
|
|
60
86
|
const withExtras = {
|
|
@@ -80,8 +106,6 @@ describe("PushEvent encode/decode", () => {
|
|
|
80
106
|
// ── Acceptance #3: missing required fields throw typed error ───────────
|
|
81
107
|
it.each([
|
|
82
108
|
"relativePath",
|
|
83
|
-
"contentHash",
|
|
84
|
-
"mtime",
|
|
85
109
|
"originDeviceId",
|
|
86
110
|
"originTenantId",
|
|
87
111
|
"sequenceNumber",
|
|
@@ -106,6 +130,24 @@ describe("PushEvent encode/decode", () => {
|
|
|
106
130
|
expect(error.issues.some((issue) => issue.path.includes(field))).toBe(true);
|
|
107
131
|
});
|
|
108
132
|
|
|
133
|
+
it("rejects an upsert without contentHash and mtime via the schema refinement", () => {
|
|
134
|
+
let caught: unknown;
|
|
135
|
+
try {
|
|
136
|
+
decodePushEvent({
|
|
137
|
+
...validFixture,
|
|
138
|
+
contentHash: undefined,
|
|
139
|
+
mtime: undefined,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
caught = err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
146
|
+
const error = caught as PushEventDecodeError;
|
|
147
|
+
expect(error.stage).toBe("schema-validation");
|
|
148
|
+
expect(error.issues.some((issue) => issue.message === "upsert requires contentHash and mtime")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
109
151
|
it("PushEventDecodeError carries the underlying zod issues array", () => {
|
|
110
152
|
// Multiple missing fields → multiple issues, all reachable via `.issues`.
|
|
111
153
|
const sparse = { relativePath: "x.md" } as unknown;
|
|
@@ -117,7 +159,7 @@ describe("PushEvent encode/decode", () => {
|
|
|
117
159
|
}
|
|
118
160
|
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
119
161
|
const error = caught as PushEventDecodeError;
|
|
120
|
-
expect(error.issues.length).toBeGreaterThanOrEqual(
|
|
162
|
+
expect(error.issues.length).toBeGreaterThanOrEqual(4);
|
|
121
163
|
});
|
|
122
164
|
|
|
123
165
|
// ── Supporting wire-contract invariants ────────────────────────────────
|
package/src/sync/push-event.ts
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
* PushEvent — the wire-shared payload exchanged between every link in the
|
|
3
3
|
* event-driven-hq-cloud-sync pipeline.
|
|
4
4
|
*
|
|
5
|
-
* Producers (the watcher) emit one PushEvent per local content change
|
|
5
|
+
* Producers (the watcher) emit one PushEvent per local content change or
|
|
6
|
+
* delete tombstone.
|
|
6
7
|
* Consumers (the push endpoint / receiver / coalescer) decode the payload,
|
|
7
8
|
* validate it, and act on it. Because the same shape crosses a network
|
|
8
9
|
* boundary, it has its own dedicated module.
|
|
9
10
|
*
|
|
10
11
|
* Conventions
|
|
11
12
|
* ───────────
|
|
12
|
-
* - `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - `kind` is `"upsert"` for content changes and `"delete"` for delete
|
|
14
|
+
* tombstones. Missing `kind` defaults to `"upsert"` for older producers.
|
|
15
|
+
* - `contentHash` is required for upserts and absent for delete tombstones.
|
|
16
|
+
* It is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>` prefix lets a
|
|
17
|
+
* future hash migration ship without breaking the wire format — consumers
|
|
18
|
+
* can branch on the prefix and fall back to refusing unknown algorithms.
|
|
16
19
|
* - `mtime` and `eventTimestamp` are strict ISO-8601 datetime strings.
|
|
17
20
|
* `mtime` is the filesystem modification time of the source file at the
|
|
18
21
|
* moment of capture; `eventTimestamp` is when the watcher emitted the
|
|
@@ -59,15 +62,18 @@ export const PushEventSchema = z
|
|
|
59
62
|
relativePath: z
|
|
60
63
|
.string()
|
|
61
64
|
.min(1, "relativePath must be a non-empty string"),
|
|
65
|
+
kind: z.enum(["upsert", "delete"]).optional().default("upsert"),
|
|
62
66
|
contentHash: z
|
|
63
67
|
.string()
|
|
64
68
|
.regex(
|
|
65
69
|
CONTENT_HASH_PATTERN,
|
|
66
70
|
"contentHash must match `sha256:<64 lowercase hex>`",
|
|
67
|
-
)
|
|
71
|
+
)
|
|
72
|
+
.optional(),
|
|
68
73
|
mtime: z
|
|
69
74
|
.string()
|
|
70
|
-
.regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime")
|
|
75
|
+
.regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime")
|
|
76
|
+
.optional(),
|
|
71
77
|
originDeviceId: z
|
|
72
78
|
.string()
|
|
73
79
|
.min(1, "originDeviceId must be a non-empty string"),
|
|
@@ -91,13 +97,23 @@ export const PushEventSchema = z
|
|
|
91
97
|
})
|
|
92
98
|
// `.strip()` is the zod 4 default; called explicitly here so the intent is
|
|
93
99
|
// obvious to future readers. Unknown keys MUST NOT throw — see module JSDoc.
|
|
94
|
-
.strip()
|
|
100
|
+
.strip()
|
|
101
|
+
.refine(
|
|
102
|
+
(e) => e.kind === "delete" || (e.contentHash != null && e.mtime != null),
|
|
103
|
+
{ message: "upsert requires contentHash and mtime" },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The canonical decoded PushEvent type. `kind` is always present after decode;
|
|
108
|
+
* `contentHash` and `mtime` are present for upserts and absent for deletes.
|
|
109
|
+
*/
|
|
110
|
+
export type PushEvent = z.output<typeof PushEventSchema>;
|
|
95
111
|
|
|
96
112
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
113
|
+
* Input accepted by the schema. Kept public so encode callers can hand in
|
|
114
|
+
* legacy upsert payloads where `kind` is absent.
|
|
99
115
|
*/
|
|
100
|
-
export type
|
|
116
|
+
export type PushEventInput = z.input<typeof PushEventSchema>;
|
|
101
117
|
|
|
102
118
|
// ─── Errors ────────────────────────────────────────────────────────────────
|
|
103
119
|
|
|
@@ -145,7 +161,7 @@ export class PushEventDecodeError extends Error {
|
|
|
145
161
|
* Extra keys on `event` are dropped — the returned JSON string contains
|
|
146
162
|
* only the declared PushEvent fields.
|
|
147
163
|
*/
|
|
148
|
-
export function encodePushEvent(event:
|
|
164
|
+
export function encodePushEvent(event: PushEventInput): string {
|
|
149
165
|
const parsed = PushEventSchema.safeParse(event);
|
|
150
166
|
if (!parsed.success) {
|
|
151
167
|
throw new PushEventDecodeError(
|
|
@@ -45,6 +45,7 @@ const QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/sync-push-in
|
|
|
45
45
|
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
46
46
|
return {
|
|
47
47
|
relativePath: "companies/indigo/notes.md",
|
|
48
|
+
kind: "upsert",
|
|
48
49
|
contentHash:
|
|
49
50
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
50
51
|
mtime: "2026-05-21T12:34:56.000Z",
|