@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.
- package/.github/workflows/ci.yml +18 -26
- package/dist/bin/sync-runner-telemetry.d.ts +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -1
- package/dist/bin/sync-runner-telemetry.js +8 -1
- package/dist/bin/sync-runner-telemetry.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +9 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +115 -9
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +15 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +26 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +16 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/qmd-reindex.d.ts +7 -0
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +12 -1
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/sync-progress.d.ts +37 -0
- package/dist/sync-progress.d.ts.map +1 -0
- package/dist/sync-progress.js +104 -0
- package/dist/sync-progress.js.map +1 -0
- package/dist/sync-progress.test.d.ts +2 -0
- package/dist/sync-progress.test.d.ts.map +1 -0
- package/dist/sync-progress.test.js +70 -0
- package/dist/sync-progress.test.js.map +1 -0
- package/package.json +3 -1
- package/src/bin/sync-runner-telemetry.ts +8 -1
- package/src/bin/sync-runner.ts +131 -7
- package/src/cli/share.ts +17 -1
- package/src/cli/sync.test.ts +17 -0
- package/src/cli/sync.ts +35 -2
- package/src/index.ts +9 -0
- package/src/qmd-reindex.ts +18 -1
- package/src/sync-progress.test.ts +81 -0
- 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,
|
package/src/qmd-reindex.ts
CHANGED
|
@@ -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
|
+
}
|