@indigoai-us/hq-cloud 6.11.13 → 6.11.15

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.
Files changed (43) hide show
  1. package/.github/workflows/ci.yml +18 -26
  2. package/dist/bin/sync-runner-telemetry.d.ts +1 -0
  3. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-telemetry.js +8 -1
  5. package/dist/bin/sync-runner-telemetry.js.map +1 -1
  6. package/dist/bin/sync-runner.d.ts +9 -1
  7. package/dist/bin/sync-runner.d.ts.map +1 -1
  8. package/dist/bin/sync-runner.js +115 -9
  9. package/dist/bin/sync-runner.js.map +1 -1
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +15 -1
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +26 -2
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +16 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/qmd-reindex.d.ts +7 -0
  23. package/dist/qmd-reindex.d.ts.map +1 -1
  24. package/dist/qmd-reindex.js +12 -1
  25. package/dist/qmd-reindex.js.map +1 -1
  26. package/dist/sync-progress.d.ts +37 -0
  27. package/dist/sync-progress.d.ts.map +1 -0
  28. package/dist/sync-progress.js +104 -0
  29. package/dist/sync-progress.js.map +1 -0
  30. package/dist/sync-progress.test.d.ts +2 -0
  31. package/dist/sync-progress.test.d.ts.map +1 -0
  32. package/dist/sync-progress.test.js +70 -0
  33. package/dist/sync-progress.test.js.map +1 -0
  34. package/package.json +3 -1
  35. package/src/bin/sync-runner-telemetry.ts +8 -1
  36. package/src/bin/sync-runner.ts +131 -7
  37. package/src/cli/share.ts +17 -1
  38. package/src/cli/sync.test.ts +17 -0
  39. package/src/cli/sync.ts +35 -2
  40. package/src/index.ts +9 -0
  41. package/src/qmd-reindex.ts +18 -1
  42. package/src/sync-progress.test.ts +81 -0
  43. package/src/sync-progress.ts +133 -0
package/src/index.ts CHANGED
@@ -57,6 +57,15 @@ export {
57
57
 
58
58
  export type { JournalSummary } from "./journal.js";
59
59
 
60
+ // Cross-process sync progress — every sync path writes one shared snapshot so
61
+ // a watcher (the hq-sync menubar) can show live progress for ANY sync.
62
+ export {
63
+ createSyncProgressRecorder,
64
+ readSyncProgress,
65
+ syncProgressPath,
66
+ } from "./sync-progress.js";
67
+ export type { SyncProgressSnapshot } from "./sync-progress.js";
68
+
60
69
  // Prefix coalescing helper (US-005)
61
70
  export {
62
71
  coalescePrefixes,
@@ -56,6 +56,13 @@ export interface ReindexOptions {
56
56
  readCompanies?: (companiesDir: string) => string[];
57
57
  /** Returns true if the knowledge dir has at least one indexable .md file. */
58
58
  hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
59
+ /** Optional diagnostic sink for unexpected swallowed failures. */
60
+ log?: (diagnostic: {
61
+ event: string;
62
+ message: string;
63
+ err: unknown;
64
+ context?: Record<string, unknown>;
65
+ }) => void;
59
66
  }
60
67
 
61
68
  /**
@@ -110,7 +117,17 @@ export function reindexAfterSync(
110
117
  const embed = exec(["embed"]);
111
118
  result.embedded = embed.status === 0;
112
119
  }
113
- } catch {
120
+ } catch (err) {
121
+ try {
122
+ opts.log?.({
123
+ event: "runner.qmd_reindex.internal_failed",
124
+ message: "post-sync qmd reindex failed",
125
+ err,
126
+ context: { hqRoot },
127
+ });
128
+ } catch {
129
+ /* diagnostics must never affect sync */
130
+ }
114
131
  // Reindex is best-effort; the sync result is authoritative. Swallow.
115
132
  }
