@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- 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 +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/qmd-reindex.ts
DELETED
|
@@ -1,842 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Post-sync qmd reindex.
|
|
3
|
-
*
|
|
4
|
-
* Why this lives in the runner (not just an HQ-core script): the qmd search
|
|
5
|
-
* index is a per-machine local store, and nothing re-indexed it after a sync
|
|
6
|
-
* pulled new files in — so teammates saw divergent search results depending
|
|
7
|
-
* on who ran `qmd update` most recently, and newly-synced knowledge folders
|
|
8
|
-
* weren't searchable until someone manually registered them as a collection.
|
|
9
|
-
*
|
|
10
|
-
* The runner ships via `npx @indigoai-us/hq-cloud@latest` (both the AppBar
|
|
11
|
-
* menubar and the `/hq-sync` CLI pull it at runtime), so putting the fix here
|
|
12
|
-
* reaches every teammate on their next sync WITHOUT requiring them to update
|
|
13
|
-
* their HQ core. It is therefore intentionally self-contained: it shells out
|
|
14
|
-
* to the globally-installed `qmd` binary directly and does NOT depend on any
|
|
15
|
-
* script inside the synced HQ tree (which may be stale).
|
|
16
|
-
*
|
|
17
|
-
* What it does, best-effort and idempotent:
|
|
18
|
-
* 1. Auto-registers populated company knowledge, company project, and
|
|
19
|
-
* personal knowledge dirs that aren't yet qmd collections (kills the
|
|
20
|
-
* manual "map" step). Company knowledge detects path drift when the name
|
|
21
|
-
* exists but points elsewhere; repairs only when
|
|
22
|
-
* `HQ_QMD_REPAIR_PATH_DRIFT=1` (non-breaking default: detect-only).
|
|
23
|
-
* 2. Runs an incremental lexical `qmd update` (fast — qmd skips unchanged
|
|
24
|
-
* files by mtime).
|
|
25
|
-
* 3. Rebuilds embeddings only when `embed: true` (slow on a multi-GB
|
|
26
|
-
* index; meant for an idle pass, not every sync). Never embeds in the
|
|
27
|
-
* same cycle as a path-drift repair (repair drops that collection's
|
|
28
|
-
* vectors).
|
|
29
|
-
*
|
|
30
|
-
* The index itself is never synced — it is large, binary, and embeds absolute
|
|
31
|
-
* local paths. Only its *freshness* is automated here.
|
|
32
|
-
*
|
|
33
|
-
* ## Corruption safety (feedback_b9a369ff + feedback_332c7ccc)
|
|
34
|
-
*
|
|
35
|
-
* The qmd store (sqlite-vec) was being corrupted on the vector side because
|
|
36
|
-
* TWO uncoordinated writers raced on it. Mitigations:
|
|
37
|
-
* - Raised exec timeout so legitimate long passes are not killed mid-write.
|
|
38
|
-
* - Dual advisory lock: legacy `<hqRoot>/.qmd/.reindex.lock` AND
|
|
39
|
-
* `<resolvedIndexDir>/.reindex.lock` (deduped when equal). Default index
|
|
40
|
-
* is often `~/.cache/qmd/index.sqlite`, not `<hqRoot>/.qmd`.
|
|
41
|
-
* - Corruption quarantine targets the resolved index dir; reports success
|
|
42
|
-
* only when files were actually moved.
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
import * as fs from "fs";
|
|
46
|
-
import * as path from "path";
|
|
47
|
-
import { spawnSync } from "child_process";
|
|
48
|
-
|
|
49
|
-
/** Result of a single `qmd` invocation. */
|
|
50
|
-
export interface QmdExecResult {
|
|
51
|
-
status: number | null;
|
|
52
|
-
stdout: string;
|
|
53
|
-
/** Captured stderr (qmd prints SQLITE_CORRUPT errors here). Optional for fakes. */
|
|
54
|
-
stderr?: string;
|
|
55
|
-
/** True when the process was killed because it exceeded the exec timeout. */
|
|
56
|
-
timedOut?: boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Injectable command runner — real `spawnSync` in prod, a fake in tests. */
|
|
60
|
-
export interface QmdExec {
|
|
61
|
-
(args: string[]): QmdExecResult;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Default exec timeout for a single `qmd` invocation. The historical 120s bound
|
|
66
|
-
* routinely killed `qmd update`/`qmd embed` mid-write on a large index (the HQ
|
|
67
|
-
* root's `hq` collection alone spans the whole tree), which corrupted the vector
|
|
68
|
-
* store. 15 minutes comfortably covers a cold or embed-heavy pass, so the bound
|
|
69
|
-
* effectively stops firing on legitimate work; a genuinely wedged process is
|
|
70
|
-
* still bounded, and a timed-out pass is treated as not-done (never written
|
|
71
|
-
* further onto) — see {@link defaultExec} and {@link reindexAfterSync}.
|
|
72
|
-
*/
|
|
73
|
-
export const DEFAULT_QMD_EXEC_TIMEOUT_MS = 900_000;
|
|
74
|
-
|
|
75
|
-
/** Resolve the exec timeout, honoring `HQ_QMD_EXEC_TIMEOUT_MS` (ms) if valid. */
|
|
76
|
-
export function resolveExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
77
|
-
const raw = env.HQ_QMD_EXEC_TIMEOUT_MS;
|
|
78
|
-
if (raw !== undefined && raw !== "") {
|
|
79
|
-
const n = Number(raw);
|
|
80
|
-
if (Number.isFinite(n) && n > 0) return Math.round(n);
|
|
81
|
-
}
|
|
82
|
-
return DEFAULT_QMD_EXEC_TIMEOUT_MS;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Opt-in destructive path-drift repair (remove + re-add). Default off. */
|
|
86
|
-
export function repairPathDriftEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
87
|
-
const raw = (env.HQ_QMD_REPAIR_PATH_DRIFT ?? "").trim().toLowerCase();
|
|
88
|
-
return raw === "1" || raw === "true" || raw === "yes";
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Parse the directory that holds `index.sqlite` from `qmd status` output.
|
|
93
|
-
* Returns null when the Index line is missing/unparseable.
|
|
94
|
-
*/
|
|
95
|
-
export function parseIndexDirFromStatus(stdout: string): string | null {
|
|
96
|
-
const m = stdout.match(/^\s*Index:\s*(.+?)\s*$/m);
|
|
97
|
-
if (!m) return null;
|
|
98
|
-
const indexFile = m[1].trim();
|
|
99
|
-
if (!indexFile) return null;
|
|
100
|
-
return path.dirname(indexFile);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Parse `Path:` from `qmd collection show` human output. */
|
|
104
|
-
export function parseCollectionPathFromShow(stdout: string): string | null {
|
|
105
|
-
const m = stdout.match(/^\s*Path:\s*(.+?)\s*$/m);
|
|
106
|
-
if (!m) return null;
|
|
107
|
-
const p = m[1].trim();
|
|
108
|
-
return p || null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Case-insensitive, trailing-separator-tolerant path equality after realpath. */
|
|
112
|
-
export function pathsEquivalent(
|
|
113
|
-
a: string,
|
|
114
|
-
b: string,
|
|
115
|
-
realpathSync: (p: string) => string = safeRealpathSync,
|
|
116
|
-
): boolean {
|
|
117
|
-
return normalizePathKey(realpathSync(a)) === normalizePathKey(realpathSync(b));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function normalizePathKey(p: string): string {
|
|
121
|
-
return path.resolve(p).replace(/[/\\]+$/, "").toLowerCase();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function safeRealpathSync(p: string): string {
|
|
125
|
-
try {
|
|
126
|
-
return fs.realpathSync(p);
|
|
127
|
-
} catch {
|
|
128
|
-
return path.resolve(p);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const defaultExec: QmdExec = (args) => {
|
|
133
|
-
const res = spawnSync("qmd", args, {
|
|
134
|
-
encoding: "utf8",
|
|
135
|
-
// Bound so a wedged index never hangs the runner forever, but generously —
|
|
136
|
-
// see DEFAULT_QMD_EXEC_TIMEOUT_MS.
|
|
137
|
-
timeout: resolveExecTimeoutMs(),
|
|
138
|
-
// On timeout send SIGINT instead of the default SIGTERM. NOTE: qmd 2.5.3
|
|
139
|
-
// traps BOTH identically — each handler only restores the cursor and calls
|
|
140
|
-
// process.exit() — so SIGINT is NOT inherently a graceful transaction
|
|
141
|
-
// unwind; it is simply the same exit path qmd takes on Ctrl-C. The real
|
|
142
|
-
// write-safety guarantee is the raised bound above (a kill is now rare) plus
|
|
143
|
-
// treating a timed-out pass as not-done and never embedding after it (see
|
|
144
|
-
// reindexAfterSync). SIGINT is kept only so a killed pass exits qmd's normal
|
|
145
|
-
// way rather than via an unhandled default signal.
|
|
146
|
-
killSignal: "SIGINT",
|
|
147
|
-
});
|
|
148
|
-
const timedOut =
|
|
149
|
-
res.error != null &&
|
|
150
|
-
(res.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
|
|
151
|
-
return {
|
|
152
|
-
status: res.status,
|
|
153
|
-
stdout: res.stdout ?? "",
|
|
154
|
-
stderr: res.stderr ?? "",
|
|
155
|
-
timedOut,
|
|
156
|
-
};
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
/** SQLite corruption signature qmd surfaces when the vector store is damaged. */
|
|
160
|
-
const CORRUPTION_SIGNATURE = /SQLITE_CORRUPT|database disk image is malformed/i;
|
|
161
|
-
|
|
162
|
-
/** True if a qmd result reports SQLite corruption on stdout or stderr. */
|
|
163
|
-
export function looksCorrupt(r: Partial<Pick<QmdExecResult, "stdout" | "stderr">>): boolean {
|
|
164
|
-
return CORRUPTION_SIGNATURE.test(`${r.stdout ?? ""}\n${r.stderr ?? ""}`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Handle returned by an acquired reindex lock. `release()` is idempotent. */
|
|
168
|
-
export interface ReindexLockHandle {
|
|
169
|
-
release(): void;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Acquire the shared reindex lock at `lockPath`. Returns a handle when acquired,
|
|
174
|
-
* or `null` when another live writer already holds it (caller should skip the
|
|
175
|
-
* cycle). Must never throw — coordination is advisory and must not fail a sync.
|
|
176
|
-
*/
|
|
177
|
-
export type AcquireReindexLock = (lockPath: string) => ReindexLockHandle | null;
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* How long a lock file may sit before a new acquirer treats it as abandoned and
|
|
181
|
-
* reclaims it, even if its recorded PID can't be probed (e.g. a different user,
|
|
182
|
-
* or a torn/empty file). Backstops the PID-liveness check for the crash case.
|
|
183
|
-
*/
|
|
184
|
-
const DEFAULT_LOCK_STALE_MS = 30 * 60_000;
|
|
185
|
-
|
|
186
|
-
function reindexLockStaleMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
187
|
-
const raw = env.HQ_QMD_LOCK_STALE_MS;
|
|
188
|
-
if (raw !== undefined && raw !== "") {
|
|
189
|
-
const n = Number(raw);
|
|
190
|
-
if (Number.isFinite(n) && n >= 0) return Math.round(n);
|
|
191
|
-
}
|
|
192
|
-
return DEFAULT_LOCK_STALE_MS;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const NOOP_LOCK: ReindexLockHandle = { release() {} };
|
|
196
|
-
|
|
197
|
-
// Track locks this process holds so a clean exit/signal removes them; a crash is
|
|
198
|
-
// covered by the stale/PID reclaim path in the acquirer.
|
|
199
|
-
const heldReindexLocks = new Set<string>();
|
|
200
|
-
let reindexExitHookInstalled = false;
|
|
201
|
-
|
|
202
|
-
function installReindexExitHookOnce(): void {
|
|
203
|
-
if (reindexExitHookInstalled) return;
|
|
204
|
-
reindexExitHookInstalled = true;
|
|
205
|
-
process.on("exit", () => {
|
|
206
|
-
for (const p of heldReindexLocks) unlinkIfOwned(p);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Is `pid` a live process? ESRCH → dead; EPERM/other → conservatively alive. */
|
|
211
|
-
function pidAlive(pid: number): boolean {
|
|
212
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
213
|
-
try {
|
|
214
|
-
process.kill(pid, 0);
|
|
215
|
-
return true;
|
|
216
|
-
} catch (err) {
|
|
217
|
-
return (err as NodeJS.ErrnoException)?.code !== "ESRCH";
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Unlink `lockPath` only if it still records THIS process as the holder. */
|
|
222
|
-
function unlinkIfOwned(lockPath: string): void {
|
|
223
|
-
try {
|
|
224
|
-
const info = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { pid?: number };
|
|
225
|
-
if (info?.pid !== process.pid) return;
|
|
226
|
-
} catch {
|
|
227
|
-
// Unreadable/torn/already-gone — don't risk clobbering another holder.
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
fs.unlinkSync(lockPath);
|
|
232
|
-
} catch {
|
|
233
|
-
/* already gone — fine */
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* If the lock at `lockPath` is held by a dead PID or is older than the stale
|
|
239
|
-
* bound, unlink it so the caller can retry. Returns true when it reclaimed (or
|
|
240
|
-
* the lock vanished mid-check), false when a live, fresh holder still owns it.
|
|
241
|
-
*/
|
|
242
|
-
function reclaimReindexLockIfStale(lockPath: string): boolean {
|
|
243
|
-
let st: fs.Stats;
|
|
244
|
-
try {
|
|
245
|
-
st = fs.statSync(lockPath);
|
|
246
|
-
} catch {
|
|
247
|
-
// Vanished between EEXIST and stat → the holder released; let caller retry.
|
|
248
|
-
return true;
|
|
249
|
-
}
|
|
250
|
-
let holderPid = 0;
|
|
251
|
-
try {
|
|
252
|
-
const info = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { pid?: number };
|
|
253
|
-
if (typeof info?.pid === "number") holderPid = info.pid;
|
|
254
|
-
} catch {
|
|
255
|
-
/* torn/empty lock — fall through to the staleness check */
|
|
256
|
-
}
|
|
257
|
-
const dead = holderPid > 0 && !pidAlive(holderPid);
|
|
258
|
-
const stale = Date.now() - st.mtimeMs > reindexLockStaleMs();
|
|
259
|
-
if (dead || stale) {
|
|
260
|
-
try {
|
|
261
|
-
fs.unlinkSync(lockPath);
|
|
262
|
-
} catch {
|
|
263
|
-
// Someone else reclaimed it first; the next create attempt re-evaluates.
|
|
264
|
-
}
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
return false;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const defaultAcquireReindexLock: AcquireReindexLock = (lockPath) => {
|
|
271
|
-
// Escape hatch: a caller that manages exclusion itself can disable the lock.
|
|
272
|
-
if (process.env.HQ_QMD_REINDEX_LOCK === "0") return NOOP_LOCK;
|
|
273
|
-
|
|
274
|
-
const payload = JSON.stringify({
|
|
275
|
-
pid: process.pid,
|
|
276
|
-
startedAt: new Date().toISOString(),
|
|
277
|
-
command: "hq-cloud qmd-reindex",
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// At most one reclaim + one retry: acquire, or (if stale) reclaim then retry.
|
|
281
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
282
|
-
try {
|
|
283
|
-
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
284
|
-
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
285
|
-
try {
|
|
286
|
-
fs.writeSync(fd, payload);
|
|
287
|
-
} finally {
|
|
288
|
-
fs.closeSync(fd);
|
|
289
|
-
}
|
|
290
|
-
heldReindexLocks.add(lockPath);
|
|
291
|
-
installReindexExitHookOnce();
|
|
292
|
-
return {
|
|
293
|
-
release() {
|
|
294
|
-
heldReindexLocks.delete(lockPath);
|
|
295
|
-
unlinkIfOwned(lockPath);
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
} catch (err) {
|
|
299
|
-
if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
|
|
300
|
-
// The lock dir isn't writable. Fail OPEN rather than block the reindex
|
|
301
|
-
// on an infra problem: proceed without the advisory lock. The corruption
|
|
302
|
-
// guard below is the remaining backstop.
|
|
303
|
-
return NOOP_LOCK;
|
|
304
|
-
}
|
|
305
|
-
// Held. Reclaim iff the holder is dead/stale, then retry once.
|
|
306
|
-
if (reclaimReindexLockIfStale(lockPath)) continue;
|
|
307
|
-
return null; // live, fresh holder → skip this cycle
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return null;
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Acquire unique lock paths (legacy + resolved index). If any is busy, release
|
|
315
|
-
* what was acquired and return busy. Dedupes when both paths are the same so a
|
|
316
|
-
* process never O_EXCL-locks itself out.
|
|
317
|
-
*/
|
|
318
|
-
export function acquireDualReindexLocks(
|
|
319
|
-
acquireLock: AcquireReindexLock,
|
|
320
|
-
lockPaths: string[],
|
|
321
|
-
): { locks: ReindexLockHandle[]; busy: boolean } {
|
|
322
|
-
const unique: string[] = [];
|
|
323
|
-
const seen = new Set<string>();
|
|
324
|
-
for (const p of lockPaths) {
|
|
325
|
-
const key = normalizePathKey(p);
|
|
326
|
-
if (seen.has(key)) continue;
|
|
327
|
-
seen.add(key);
|
|
328
|
-
unique.push(p);
|
|
329
|
-
}
|
|
330
|
-
const locks: ReindexLockHandle[] = [];
|
|
331
|
-
for (const p of unique) {
|
|
332
|
-
const handle = acquireLock(p);
|
|
333
|
-
if (!handle) {
|
|
334
|
-
for (const acquired of locks) acquired.release();
|
|
335
|
-
return { locks: [], busy: true };
|
|
336
|
-
}
|
|
337
|
-
locks.push(handle);
|
|
338
|
-
}
|
|
339
|
-
return { locks, busy: false };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** Move corrupt DB files aside (never delete) so a clean rebuild can follow. */
|
|
343
|
-
export type QuarantineCorruptIndex = (
|
|
344
|
-
qmdDir: string,
|
|
345
|
-
timestampSuffix: string,
|
|
346
|
-
) => string | null;
|
|
347
|
-
|
|
348
|
-
const CORRUPTIBLE_DB_FILES = ["index.sqlite", "index.sqlite-wal", "index.sqlite-shm"];
|
|
349
|
-
|
|
350
|
-
const defaultQuarantineCorruptIndex: QuarantineCorruptIndex = (qmdDir, tsSuffix) => {
|
|
351
|
-
let movedBase: string | null = null;
|
|
352
|
-
for (const name of CORRUPTIBLE_DB_FILES) {
|
|
353
|
-
const src = path.join(qmdDir, name);
|
|
354
|
-
try {
|
|
355
|
-
if (!fs.existsSync(src)) continue;
|
|
356
|
-
const dest = `${src}.corrupt-${tsSuffix}`;
|
|
357
|
-
fs.renameSync(src, dest);
|
|
358
|
-
if (name === "index.sqlite") movedBase = dest;
|
|
359
|
-
else if (movedBase === null) movedBase = dest;
|
|
360
|
-
} catch {
|
|
361
|
-
// Best-effort: move what we can. A file we can't move is left in place;
|
|
362
|
-
// the corruption guard still prevents further writes this cycle.
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return movedBase;
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
/** Filesystem-safe timestamp suffix for a quarantined DB file. */
|
|
369
|
-
function quarantineSuffix(nowMs: number): string {
|
|
370
|
-
return new Date(nowMs).toISOString().replace(/[:.]/g, "-");
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const DEFAULT_CHANGED_PATH_DEBOUNCE_MS = 60_000;
|
|
374
|
-
|
|
375
|
-
export interface ReindexOptions {
|
|
376
|
-
/** Rebuild embeddings too (slow). Default false — lexical-only. */
|
|
377
|
-
embed?: boolean;
|
|
378
|
-
/**
|
|
379
|
-
* HQ-relative paths changed by sync. When omitted, preserves the historical
|
|
380
|
-
* behavior and runs QMD for any sync caller that invokes this function.
|
|
381
|
-
*/
|
|
382
|
-
changedPaths?: string[];
|
|
383
|
-
/** Force a collection registration refresh even when content is not dirty. */
|
|
384
|
-
forceCollectionRefresh?: boolean;
|
|
385
|
-
/** Delay dirty updates by this many ms while keeping a persistent marker. */
|
|
386
|
-
debounceMs?: number;
|
|
387
|
-
/** Clock override for deterministic debounce tests. */
|
|
388
|
-
nowMs?: number;
|
|
389
|
-
/** State file override for tests. */
|
|
390
|
-
statePath?: string;
|
|
391
|
-
/** Command runner override for tests. */
|
|
392
|
-
exec?: QmdExec;
|
|
393
|
-
/** `existsSync` override for tests. */
|
|
394
|
-
existsSync?: (p: string) => boolean;
|
|
395
|
-
/** `readdirSync` override for tests (returns subdir names of companies/). */
|
|
396
|
-
readCompanies?: (companiesDir: string) => string[];
|
|
397
|
-
/** Returns true if the knowledge dir has at least one indexable .md file. */
|
|
398
|
-
hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
|
|
399
|
-
/** Returns true if the projects dir has at least one indexable .md or .json file. */
|
|
400
|
-
hasIndexableProjectContent?: (projectsDir: string) => boolean;
|
|
401
|
-
/** Reindex-lock acquirer override for tests. */
|
|
402
|
-
acquireReindexLock?: AcquireReindexLock;
|
|
403
|
-
/** Corrupt-index quarantine override for tests. */
|
|
404
|
-
quarantineCorruptIndex?: QuarantineCorruptIndex;
|
|
405
|
-
/** realpath override for path-drift comparison in tests. */
|
|
406
|
-
realpathSync?: (p: string) => string;
|
|
407
|
-
/** Env override for tests (repair flag, etc.). */
|
|
408
|
-
env?: NodeJS.ProcessEnv;
|
|
409
|
-
/** Optional diagnostic sink for unexpected swallowed failures. */
|
|
410
|
-
log?: (diagnostic: {
|
|
411
|
-
event: string;
|
|
412
|
-
message: string;
|
|
413
|
-
err: unknown;
|
|
414
|
-
context?: Record<string, unknown>;
|
|
415
|
-
}) => void;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export interface ReindexResult {
|
|
419
|
-
qmdAvailable: boolean;
|
|
420
|
-
collectionsAdded: string[];
|
|
421
|
-
/** HQ-convention collections whose registered path differs from desired. */
|
|
422
|
-
pathDriftDetected: string[];
|
|
423
|
-
/** Collections successfully remove+re-added when repair was enabled. */
|
|
424
|
-
collectionsRepaired: string[];
|
|
425
|
-
updated: boolean;
|
|
426
|
-
embedded: boolean;
|
|
427
|
-
pendingDirty: boolean;
|
|
428
|
-
/** True when the cycle was skipped because another writer held the lock. */
|
|
429
|
-
lockBusy: boolean;
|
|
430
|
-
/** True when a qmd command exceeded the exec timeout and was aborted. */
|
|
431
|
-
timedOut: boolean;
|
|
432
|
-
/** True when a corrupt index was detected and files were quarantined. */
|
|
433
|
-
corruptionQuarantined: boolean;
|
|
434
|
-
/** True when corruption was detected but quarantine moved nothing. */
|
|
435
|
-
corruptionQuarantineFailed: boolean;
|
|
436
|
-
/** Directory used for quarantine / index-side lock (resolved or fallback). */
|
|
437
|
-
indexDir: string | null;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Reindex qmd for an HQ tree after a sync. Never throws — all failures are
|
|
442
|
-
* swallowed so a reindex problem can never mask or fail the sync result.
|
|
443
|
-
*
|
|
444
|
-
* @returns a small summary for logging/telemetry (collectionsAdded, updated).
|
|
445
|
-
*/
|
|
446
|
-
export function reindexAfterSync(
|
|
447
|
-
hqRoot: string,
|
|
448
|
-
opts: ReindexOptions = {},
|
|
449
|
-
): ReindexResult {
|
|
450
|
-
const exec = opts.exec ?? defaultExec;
|
|
451
|
-
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
452
|
-
const acquireLock = opts.acquireReindexLock ?? defaultAcquireReindexLock;
|
|
453
|
-
const quarantine = opts.quarantineCorruptIndex ?? defaultQuarantineCorruptIndex;
|
|
454
|
-
const realpathSync = opts.realpathSync ?? safeRealpathSync;
|
|
455
|
-
const env = opts.env ?? process.env;
|
|
456
|
-
const result: ReindexResult = {
|
|
457
|
-
qmdAvailable: false,
|
|
458
|
-
collectionsAdded: [],
|
|
459
|
-
pathDriftDetected: [],
|
|
460
|
-
collectionsRepaired: [],
|
|
461
|
-
updated: false,
|
|
462
|
-
embedded: false,
|
|
463
|
-
pendingDirty: false,
|
|
464
|
-
lockBusy: false,
|
|
465
|
-
timedOut: false,
|
|
466
|
-
corruptionQuarantined: false,
|
|
467
|
-
corruptionQuarantineFailed: false,
|
|
468
|
-
indexDir: null,
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
// Guard: only operate on a real HQ tree.
|
|
473
|
-
if (!existsSync(path.join(hqRoot, "core", "core.yaml"))) return result;
|
|
474
|
-
|
|
475
|
-
const statePath = opts.statePath ?? path.join(hqRoot, ".hq", "qmd-reindex-state.json");
|
|
476
|
-
const state = readState(statePath);
|
|
477
|
-
const changedPaths = opts.changedPaths;
|
|
478
|
-
const dirtyFromChanges =
|
|
479
|
-
changedPaths === undefined ||
|
|
480
|
-
changedPaths.some(isQmdIndexableContentPath);
|
|
481
|
-
const registrationMayBeStale =
|
|
482
|
-
opts.forceCollectionRefresh === true ||
|
|
483
|
-
changedPaths === undefined ||
|
|
484
|
-
dirtyFromChanges;
|
|
485
|
-
const pendingDirty = state.pendingDirty === true;
|
|
486
|
-
if (!dirtyFromChanges && !registrationMayBeStale && !pendingDirty) {
|
|
487
|
-
return result;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const nowMs = opts.nowMs ?? Date.now();
|
|
491
|
-
const pendingSinceMs =
|
|
492
|
-
pendingDirty && typeof state.pendingSinceMs === "number"
|
|
493
|
-
? state.pendingSinceMs
|
|
494
|
-
: nowMs;
|
|
495
|
-
|
|
496
|
-
// Resolve the real index directory before locking. Default qmd layout is
|
|
497
|
-
// ~/.cache/qmd, not <hqRoot>/.qmd — status is read-only.
|
|
498
|
-
const legacyQmdDir = path.join(hqRoot, ".qmd");
|
|
499
|
-
const status = exec(["status"]);
|
|
500
|
-
const parsedIndexDir = parseIndexDirFromStatus(status.stdout ?? "");
|
|
501
|
-
const indexDir = parsedIndexDir ?? legacyQmdDir;
|
|
502
|
-
result.indexDir = indexDir;
|
|
503
|
-
|
|
504
|
-
// Serialize qmd writers across legacy + resolved layouts (feedback_332c7ccc).
|
|
505
|
-
const { locks, busy } = acquireDualReindexLocks(acquireLock, [
|
|
506
|
-
path.join(legacyQmdDir, ".reindex.lock"),
|
|
507
|
-
path.join(indexDir, ".reindex.lock"),
|
|
508
|
-
]);
|
|
509
|
-
if (busy) {
|
|
510
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
511
|
-
result.lockBusy = true;
|
|
512
|
-
result.pendingDirty = true;
|
|
513
|
-
return result;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
if (looksCorrupt(status)) {
|
|
518
|
-
handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
|
|
519
|
-
return result;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Guard: qmd must be installed. `qmd collection list` doubles as the
|
|
523
|
-
// availability probe AND the source for which collections already exist.
|
|
524
|
-
const list = exec(["collection", "list"]);
|
|
525
|
-
if (list.status !== 0) return result; // qmd absent or errored — no-op
|
|
526
|
-
// An already-corrupt store can surface here even on the lexical side.
|
|
527
|
-
if (looksCorrupt(list)) {
|
|
528
|
-
handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
|
|
529
|
-
return result;
|
|
530
|
-
}
|
|
531
|
-
result.qmdAvailable = true;
|
|
532
|
-
const existingCollections = list.stdout;
|
|
533
|
-
let repairedThisCycle = false;
|
|
534
|
-
const hasIndexableMarkdown = opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown;
|
|
535
|
-
const hasIndexableProjectContent =
|
|
536
|
-
opts.hasIndexableProjectContent ?? defaultHasIndexableProjectContent;
|
|
537
|
-
|
|
538
|
-
// 1. Auto-register missing / reconcile drifted company knowledge collections.
|
|
539
|
-
if (registrationMayBeStale) {
|
|
540
|
-
const companiesDir = path.join(hqRoot, "companies");
|
|
541
|
-
const slugs = (opts.readCompanies ?? defaultReadCompanies)(companiesDir);
|
|
542
|
-
const allowRepair = repairPathDriftEnabled(env);
|
|
543
|
-
for (const slug of slugs) {
|
|
544
|
-
const knowledgeDir = path.join(companiesDir, slug, "knowledge");
|
|
545
|
-
if (!existsSync(knowledgeDir)) continue;
|
|
546
|
-
const hasMd = hasIndexableMarkdown(knowledgeDir);
|
|
547
|
-
if (!hasMd) continue;
|
|
548
|
-
|
|
549
|
-
const namePresent = existingCollections.includes(`qmd://${slug}/`);
|
|
550
|
-
if (!namePresent) {
|
|
551
|
-
const add = exec(["collection", "add", knowledgeDir, "--name", slug, "--mask", "**/*.md"]);
|
|
552
|
-
if (add.status === 0) {
|
|
553
|
-
exec(["context", "add", `qmd://${slug}`, `Knowledge base for ${slug}.`]);
|
|
554
|
-
result.collectionsAdded.push(slug);
|
|
555
|
-
}
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Name exists — check path (feedback_f8e233c6). Paths only on `show`.
|
|
560
|
-
const show = exec(["collection", "show", slug]);
|
|
561
|
-
if (show.status !== 0) continue;
|
|
562
|
-
const registeredPath = parseCollectionPathFromShow(show.stdout ?? "");
|
|
563
|
-
if (!registeredPath) continue;
|
|
564
|
-
if (pathsEquivalent(registeredPath, knowledgeDir, realpathSync)) continue;
|
|
565
|
-
|
|
566
|
-
result.pathDriftDetected.push(slug);
|
|
567
|
-
opts.log?.({
|
|
568
|
-
event: "runner.qmd_reindex.path_drift",
|
|
569
|
-
message: `qmd collection ${slug} path drifted`,
|
|
570
|
-
err: null,
|
|
571
|
-
context: {
|
|
572
|
-
slug,
|
|
573
|
-
registeredPath,
|
|
574
|
-
desiredPath: knowledgeDir,
|
|
575
|
-
repairEnabled: allowRepair,
|
|
576
|
-
},
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
if (!allowRepair) continue;
|
|
580
|
-
|
|
581
|
-
// Guaranteed hasMd + exists above. Remove + re-add; never embed inline.
|
|
582
|
-
const removed = exec(["collection", "remove", slug]);
|
|
583
|
-
if (removed.status !== 0) {
|
|
584
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
585
|
-
result.pendingDirty = true;
|
|
586
|
-
opts.log?.({
|
|
587
|
-
event: "runner.qmd_reindex.path_drift_repair_failed",
|
|
588
|
-
message: `failed to remove drifted collection ${slug}`,
|
|
589
|
-
err: removed.stderr ?? removed.stdout,
|
|
590
|
-
context: { slug },
|
|
591
|
-
});
|
|
592
|
-
continue;
|
|
593
|
-
}
|
|
594
|
-
const add = exec(["collection", "add", knowledgeDir, "--name", slug, "--mask", "**/*.md"]);
|
|
595
|
-
if (add.status !== 0) {
|
|
596
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
597
|
-
result.pendingDirty = true;
|
|
598
|
-
opts.log?.({
|
|
599
|
-
event: "runner.qmd_reindex.path_drift_repair_failed",
|
|
600
|
-
message: `removed ${slug} but failed to re-add at desired path`,
|
|
601
|
-
err: add.stderr ?? add.stdout,
|
|
602
|
-
context: { slug, knowledgeDir },
|
|
603
|
-
});
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
606
|
-
exec(["context", "add", `qmd://${slug}`, `Knowledge base for ${slug}.`]);
|
|
607
|
-
result.collectionsRepaired.push(slug);
|
|
608
|
-
repairedThisCycle = true;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// 1b. Auto-register company projects collections. Match the shell
|
|
612
|
-
// post-sync convention exactly so prd.json and project docs are searchable.
|
|
613
|
-
for (const slug of slugs) {
|
|
614
|
-
const projectsDir = path.join(companiesDir, slug, "projects");
|
|
615
|
-
if (!existsSync(projectsDir)) continue;
|
|
616
|
-
if (!hasIndexableProjectContent(projectsDir)) continue;
|
|
617
|
-
|
|
618
|
-
const name = `${slug}-projects`;
|
|
619
|
-
if (existingCollections.includes(`qmd://${name}/`)) continue;
|
|
620
|
-
const add = exec([
|
|
621
|
-
"collection",
|
|
622
|
-
"add",
|
|
623
|
-
projectsDir,
|
|
624
|
-
"--name",
|
|
625
|
-
name,
|
|
626
|
-
"--mask",
|
|
627
|
-
"**/*.{md,json}",
|
|
628
|
-
]);
|
|
629
|
-
if (add.status === 0) {
|
|
630
|
-
exec(["context", "add", `qmd://${name}`, `Project PRDs and documentation for ${slug}.`]);
|
|
631
|
-
result.collectionsAdded.push(name);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// 1c. Auto-register the owner overlay's personal knowledge collection.
|
|
636
|
-
const personalKnowledgeDir = path.join(hqRoot, "personal", "knowledge");
|
|
637
|
-
const personalKnowledgeName = "personal-knowledge";
|
|
638
|
-
if (
|
|
639
|
-
existsSync(personalKnowledgeDir) &&
|
|
640
|
-
hasIndexableMarkdown(personalKnowledgeDir) &&
|
|
641
|
-
!existingCollections.includes(`qmd://${personalKnowledgeName}/`)
|
|
642
|
-
) {
|
|
643
|
-
const add = exec([
|
|
644
|
-
"collection",
|
|
645
|
-
"add",
|
|
646
|
-
personalKnowledgeDir,
|
|
647
|
-
"--name",
|
|
648
|
-
personalKnowledgeName,
|
|
649
|
-
"--mask",
|
|
650
|
-
"**/*.md",
|
|
651
|
-
]);
|
|
652
|
-
if (add.status === 0) {
|
|
653
|
-
exec([
|
|
654
|
-
"context",
|
|
655
|
-
"add",
|
|
656
|
-
`qmd://${personalKnowledgeName}`,
|
|
657
|
-
"Personal knowledge base (owner overlay).",
|
|
658
|
-
]);
|
|
659
|
-
result.collectionsAdded.push(personalKnowledgeName);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// 2. Incremental lexical reindex.
|
|
665
|
-
const shouldUpdate = dirtyFromChanges || pendingDirty;
|
|
666
|
-
if (shouldUpdate) {
|
|
667
|
-
const debounceMs =
|
|
668
|
-
opts.debounceMs ??
|
|
669
|
-
(changedPaths === undefined ? 0 : DEFAULT_CHANGED_PATH_DEBOUNCE_MS);
|
|
670
|
-
if (debounceMs > 0 && nowMs - pendingSinceMs < debounceMs) {
|
|
671
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
672
|
-
result.pendingDirty = true;
|
|
673
|
-
return result;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const update = exec(["update"]);
|
|
677
|
-
if (looksCorrupt(update)) {
|
|
678
|
-
handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
|
|
679
|
-
return result;
|
|
680
|
-
}
|
|
681
|
-
if (update.timedOut) {
|
|
682
|
-
// Aborted mid-pass by the timeout. Do NOT treat as done and do NOT
|
|
683
|
-
// proceed to embed — mark dirty and retry cleanly next cycle.
|
|
684
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
685
|
-
result.timedOut = true;
|
|
686
|
-
result.pendingDirty = true;
|
|
687
|
-
return result;
|
|
688
|
-
}
|
|
689
|
-
result.updated = update.status === 0;
|
|
690
|
-
if (result.updated) {
|
|
691
|
-
writeState(statePath, { pendingDirty: false, lastSuccessMs: nowMs });
|
|
692
|
-
} else {
|
|
693
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
694
|
-
result.pendingDirty = true;
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// 3. Embeddings only on explicit request — never after a path-drift repair
|
|
699
|
-
// in the same cycle (repair destroys that collection's vectors).
|
|
700
|
-
if (opts.embed && !repairedThisCycle) {
|
|
701
|
-
const embed = exec(["embed"]);
|
|
702
|
-
if (looksCorrupt(embed)) {
|
|
703
|
-
handleCorruption(indexDir, statePath, state, pendingSinceMs, nowMs, quarantine, result);
|
|
704
|
-
return result;
|
|
705
|
-
}
|
|
706
|
-
if (embed.timedOut) {
|
|
707
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
708
|
-
result.timedOut = true;
|
|
709
|
-
result.pendingDirty = true;
|
|
710
|
-
return result;
|
|
711
|
-
}
|
|
712
|
-
result.embedded = embed.status === 0;
|
|
713
|
-
}
|
|
714
|
-
} finally {
|
|
715
|
-
for (const lock of locks) lock.release();
|
|
716
|
-
}
|
|
717
|
-
} catch (err) {
|
|
718
|
-
try {
|
|
719
|
-
opts.log?.({
|
|
720
|
-
event: "runner.qmd_reindex.internal_failed",
|
|
721
|
-
message: "post-sync qmd reindex failed",
|
|
722
|
-
err,
|
|
723
|
-
context: { hqRoot },
|
|
724
|
-
});
|
|
725
|
-
} catch {
|
|
726
|
-
/* diagnostics must never affect sync */
|
|
727
|
-
}
|
|
728
|
-
// Reindex is best-effort; the sync result is authoritative. Swallow.
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
return result;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Quarantine the corrupt DB aside and mark the tree dirty so the NEXT cycle
|
|
736
|
-
* rebuilds from scratch under the lock, instead of writing further onto an
|
|
737
|
-
* already-damaged vector store. Mutates `result` in place.
|
|
738
|
-
* `corruptionQuarantined` is true only when files were actually moved.
|
|
739
|
-
*/
|
|
740
|
-
function handleCorruption(
|
|
741
|
-
indexDir: string,
|
|
742
|
-
statePath: string,
|
|
743
|
-
state: QmdReindexState,
|
|
744
|
-
pendingSinceMs: number,
|
|
745
|
-
nowMs: number,
|
|
746
|
-
quarantine: QuarantineCorruptIndex,
|
|
747
|
-
result: ReindexResult,
|
|
748
|
-
): void {
|
|
749
|
-
const moved = quarantine(indexDir, quarantineSuffix(nowMs));
|
|
750
|
-
writeState(statePath, { ...state, pendingDirty: true, pendingSinceMs });
|
|
751
|
-
if (moved != null) {
|
|
752
|
-
result.corruptionQuarantined = true;
|
|
753
|
-
} else {
|
|
754
|
-
result.corruptionQuarantineFailed = true;
|
|
755
|
-
}
|
|
756
|
-
result.pendingDirty = true;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
interface QmdReindexState {
|
|
760
|
-
pendingDirty?: boolean;
|
|
761
|
-
pendingSinceMs?: number;
|
|
762
|
-
lastSuccessMs?: number;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
function readState(statePath: string): QmdReindexState {
|
|
766
|
-
try {
|
|
767
|
-
return JSON.parse(fs.readFileSync(statePath, "utf8")) as QmdReindexState;
|
|
768
|
-
} catch {
|
|
769
|
-
return {};
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
function writeState(statePath: string, state: QmdReindexState): void {
|
|
774
|
-
try {
|
|
775
|
-
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
776
|
-
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
|
|
777
|
-
} catch {
|
|
778
|
-
// State is an optimization/guarantee aid; QMD failures must not fail sync.
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function isQmdIndexableContentPath(relPath: string): boolean {
|
|
783
|
-
const normalized = relPath.split(path.sep).join("/");
|
|
784
|
-
return (
|
|
785
|
-
/^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized) ||
|
|
786
|
-
/^companies\/[^/]+\/projects\/.+\.(?:md|json)$/i.test(normalized) ||
|
|
787
|
-
/^personal\/knowledge\/.+\.md$/i.test(normalized)
|
|
788
|
-
);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function defaultReadCompanies(companiesDir: string): string[] {
|
|
792
|
-
try {
|
|
793
|
-
return fs
|
|
794
|
-
.readdirSync(companiesDir, { withFileTypes: true })
|
|
795
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith("."))
|
|
796
|
-
.map((e) => e.name);
|
|
797
|
-
} catch {
|
|
798
|
-
return [];
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function defaultHasIndexableMarkdown(knowledgeDir: string): boolean {
|
|
803
|
-
// Shallow check is enough to decide "is this collection worth registering":
|
|
804
|
-
// walk a bounded depth looking for any .md that isn't INDEX.md.
|
|
805
|
-
const stack: Array<{ dir: string; depth: number }> = [{ dir: knowledgeDir, depth: 0 }];
|
|
806
|
-
while (stack.length) {
|
|
807
|
-
const { dir, depth } = stack.pop()!;
|
|
808
|
-
let entries: fs.Dirent[];
|
|
809
|
-
try {
|
|
810
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
811
|
-
} catch {
|
|
812
|
-
continue;
|
|
813
|
-
}
|
|
814
|
-
for (const e of entries) {
|
|
815
|
-
if (e.isFile() && e.name.endsWith(".md") && e.name !== "INDEX.md") return true;
|
|
816
|
-
if (e.isDirectory() && depth < 4 && !e.name.startsWith(".")) {
|
|
817
|
-
stack.push({ dir: path.join(dir, e.name), depth: depth + 1 });
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
return false;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function defaultHasIndexableProjectContent(projectsDir: string): boolean {
|
|
825
|
-
// Match the shell's `find -type f \( -name '*.md' -o -name '*.json' \)`
|
|
826
|
-
// guard: project documentation and PRD JSON both make the collection useful.
|
|
827
|
-
const stack = [projectsDir];
|
|
828
|
-
while (stack.length) {
|
|
829
|
-
const dir = stack.pop()!;
|
|
830
|
-
let entries: fs.Dirent[];
|
|
831
|
-
try {
|
|
832
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
833
|
-
} catch {
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
for (const e of entries) {
|
|
837
|
-
if (e.isFile() && (e.name.endsWith(".md") || e.name.endsWith(".json"))) return true;
|
|
838
|
-
if (e.isDirectory()) stack.push(path.join(dir, e.name));
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
return false;
|
|
842
|
-
}
|