@indigoai-us/hq-cloud 6.14.39 → 6.14.41
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/cli/sync.js +41 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +78 -0
- package/dist/cli/sync.test.js.map +1 -1
- 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/cli/sync.test.ts +103 -0
- package/src/cli/sync.ts +42 -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/src/cli/sync.test.ts
CHANGED
|
@@ -931,6 +931,109 @@ describe("sync", () => {
|
|
|
931
931
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
932
932
|
});
|
|
933
933
|
|
|
934
|
+
it("untracked-local guard: an UNJOURNALED local file is NOT silently clobbered — routes through conflict and preserves local", async () => {
|
|
935
|
+
// Regression for the 2026-08-01/02 prd.json losses: a locally-edited file
|
|
936
|
+
// whose key has NO journal entry fell straight through the 3-way merge to
|
|
937
|
+
// `download` (localChanged and remoteChanged are both gated on
|
|
938
|
+
// `!!journalEntry`, so both read false). The in-progress local edit was
|
|
939
|
+
// overwritten with the cloud copy with no conflict, no `.conflict-` mirror,
|
|
940
|
+
// and no conflict-index record — the loss was invisible.
|
|
941
|
+
//
|
|
942
|
+
// A key with no journal entry carries NO evidence that local and remote
|
|
943
|
+
// ever agreed, so a blind overwrite is never safe. Route it through the
|
|
944
|
+
// conflict path; the executor's convergence probe still collapses a
|
|
945
|
+
// byte-identical remote to a silent reconcile (covered by the next test),
|
|
946
|
+
// so this costs nothing on the common first-pull case.
|
|
947
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
948
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
949
|
+
const localPath = path.join(companyDocs, "handoff.md");
|
|
950
|
+
// Stand-in for the PRD carrying the in-progress 11th story.
|
|
951
|
+
const localContent = "in-progress local work that must survive\n";
|
|
952
|
+
fs.writeFileSync(localPath, localContent);
|
|
953
|
+
|
|
954
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
955
|
+
{ key: "docs/handoff.md", size: 17, lastModified: new Date(), etag: '"cloud-etag"' },
|
|
956
|
+
]);
|
|
957
|
+
|
|
958
|
+
// Journal has NO entry for this key — the whole point of the regression.
|
|
959
|
+
fs.writeFileSync(
|
|
960
|
+
journalPath,
|
|
961
|
+
JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files: {} }),
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
const result = await sync({
|
|
965
|
+
company: "acme",
|
|
966
|
+
onConflict: "keep",
|
|
967
|
+
vaultConfig: mockConfig,
|
|
968
|
+
hqRoot: tmpDir,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// Surfaced as a conflict, NOT a silent download.
|
|
972
|
+
expect(result.conflicts).toBe(1);
|
|
973
|
+
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
974
|
+
expect(result.filesDownloaded).toBe(0);
|
|
975
|
+
// The local edit is intact.
|
|
976
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(localContent);
|
|
977
|
+
// Cloud copy preserved alongside it for inspection / `/resolve-conflicts`.
|
|
978
|
+
const litter = fs
|
|
979
|
+
.readdirSync(companyDocs)
|
|
980
|
+
.filter((f) => f.includes(".conflict-"));
|
|
981
|
+
expect(litter).toHaveLength(1);
|
|
982
|
+
expect(fs.readFileSync(path.join(companyDocs, litter[0]!), "utf-8")).toBe(
|
|
983
|
+
"mock file content",
|
|
984
|
+
);
|
|
985
|
+
// And a durable record exists so the overwrite can never be silent.
|
|
986
|
+
const index = JSON.parse(
|
|
987
|
+
fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"),
|
|
988
|
+
);
|
|
989
|
+
expect(
|
|
990
|
+
index.conflicts.some(
|
|
991
|
+
(c: { originalPath: string }) =>
|
|
992
|
+
c.originalPath === "companies/acme/docs/handoff.md",
|
|
993
|
+
),
|
|
994
|
+
).toBe(true);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("untracked-local guard is free on first pull: an unjournaled local file that already MATCHES the cloud reconciles silently", async () => {
|
|
998
|
+
// The guard must not manufacture conflicts for the common case — a fresh
|
|
999
|
+
// machine, or a re-created journal, where local and cloud are already
|
|
1000
|
+
// byte-identical. The executor's convergence probe collapses these to a
|
|
1001
|
+
// reconcile: no conflict, no mirror, journal stamped so the next sync is
|
|
1002
|
+
// a plain skip-unchanged.
|
|
1003
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
1004
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
1005
|
+
const localPath = path.join(companyDocs, "handoff.md");
|
|
1006
|
+
// Byte-identical to what the downloadFile mock writes.
|
|
1007
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
1008
|
+
|
|
1009
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1010
|
+
{ key: "docs/handoff.md", size: 17, lastModified: new Date(), etag: '"cloud-etag"' },
|
|
1011
|
+
]);
|
|
1012
|
+
|
|
1013
|
+
fs.writeFileSync(
|
|
1014
|
+
journalPath,
|
|
1015
|
+
JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files: {} }),
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
const result = await sync({
|
|
1019
|
+
company: "acme",
|
|
1020
|
+
onConflict: "keep",
|
|
1021
|
+
vaultConfig: mockConfig,
|
|
1022
|
+
hqRoot: tmpDir,
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
expect(result.conflicts).toBe(0);
|
|
1026
|
+
expect(result.conflictPaths).toEqual([]);
|
|
1027
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
1028
|
+
const litter = fs
|
|
1029
|
+
.readdirSync(companyDocs)
|
|
1030
|
+
.filter((f) => f.includes(".conflict-"));
|
|
1031
|
+
expect(litter).toHaveLength(0);
|
|
1032
|
+
// Journal stamped, so the next pass is a plain unchanged-skip.
|
|
1033
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1034
|
+
expect(journal.files["docs/handoff.md"]).toBeDefined();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
934
1037
|
it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
|
|
935
1038
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
936
1039
|
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-escape-"));
|
package/src/cli/sync.ts
CHANGED
|
@@ -2903,6 +2903,48 @@ function computePullPlan(
|
|
|
2903
2903
|
continue;
|
|
2904
2904
|
}
|
|
2905
2905
|
|
|
2906
|
+
// ── Untracked-local guard (unjournaled local file) ──────────────
|
|
2907
|
+
// Both `localChanged` and `remoteChanged` are gated on `!!journalEntry`,
|
|
2908
|
+
// so a key with NO journal entry reads "clean on both sides" and falls
|
|
2909
|
+
// all the way through to a plain `download` — silently replacing a local
|
|
2910
|
+
// file that exists on disk. There is no conflict, no `.conflict-` mirror
|
|
2911
|
+
// and no conflict-index record, so the loss is invisible unless someone
|
|
2912
|
+
// happens to know what the file should contain.
|
|
2913
|
+
//
|
|
2914
|
+
// This destroyed in-progress edits to
|
|
2915
|
+
// `companies/*/projects/*/prd.json` three times on 2026-08-01/02, but
|
|
2916
|
+
// nothing about it is prd- or project-specific: it fires for ANY synced
|
|
2917
|
+
// path whose journal entry is absent. Absence is common — a file created
|
|
2918
|
+
// on this machine and on a peer under the same key before either pushed,
|
|
2919
|
+
// a journal shard reset/lost/rebuilt (`readJournal` returns an empty
|
|
2920
|
+
// `files` map for a missing shard, so EVERY divergent local file is in
|
|
2921
|
+
// scope), a run under a different `HQ_STATE_DIR`, or an entry dropped by
|
|
2922
|
+
// scope-shrink or rescue.
|
|
2923
|
+
//
|
|
2924
|
+
// No journal entry means no evidence that local and remote ever agreed,
|
|
2925
|
+
// which is exactly when an overwrite is least safe. Route it through the
|
|
2926
|
+
// conflict path instead: the executor's convergence probe fetches the
|
|
2927
|
+
// remote once and, when the bytes already match (the ordinary
|
|
2928
|
+
// fresh-machine / rebuilt-journal case), reconciles silently — no
|
|
2929
|
+
// conflict, no mirror, journal stamped. Only genuine divergence surfaces,
|
|
2930
|
+
// and there local is PRESERVED as-is with the cloud copy written beside
|
|
2931
|
+
// it under `--on-conflict keep`, or the run halts under `abort`.
|
|
2932
|
+
// Cloud-authoritative paths (board.json, ontology/, signals/, …) are
|
|
2933
|
+
// unaffected — the conflict executor short-circuits those back to a
|
|
2934
|
+
// pull-wins download before any mirror is minted.
|
|
2935
|
+
if (!journalEntry) {
|
|
2936
|
+
items.push({
|
|
2937
|
+
action: "conflict",
|
|
2938
|
+
remoteFile,
|
|
2939
|
+
localPath,
|
|
2940
|
+
localHash,
|
|
2941
|
+
localMtime: localLstat!.mtime,
|
|
2942
|
+
localSize: isLocalSymlink ? 0 : localLstat!.size,
|
|
2943
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2944
|
+
});
|
|
2945
|
+
continue;
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2906
2948
|
// Mirror the original 3-way merge from the inline loop. Tested by
|
|
2907
2949
|
// `does NOT flag a pull conflict when only local changed since last
|
|
2908
2950
|
// sync` and `detects conflicts with local changes…`.
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
parseCodexSessionMeta,
|
|
12
12
|
collectAndSendSkillTelemetry,
|
|
13
13
|
computeSkillVersion,
|
|
14
|
+
MAX_DECODE_BYTES,
|
|
14
15
|
readFileRegion,
|
|
15
16
|
} from "./skill-telemetry.js";
|
|
16
17
|
import { encodeLocalVaultSegment } from "./local-path-codec.js";
|
|
@@ -758,6 +759,244 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
758
759
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
759
760
|
});
|
|
760
761
|
|
|
762
|
+
it("HQ-DESKTOP-47: settles at complete UTF-8 lines and drains a bounded backlog without gaps", async () => {
|
|
763
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-bounded-"));
|
|
764
|
+
const projects = path.join(tmp, "projects");
|
|
765
|
+
const dir = path.join(projects, "-p");
|
|
766
|
+
await fs.mkdir(dir, { recursive: true });
|
|
767
|
+
const file = path.join(dir, "s.jsonl");
|
|
768
|
+
const lines = [
|
|
769
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T10:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
|
|
770
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T10:01:00Z", cwd: "/x", uuid: "u2", message: { role: "user", content: "<command-name>/plán</command-name>" } }),
|
|
771
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T10:02:00Z", cwd: "/x", uuid: "u3", message: { role: "user", content: "<command-name>/ship</command-name>" } }),
|
|
772
|
+
];
|
|
773
|
+
await fs.writeFile(file, `${lines.join("\n")}\n`, "utf-8");
|
|
774
|
+
|
|
775
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
776
|
+
const captured: SkillInvocationBatch[] = [];
|
|
777
|
+
const client = stubClient(captured);
|
|
778
|
+
const base = {
|
|
779
|
+
client,
|
|
780
|
+
machineId: "m",
|
|
781
|
+
installerVersion: "t",
|
|
782
|
+
claudeProjectsRoot: projects,
|
|
783
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
784
|
+
cursorPath,
|
|
785
|
+
};
|
|
786
|
+
const cursorOffset = async (): Promise<number> => {
|
|
787
|
+
const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
|
|
788
|
+
files: Record<string, { offset: number }>;
|
|
789
|
+
};
|
|
790
|
+
return cursor.files[file]?.offset ?? 0;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Stop in the middle of the two-byte á. The first pass must commit only
|
|
794
|
+
// the preceding complete line rather than a character-counted position.
|
|
795
|
+
const accentOffset = lines[1].indexOf("á");
|
|
796
|
+
const firstBudget =
|
|
797
|
+
Buffer.byteLength(`${lines[0]}\n${lines[1].slice(0, accentOffset)}`, "utf-8") + 1;
|
|
798
|
+
const logs: string[] = [];
|
|
799
|
+
const first = await collectAndSendSkillTelemetry({
|
|
800
|
+
...base,
|
|
801
|
+
maxScanBytesPerSource: firstBudget,
|
|
802
|
+
log: (message) => logs.push(message),
|
|
803
|
+
});
|
|
804
|
+
const afterFirst = await cursorOffset();
|
|
805
|
+
expect(first.eventsSent).toBe(1);
|
|
806
|
+
expect(afterFirst).toBe(Buffer.byteLength(`${lines[0]}\n`, "utf-8"));
|
|
807
|
+
expect(logs.some((message) => message.includes("scan budget reached"))).toBe(true);
|
|
808
|
+
|
|
809
|
+
// The second pass reaches the next complete line but not the third; the
|
|
810
|
+
// third drains the remainder. Together they must equal an unbounded scan.
|
|
811
|
+
const second = await collectAndSendSkillTelemetry({
|
|
812
|
+
...base,
|
|
813
|
+
maxScanBytesPerSource: Buffer.byteLength(`${lines[1]}\n`, "utf-8") + 1,
|
|
814
|
+
});
|
|
815
|
+
const afterSecond = await cursorOffset();
|
|
816
|
+
const third = await collectAndSendSkillTelemetry({
|
|
817
|
+
...base,
|
|
818
|
+
maxScanBytesPerSource: 1024 * 1024,
|
|
819
|
+
});
|
|
820
|
+
const afterThird = await cursorOffset();
|
|
821
|
+
|
|
822
|
+
expect(second.eventsSent).toBe(1);
|
|
823
|
+
expect(third.eventsSent).toBe(1);
|
|
824
|
+
expect(afterFirst).toBeLessThan(afterSecond);
|
|
825
|
+
expect(afterSecond).toBeLessThan(afterThird);
|
|
826
|
+
expect(afterThird).toBe(Buffer.byteLength(`${lines.join("\n")}\n`, "utf-8"));
|
|
827
|
+
expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
|
|
828
|
+
"deploy",
|
|
829
|
+
"plán",
|
|
830
|
+
"ship",
|
|
831
|
+
]);
|
|
832
|
+
|
|
833
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("HQ-DESKTOP-47: a transcript below the injected budget keeps the one-pass EOF behaviour", async () => {
|
|
837
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-small-budget-"));
|
|
838
|
+
const projects = path.join(tmp, "projects");
|
|
839
|
+
const dir = path.join(projects, "-p");
|
|
840
|
+
await fs.mkdir(dir, { recursive: true });
|
|
841
|
+
const file = path.join(dir, "s.jsonl");
|
|
842
|
+
const transcript = row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T11:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n";
|
|
843
|
+
await fs.writeFile(file, transcript, "utf-8");
|
|
844
|
+
|
|
845
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
846
|
+
const captured: SkillInvocationBatch[] = [];
|
|
847
|
+
const result = await collectAndSendSkillTelemetry({
|
|
848
|
+
client: stubClient(captured),
|
|
849
|
+
machineId: "m",
|
|
850
|
+
installerVersion: "t",
|
|
851
|
+
claudeProjectsRoot: projects,
|
|
852
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
853
|
+
cursorPath,
|
|
854
|
+
maxScanBytesPerSource: Buffer.byteLength(transcript, "utf-8") + 1,
|
|
855
|
+
});
|
|
856
|
+
const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
|
|
857
|
+
files: Record<string, { offset: number }>;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
expect(result.eventsSent).toBe(1);
|
|
861
|
+
expect(cursor.files[file]?.offset).toBe(Buffer.byteLength(transcript, "utf-8"));
|
|
862
|
+
expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
|
|
863
|
+
"deploy",
|
|
864
|
+
]);
|
|
865
|
+
|
|
866
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("HQ-DESKTOP-47: a truncated bounded backlog resets to the replacement file", async () => {
|
|
870
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-budget-rotate-"));
|
|
871
|
+
const projects = path.join(tmp, "projects");
|
|
872
|
+
const dir = path.join(projects, "-p");
|
|
873
|
+
await fs.mkdir(dir, { recursive: true });
|
|
874
|
+
const file = path.join(dir, "s.jsonl");
|
|
875
|
+
const oldLine = row({ type: "user", sessionId: "old", timestamp: "2026-08-02T12:00:00Z", cwd: "/x", uuid: "old-1", message: { role: "user", content: "<command-name>/deploy</command-name>" } });
|
|
876
|
+
const oldTail = row({ type: "user", sessionId: "old", timestamp: "2026-08-02T12:01:00Z", cwd: "/x", uuid: "old-2", message: { role: "user", content: "<command-name>/ship</command-name>" } });
|
|
877
|
+
await fs.writeFile(file, `${oldLine}\n${oldTail}\n`, "utf-8");
|
|
878
|
+
|
|
879
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
880
|
+
const captured: SkillInvocationBatch[] = [];
|
|
881
|
+
const base = {
|
|
882
|
+
client: stubClient(captured),
|
|
883
|
+
machineId: "m",
|
|
884
|
+
installerVersion: "t",
|
|
885
|
+
claudeProjectsRoot: projects,
|
|
886
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
887
|
+
cursorPath,
|
|
888
|
+
};
|
|
889
|
+
await collectAndSendSkillTelemetry({
|
|
890
|
+
...base,
|
|
891
|
+
maxScanBytesPerSource: Buffer.byteLength(`${oldLine}\n`, "utf-8") + 1,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
const replacement = row({ type: "user", sessionId: "new", timestamp: "2026-08-02T12:02:00Z", cwd: "/x", uuid: "new-1", message: { role: "user", content: "<command-name>/land</command-name>" } }) + "\n";
|
|
895
|
+
expect(Buffer.byteLength(replacement, "utf-8")).toBeLessThan(
|
|
896
|
+
Buffer.byteLength(`${oldLine}\n`, "utf-8"),
|
|
897
|
+
);
|
|
898
|
+
await fs.writeFile(file, replacement, "utf-8");
|
|
899
|
+
|
|
900
|
+
const result = await collectAndSendSkillTelemetry({
|
|
901
|
+
...base,
|
|
902
|
+
maxScanBytesPerSource: 1024 * 1024,
|
|
903
|
+
});
|
|
904
|
+
const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
|
|
905
|
+
files: Record<string, { offset: number }>;
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
expect(result.eventsSent).toBe(1);
|
|
909
|
+
expect(cursor.files[file]?.offset).toBe(Buffer.byteLength(replacement, "utf-8"));
|
|
910
|
+
expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
|
|
911
|
+
"deploy",
|
|
912
|
+
"land",
|
|
913
|
+
]);
|
|
914
|
+
|
|
915
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("HQ-DESKTOP-47: continues past a single JSONL record larger than the scan budget", async () => {
|
|
919
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-large-line-"));
|
|
920
|
+
const projects = path.join(tmp, "projects");
|
|
921
|
+
const dir = path.join(projects, "-p");
|
|
922
|
+
await fs.mkdir(dir, { recursive: true });
|
|
923
|
+
const file = path.join(dir, "s.jsonl");
|
|
924
|
+
const oversized = row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T13:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: `<command-name>/deploy</command-name><command-args>${"x".repeat(900)}</command-args>` } });
|
|
925
|
+
const tail = row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T13:01:00Z", cwd: "/x", uuid: "u2", message: { role: "user", content: "<command-name>/land</command-name>" } });
|
|
926
|
+
await fs.writeFile(file, `${oversized}\n${tail}\n`, "utf-8");
|
|
927
|
+
|
|
928
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
929
|
+
const captured: SkillInvocationBatch[] = [];
|
|
930
|
+
const base = {
|
|
931
|
+
client: stubClient(captured),
|
|
932
|
+
machineId: "m",
|
|
933
|
+
installerVersion: "t",
|
|
934
|
+
claudeProjectsRoot: projects,
|
|
935
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
936
|
+
cursorPath,
|
|
937
|
+
maxScanBytesPerSource: 128,
|
|
938
|
+
};
|
|
939
|
+
const offsets: number[] = [];
|
|
940
|
+
for (let pass = 0; pass < 12; pass++) {
|
|
941
|
+
await collectAndSendSkillTelemetry(base);
|
|
942
|
+
const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
|
|
943
|
+
files: Record<string, { offset: number; pendingLine?: { scannedOffset: number } }>;
|
|
944
|
+
};
|
|
945
|
+
offsets.push(cursor.files[file]?.offset ?? 0);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
expect(offsets.some((offset) => offset > 0)).toBe(true);
|
|
949
|
+
expect(offsets[0]).toBe(0); // first pass only discovers a partial record
|
|
950
|
+
expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
|
|
951
|
+
"deploy",
|
|
952
|
+
"land",
|
|
953
|
+
]);
|
|
954
|
+
|
|
955
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("HQ-DESKTOP-47: rotates a shared runtime budget so a backlog cannot starve a later session", async () => {
|
|
959
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-fair-"));
|
|
960
|
+
const projects = path.join(tmp, "projects");
|
|
961
|
+
const backlogDir = path.join(projects, "a-backlog");
|
|
962
|
+
const currentDir = path.join(projects, "b-current");
|
|
963
|
+
await fs.mkdir(backlogDir, { recursive: true });
|
|
964
|
+
await fs.mkdir(currentDir, { recursive: true });
|
|
965
|
+
const backlogLine = row({ type: "user", sessionId: "old", timestamp: "2026-08-02T14:00:00Z", cwd: "/x", uuid: "old-1", message: { role: "user", content: "<command-name>/deploy</command-name>" } });
|
|
966
|
+
await fs.writeFile(
|
|
967
|
+
path.join(backlogDir, "old.jsonl"),
|
|
968
|
+
`${Array.from({ length: 4 }, () => backlogLine).join("\n")}\n`,
|
|
969
|
+
"utf-8",
|
|
970
|
+
);
|
|
971
|
+
await fs.writeFile(
|
|
972
|
+
path.join(currentDir, "current.jsonl"),
|
|
973
|
+
row({ type: "user", sessionId: "new", timestamp: "2026-08-02T14:01:00Z", cwd: "/x", uuid: "new-1", message: { role: "user", content: "<command-name>/land</command-name>" } }) + "\n",
|
|
974
|
+
"utf-8",
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
const captured: SkillInvocationBatch[] = [];
|
|
978
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
979
|
+
const budget = Buffer.byteLength(`${backlogLine}\n`, "utf-8") + 1;
|
|
980
|
+
const base = {
|
|
981
|
+
client: stubClient(captured),
|
|
982
|
+
machineId: "m",
|
|
983
|
+
installerVersion: "t",
|
|
984
|
+
claudeProjectsRoot: projects,
|
|
985
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
986
|
+
cursorPath,
|
|
987
|
+
maxScanBytesPerSource: budget,
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
await collectAndSendSkillTelemetry(base);
|
|
991
|
+
await collectAndSendSkillTelemetry(base);
|
|
992
|
+
|
|
993
|
+
expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toContain(
|
|
994
|
+
"land",
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
998
|
+
});
|
|
999
|
+
|
|
761
1000
|
it("captures every project when hqRoot is omitted", async () => {
|
|
762
1001
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
|
|
763
1002
|
const projects = path.join(tmp, "projects");
|
|
@@ -1031,6 +1270,49 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
1031
1270
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
1032
1271
|
});
|
|
1033
1272
|
|
|
1273
|
+
it("HQ-DESKTOP-47: preserves Codex turn dedup across a bounded resume", async () => {
|
|
1274
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-codex-budget-"));
|
|
1275
|
+
const codex = path.join(tmp, "sessions", "2026", "08", "02");
|
|
1276
|
+
await fs.mkdir(codex, { recursive: true });
|
|
1277
|
+
const hqRoot = "/home/ec2-user/hq";
|
|
1278
|
+
const lines = [
|
|
1279
|
+
row({ timestamp: "2026-08-02T15:00:00.000Z", type: "session_meta", payload: { id: "sess-budget", cwd: hqRoot } }),
|
|
1280
|
+
row({ timestamp: "2026-08-02T15:00:01.000Z", type: "turn_context", payload: { turn_id: "turn-budget", cwd: hqRoot } }),
|
|
1281
|
+
row({ timestamp: "2026-08-02T15:00:02.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", arguments: JSON.stringify({ cmd: "sed -n '1,80p' .claude/skills/deploy/SKILL.md", workdir: hqRoot }) } }),
|
|
1282
|
+
row({ timestamp: "2026-08-02T15:00:03.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", arguments: JSON.stringify({ cmd: "wc -l .claude/skills/deploy/SKILL.md", workdir: hqRoot }) } }),
|
|
1283
|
+
];
|
|
1284
|
+
const file = path.join(codex, "rollout-budget.jsonl");
|
|
1285
|
+
await fs.writeFile(file, `${lines.join("\n")}\n`, "utf-8");
|
|
1286
|
+
|
|
1287
|
+
const captured: SkillInvocationBatch[] = [];
|
|
1288
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
1289
|
+
const base = {
|
|
1290
|
+
client: stubClient(captured),
|
|
1291
|
+
machineId: "m",
|
|
1292
|
+
installerVersion: "t",
|
|
1293
|
+
hqRoot,
|
|
1294
|
+
claudeProjectsRoot: path.join(tmp, "no-claude"),
|
|
1295
|
+
codexSessionsRoot: path.join(tmp, "sessions"),
|
|
1296
|
+
cursorPath,
|
|
1297
|
+
};
|
|
1298
|
+
// Commit the turn_context but stop in the next function_call row.
|
|
1299
|
+
await collectAndSendSkillTelemetry({
|
|
1300
|
+
...base,
|
|
1301
|
+
maxScanBytesPerSource: Buffer.byteLength(`${lines[0]}\n${lines[1]}\n`, "utf-8") + 1,
|
|
1302
|
+
});
|
|
1303
|
+
await collectAndSendSkillTelemetry({ ...base, maxScanBytesPerSource: 1024 * 1024 });
|
|
1304
|
+
await collectAndSendSkillTelemetry({ ...base, maxScanBytesPerSource: 1024 * 1024 });
|
|
1305
|
+
|
|
1306
|
+
const events = captured.flatMap((batch) => batch.events);
|
|
1307
|
+
expect(events).toHaveLength(1);
|
|
1308
|
+
expect(events[0]).toMatchObject({
|
|
1309
|
+
skill: "deploy",
|
|
1310
|
+
uuid: "codex:skill:sess-budget:turn-budget:deploy",
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1034
1316
|
it("does not count SKILL.md edits (apply_patch / write) as skill usage", async () => {
|
|
1035
1317
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-edit-"));
|
|
1036
1318
|
const codex = path.join(tmp, "sessions", "2026", "06", "08");
|
|
@@ -1248,6 +1530,10 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
|
|
|
1248
1530
|
describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", () => {
|
|
1249
1531
|
const INT32_MAX = 2 ** 31 - 1;
|
|
1250
1532
|
|
|
1533
|
+
it("keeps the skill scanner's decode ceiling below V8's fatal 2 GiB boundary", () => {
|
|
1534
|
+
expect(MAX_DECODE_BYTES).toBeLessThan(2 ** 31);
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1251
1537
|
it("never issues a single read whose length exceeds Int32, even for a multi-GiB region", async () => {
|
|
1252
1538
|
// The original code did `fh.read(buf, 0, currentSize - offset, offset)` in
|
|
1253
1539
|
// one shot. Once a session log's unread tail passed ~2 GiB, the length
|