116
133
 
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import type { SyncProgressEvent } from "./cli/sync.js";
6
+ import {
7
+ createSyncProgressRecorder,
8
+ readSyncProgress,
9
+ } from "./sync-progress.js";
10
+
11
+ const planEvent = (download: number, upload: number, del: number): SyncProgressEvent =>
12
+ ({
13
+ type: "plan",
14
+ filesToDownload: download,
15
+ bytesToDownload: 0,
16
+ filesToUpload: upload,
17
+ bytesToUpload: 0,
18
+ filesToSkip: 0,
19
+ filesToConflict: 0,
20
+ filesToDelete: del,
21
+ }) as SyncProgressEvent;
22
+
23
+ describe("sync-progress", () => {
24
+ let stateDir: string;
25
+ beforeEach(() => {
26
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-progress-test-"));
27
+ process.env.HQ_STATE_DIR = stateDir;
28
+ });
29
+ afterEach(() => {
30
+ delete process.env.HQ_STATE_DIR;
31
+ fs.rmSync(stateDir, { recursive: true, force: true });
32
+ });
33
+
34
+ it("returns null when no sync has written a snapshot", () => {
35
+ expect(readSyncProgress()).toBeNull();
36
+ });
37
+
38
+ it("accumulates plan totals, progress, and conflicts into the shared snapshot", () => {
39
+ const record = createSyncProgressRecorder({ company: "acme", phase: "pull" });
40
+ record(planEvent(3, 0, 1)); // 3 to download + 1 to delete = 4 total
41
+ record({ type: "progress", path: "a.md", bytes: 10 } as SyncProgressEvent);
42
+ record({ type: "progress", path: "b.md", bytes: 10 } as SyncProgressEvent);
43
+ record({
44
+ type: "conflict",
45
+ path: "c.md",
46
+ direction: "pull",
47
+ resolution: "keep",
48
+ } as SyncProgressEvent);
49
+
50
+ const snap = readSyncProgress();
51
+ expect(snap).not.toBeNull();
52
+ expect(snap!.company).toBe("acme");
53
+ expect(snap!.phase).toBe("pull");
54
+ expect(snap!.filesTotal).toBe(4);
55
+ expect(snap!.filesDone).toBe(2);
56
+ expect(snap!.conflicts).toBe(1);
57
+ expect(snap!.currentFile).toBe("b.md");
58
+ expect(snap!.status).toBe("syncing");
59
+ expect(snap!.pid).toBe(process.pid);
60
+ expect(typeof snap!.updatedAt).toBe("string");
61
+ });
62
+
63
+ it("records the push leg + personal scope (company null)", () => {
64
+ const record = createSyncProgressRecorder({ company: null, phase: "push" });
65
+ record(planEvent(0, 5, 0));
66
+ const snap = readSyncProgress();
67
+ expect(snap!.phase).toBe("push");
68
+ expect(snap!.company).toBeNull();
69
+ expect(snap!.filesTotal).toBe(5);
70
+ });
71
+
72
+ it("ignores non-progress events (no churn from new-files/reconciled)", () => {
73
+ const record = createSyncProgressRecorder({ company: "acme", phase: "pull" });
74
+ record(planEvent(1, 0, 0));
75
+ record({ type: "new-files", files: [] } as SyncProgressEvent);
76
+ record({ type: "reconciled", path: "x.md", direction: "pull" } as SyncProgressEvent);
77
+ const snap = readSyncProgress();
78
+ expect(snap!.filesDone).toBe(0);
79
+ expect(snap!.filesTotal).toBe(1);
80
+ });
81
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Cross-process sync progress.
3
+ *
4
+ * Every sync path — the menubar watch daemon (auto-sync), "Sync Now", and a
5
+ * CLI `hq sync` — runs through the same engine (`sync()` pull + `share()`
6
+ * push). Each writes one shared status snapshot here so a watcher (the menubar)
7
+ * can show LIVE progress for ANY sync, not just the one it spawned over stdout.
8
+ *
9
+ * Because syncs are serialized (the op-lock; the watch daemon coordinates with
10
+ * one-shots), there is at most one active sync, so this single file is
11
+ * unambiguous. Writes are atomic (temp + rename) — a torn read at worst shows
12
+ * slightly stale progress. There is intentionally no explicit "done": the
13
+ * engine simply stops writing when a sync ends, so a consumer treats the file
14
+ * as idle once `updatedAt` is older than a few seconds. Progress reporting must
15
+ * never break a sync, so every fs error here is swallowed.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as os from "os";
19
+ import * as path from "path";
20
+ import type { SyncProgressEvent } from "./cli/sync.js";
21
+
22
+ /** Mirror operation-lock's stateDir so the file lands next to the journal/lock. */
23
+ function stateDir(): string {
24
+ return process.env.HQ_STATE_DIR || path.join(os.homedir(), ".hq");
25
+ }
26
+
27
+ /** Absolute path of the shared progress snapshot. Exported for the watcher + tests. */
28
+ export function syncProgressPath(): string {
29
+ return path.join(stateDir(), "sync-progress.json");
30
+ }
31
+
32
+ export interface SyncProgressSnapshot {
33
+ schema: 1;
34
+ /** PID of the writing sync — lets a watcher detect a dead/stale holder. */
35
+ pid: number;
36
+ /** Company slug being synced, or null for the personal vault. */
37
+ company: string | null;
38
+ /** Which leg is running. */
39
+ phase: "pull" | "push";
40
+ filesTotal: number;
41
+ filesDone: number;
42
+ conflicts: number;
43
+ /** Path of the file currently transferring, or null before the first one. */
44
+ currentFile: string | null;
45
+ startedAt: string;
46
+ updatedAt: string;
47
+ status: "syncing";
48
+ }
49
+
50
+ export interface SyncProgressRecorderOptions {
51
+ company?: string | null;
52
+ phase: "pull" | "push";
53
+ }
54
+
55
+ /**
56
+ * Build an event sink that accumulates a sync's progress and atomic-writes the
57
+ * shared snapshot on each meaningful event. Wrap the engine's `onEvent` with
58
+ * it: `const emit = (e) => { baseEmit(e); record(e); }`.
59
+ */
60
+ export function createSyncProgressRecorder(
61
+ opts: SyncProgressRecorderOptions,
62
+ ): (event: SyncProgressEvent) => void {
63
+ let filesTotal = 0;
64
+ let filesDone = 0;
65
+ let conflicts = 0;
66
+ let currentFile: string | null = null;
67
+ const startedAt = new Date().toISOString();
68
+ const file = syncProgressPath();
69
+
70
+ const flush = (): void => {
71
+ const snap: SyncProgressSnapshot = {
72
+ schema: 1,
73
+ pid: process.pid,
74
+ company: opts.company ?? null,
75
+ phase: opts.phase,
76
+ filesTotal,
77
+ filesDone,
78
+ conflicts,
79
+ currentFile,
80
+ startedAt,
81
+ updatedAt: new Date().toISOString(),
82
+ status: "syncing",
83
+ };
84
+ try {
85
+ fs.mkdirSync(path.dirname(file), { recursive: true });
86
+ const tmp = `${file}.${process.pid}.tmp`;
87
+ fs.writeFileSync(tmp, JSON.stringify(snap));
88
+ fs.renameSync(tmp, file);
89
+ } catch {
90
+ // Progress reporting is best-effort and must never break a sync.
91
+ }
92
+ };
93
+
94
+ return (event: SyncProgressEvent): void => {
95
+ switch (event.type) {
96
+ case "plan":
97
+ // Sum the work this leg will do. `plan` arrives once before any
98
+ // progress events for the leg.
99
+ filesTotal +=
100
+ event.filesToDownload + event.filesToUpload + event.filesToDelete;
101
+ flush();
102
+ break;
103
+ case "progress":
104
+ filesDone += 1;
105
+ currentFile = event.path;
106
+ flush();
107
+ break;
108
+ case "conflict":
109
+ conflicts += 1;
110
+ flush();
111
+ break;
112
+ default:
113
+ // error / new-files / reconciled / delete-refused etc. don't move the
114
+ // bar — skip the write to keep the file churn-free.
115
+ break;
116
+ }
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Read the current snapshot, or null if absent/unreadable. Consumers should
122
+ * additionally treat a snapshot whose `updatedAt` is older than a few seconds
123
+ * (or whose `pid` is dead) as idle.
124
+ */
125
+ export function readSyncProgress(): SyncProgressSnapshot | null {
126
+ try {
127
+ const raw = fs.readFileSync(syncProgressPath(), "utf8");
128
+ const parsed = JSON.parse(raw) as SyncProgressSnapshot;
129
+ return parsed && parsed.schema === 1 ? parsed : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }