@indigoai-us/hq-cli 5.86.0 → 5.87.0
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/CHANGELOG.md +12 -0
- package/assets/scaffold/core/scripts/lint-shared-worker-skills.sh +143 -0
- package/assets/scaffold/core/scripts/share-worker-skill.sh +178 -0
- package/dist/commands/core.d.ts +15 -0
- package/dist/commands/core.js +46 -0
- package/dist/commands/index-cmd.d.ts +4 -0
- package/dist/commands/index-cmd.js +34 -0
- package/dist/lib/search-index/background.d.ts +39 -0
- package/dist/lib/search-index/background.js +382 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.87.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added the hidden `hq core worker` subgroup for worker-scoped skill
|
|
10
|
+
maintenance: `hq core worker lint` fails when a skill shared by multiple
|
|
11
|
+
workers is duplicated by copy instead of single-sourced (or a shared-skill
|
|
12
|
+
symlink dangles), and `hq core worker share` migrates duplicated copies onto
|
|
13
|
+
one canonical file plus relative symlinks (dry-run by default; refuses
|
|
14
|
+
cross-scope paths and never silently clobbers a drifted copy). (#309)
|
|
15
|
+
- Added single-flight background reindex. (#308)
|
|
16
|
+
|
|
5
17
|
## [5.86.0]
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lint-shared-worker-skills.sh — fail if a worker-scoped skill is duplicated by
|
|
3
|
+
# copy instead of being single-sourced through the shared-skills convention.
|
|
4
|
+
#
|
|
5
|
+
# THE DRIFT PROBLEM this guards against: a skill used by several workers used to
|
|
6
|
+
# be physically COPIED into each worker's skills/ directory. Editing one copy
|
|
7
|
+
# left the others stale, so the copies drifted apart over time (e.g. a shared
|
|
8
|
+
# e2e-testing skill splitting into two divergent versions). The fix is to keep
|
|
9
|
+
# ONE canonical file under a scope-appropriate `_shared-skills/` store and point
|
|
10
|
+
# each sharing worker's skill entry at it with a relative symlink — see
|
|
11
|
+
# core/knowledge/public/hq-core/shared-worker-skills.md.
|
|
12
|
+
#
|
|
13
|
+
# This linter makes that convention enforceable. It flags two failure shapes:
|
|
14
|
+
#
|
|
15
|
+
# DUPLICATE — two or more NON-symlink skill files, in any worker under the
|
|
16
|
+
# scanned roots, whose byte content is identical. Identical bytes
|
|
17
|
+
# across two real files is exactly an un-single-sourced copy: the
|
|
18
|
+
# moment someone edits one, they drift. (Symlinks that resolve to
|
|
19
|
+
# a shared canonical are single-sourced and are NOT flagged, even
|
|
20
|
+
# though their resolved content matches the canonical.)
|
|
21
|
+
# BROKEN — a skill entry that is a symlink whose target does not resolve.
|
|
22
|
+
#
|
|
23
|
+
# It deliberately does NOT flag two same-NAMED skills whose content differs
|
|
24
|
+
# (e.g. an API-level vs a browser-level e2e skill): distinct content means they
|
|
25
|
+
# are distinct skills that merely share a filename, not a drifted share.
|
|
26
|
+
#
|
|
27
|
+
# Usage: lint-shared-worker-skills.sh [root ...]
|
|
28
|
+
# Default roots: core/workers core/packages (the SHIPPED hq-core scope —
|
|
29
|
+
# company `_shared-skills/` stores live under companies/<co>/ and are linted
|
|
30
|
+
# per-tenant, not here).
|
|
31
|
+
#
|
|
32
|
+
# Exit 0 + "OK:" line when clean; exit 1 + a report naming every offending group
|
|
33
|
+
# when not. Matches the loud-and-specific style of lint-skill-script-refs.sh.
|
|
34
|
+
|
|
35
|
+
set -euo pipefail
|
|
36
|
+
|
|
37
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
38
|
+
cd "$repo_root"
|
|
39
|
+
|
|
40
|
+
roots=("$@")
|
|
41
|
+
if [[ ${#roots[@]} -eq 0 ]]; then
|
|
42
|
+
roots=(core/workers core/packages)
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Content hash of a file, portable across Linux and macOS (neither guaranteed to
|
|
46
|
+
# ship the other's tool): md5sum (GNU) → md5 (BSD/macOS) → shasum (Perl, both).
|
|
47
|
+
content_hash() {
|
|
48
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
49
|
+
md5sum "$1" | cut -d' ' -f1
|
|
50
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
51
|
+
md5 -q "$1"
|
|
52
|
+
else
|
|
53
|
+
shasum "$1" | cut -d' ' -f1
|
|
54
|
+
fi
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Collect skill entries: regular files AND symlinks named *.md under any
|
|
58
|
+
# .../skills/... path. -type l must be matched explicitly — a symlink is not a
|
|
59
|
+
# -type f, so a skills symlink would otherwise be invisible to the scan.
|
|
60
|
+
mapfile -t entries < <(
|
|
61
|
+
find "${roots[@]}" \( -type f -o -type l \) -path '*/skills/*.md' 2>/dev/null | sort
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Empty is clean — and guards `"${entries[@]}"` under `set -u` on bash 3.2
|
|
65
|
+
# (macOS), which errors on an empty array expansion.
|
|
66
|
+
if [[ ${#entries[@]} -eq 0 ]]; then
|
|
67
|
+
echo "OK: worker-scoped skills are single-sourced (0 skill entries under: ${roots[*]})"
|
|
68
|
+
exit 0
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
broken=()
|
|
72
|
+
# Parallel arrays keyed by content hash: hash_keys[i] is a hash, and
|
|
73
|
+
# hash_regfiles[i] is a newline-joined list of the NON-symlink files with that
|
|
74
|
+
# hash. Bash 3.2 (macOS) has no associative arrays in a portable-guaranteed way,
|
|
75
|
+
# so a linear scan over parallel arrays keeps this runnable everywhere HQ runs.
|
|
76
|
+
hash_keys=()
|
|
77
|
+
hash_regfiles=()
|
|
78
|
+
|
|
79
|
+
hash_index() { # echo the index of $1 in hash_keys, or -1
|
|
80
|
+
local want="$1" i
|
|
81
|
+
for i in "${!hash_keys[@]}"; do
|
|
82
|
+
if [[ "${hash_keys[$i]}" == "$want" ]]; then
|
|
83
|
+
echo "$i"; return 0
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
echo "-1"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for entry in "${entries[@]}"; do
|
|
90
|
+
if [[ -L "$entry" ]]; then
|
|
91
|
+
# Symlink: single-sourced by design. Only a DANGLING one is a problem.
|
|
92
|
+
if [[ ! -e "$entry" ]]; then
|
|
93
|
+
broken+=("$entry")
|
|
94
|
+
fi
|
|
95
|
+
continue
|
|
96
|
+
fi
|
|
97
|
+
# Regular file: hash its bytes and bucket it. Two regular files sharing a
|
|
98
|
+
# bucket are two copies of the same skill — the drift hazard.
|
|
99
|
+
h="$(content_hash "$entry")"
|
|
100
|
+
idx="$(hash_index "$h")"
|
|
101
|
+
if [[ "$idx" == "-1" ]]; then
|
|
102
|
+
hash_keys+=("$h")
|
|
103
|
+
hash_regfiles+=("$entry")
|
|
104
|
+
else
|
|
105
|
+
hash_regfiles[$idx]="${hash_regfiles[$idx]}"$'\n'"$entry"
|
|
106
|
+
fi
|
|
107
|
+
done
|
|
108
|
+
|
|
109
|
+
findings=0
|
|
110
|
+
|
|
111
|
+
for i in "${!hash_keys[@]}"; do
|
|
112
|
+
group="${hash_regfiles[$i]}"
|
|
113
|
+
count="$(printf '%s\n' "$group" | grep -c .)"
|
|
114
|
+
if [[ "$count" -ge 2 ]]; then
|
|
115
|
+
if [[ $findings -eq 0 ]]; then
|
|
116
|
+
echo "lint-shared-worker-skills: FAIL — duplicated worker skills (single-source these via _shared-skills/ + relative symlinks):" >&2
|
|
117
|
+
fi
|
|
118
|
+
findings=$((findings + 1))
|
|
119
|
+
echo " DUPLICATE (identical content, ${count} copies — pick one canonical and symlink the rest):" >&2
|
|
120
|
+
printf ' %s\n' "$group" >&2
|
|
121
|
+
fi
|
|
122
|
+
done
|
|
123
|
+
|
|
124
|
+
if [[ ${#broken[@]} -gt 0 ]]; then
|
|
125
|
+
if [[ $findings -eq 0 ]]; then
|
|
126
|
+
echo "lint-shared-worker-skills: FAIL — broken shared-skill symlink(s):" >&2
|
|
127
|
+
fi
|
|
128
|
+
findings=$((findings + ${#broken[@]}))
|
|
129
|
+
echo " BROKEN (symlink target does not resolve):" >&2
|
|
130
|
+
printf ' %s\n' "${broken[@]}" >&2
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
if [[ $findings -gt 0 ]]; then
|
|
134
|
+
echo "" >&2
|
|
135
|
+
echo " Fix: keep ONE canonical file in the narrowest-scope _shared-skills/ store" >&2
|
|
136
|
+
echo " (core/workers/_shared-skills, core/packages/<pack>/workers/_shared-skills," >&2
|
|
137
|
+
echo " or companies/<co>/workers/_shared-skills) and replace each duplicate with a" >&2
|
|
138
|
+
echo " relative symlink. Helper: hq core worker share" >&2
|
|
139
|
+
echo " Doc: core/knowledge/public/hq-core/shared-worker-skills.md" >&2
|
|
140
|
+
exit 1
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
echo "OK: worker-scoped skills are single-sourced (${#entries[@]} skill entries scanned under: ${roots[*]})"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# share-worker-skill.sh — convert duplicated worker-scoped skill copies into a
|
|
3
|
+
# single canonical file plus relative symlinks, the migration step for the
|
|
4
|
+
# shared-worker-skills convention (core/knowledge/public/hq-core/shared-worker-skills.md).
|
|
5
|
+
#
|
|
6
|
+
# This performs the ONLY mechanical move the design needs: it never edits any
|
|
7
|
+
# worker.yaml (the skill name still resolves to the same path — only the file
|
|
8
|
+
# type changes from regular file to symlink), and it never touches a skill
|
|
9
|
+
# resolver. `/run` reads {worker}/skills/{skill}.md and follows the symlink
|
|
10
|
+
# transparently, so the change is purely file-layout.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# share-worker-skill.sh <scope-dir> <skill-name> <worker-skill-path> [<worker-skill-path> ...]
|
|
14
|
+
#
|
|
15
|
+
# <scope-dir> The narrowest scope that contains EVERY sharing worker,
|
|
16
|
+
# e.g. core/workers, core/packages/<pack>/workers, or
|
|
17
|
+
# companies/<co>/workers. The canonical file is created at
|
|
18
|
+
# <scope-dir>/_shared-skills/<skill-name>.md.
|
|
19
|
+
# <skill-name> Bare skill name (no .md), e.g. e2e-testing.
|
|
20
|
+
# <worker-skill-path> Each worker's current copy, e.g.
|
|
21
|
+
# core/workers/foo/skills/e2e-testing.md
|
|
22
|
+
#
|
|
23
|
+
# Safety rails:
|
|
24
|
+
# * Every worker path MUST live under <scope-dir> (no cross-scope symlinks,
|
|
25
|
+
# which would break on pack install / hq-sync). Refuses otherwise.
|
|
26
|
+
# * If no canonical exists yet, the FIRST worker path is promoted to canonical.
|
|
27
|
+
# * A worker copy whose content DIFFERS from the canonical is a DRIFTED copy:
|
|
28
|
+
# the script refuses and tells you to reconcile by hand first (the human
|
|
29
|
+
# decision the design reserves — never a silent overwrite). Re-run after
|
|
30
|
+
# reconciling, or pass --force to accept the canonical for that copy.
|
|
31
|
+
# * Idempotent: a path already symlinked to the canonical is left alone.
|
|
32
|
+
# * DRY-RUN BY DEFAULT. Pass --apply to make changes.
|
|
33
|
+
|
|
34
|
+
set -euo pipefail
|
|
35
|
+
|
|
36
|
+
APPLY=0
|
|
37
|
+
FORCE=0
|
|
38
|
+
args=()
|
|
39
|
+
for a in "$@"; do
|
|
40
|
+
case "$a" in
|
|
41
|
+
--apply) APPLY=1 ;;
|
|
42
|
+
--force) FORCE=1 ;;
|
|
43
|
+
-*) echo "unknown flag: $a" >&2; exit 2 ;;
|
|
44
|
+
*) args+=("$a") ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
|
|
48
|
+
if [[ ${#args[@]} -lt 3 ]]; then
|
|
49
|
+
echo "usage: $0 [--apply] [--force] <scope-dir> <skill-name> <worker-skill-path>..." >&2
|
|
50
|
+
exit 2
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
54
|
+
cd "$repo_root"
|
|
55
|
+
|
|
56
|
+
scope_dir="${args[0]%/}"
|
|
57
|
+
skill_name="${args[1]}"
|
|
58
|
+
worker_paths=("${args[@]:2}")
|
|
59
|
+
|
|
60
|
+
if [[ ! "$skill_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
|
61
|
+
echo "error: skill-name '$skill_name' is not a bare skill name (no slashes, no .md)" >&2
|
|
62
|
+
exit 2
|
|
63
|
+
fi
|
|
64
|
+
if [[ ! -d "$scope_dir" ]]; then
|
|
65
|
+
echo "error: scope dir does not exist: $scope_dir" >&2
|
|
66
|
+
exit 2
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
canonical="$scope_dir/_shared-skills/$skill_name.md"
|
|
70
|
+
|
|
71
|
+
note() { printf '%s\n' "$*"; }
|
|
72
|
+
would() { if [[ $APPLY -eq 1 ]]; then note " did: $*"; else note " would: $*"; fi; }
|
|
73
|
+
|
|
74
|
+
# Normalize a path to repo-relative without requiring GNU realpath (macOS-safe).
|
|
75
|
+
rel_under() { # rel_under <path> <dir> → 0 if <path> is inside <dir>
|
|
76
|
+
local p="${1#./}" d="${2#./}"
|
|
77
|
+
[[ "$p" == "$d/"* ]]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# rel_path <from-dir> <to-file> → relative path from <from-dir> to <to-file>,
|
|
81
|
+
# computed purely from the two repo-relative strings (both already normalized,
|
|
82
|
+
# no `..` segments, no symlinks in the literal path). Portable: no python3, no
|
|
83
|
+
# GNU realpath --relative-to (macOS ships neither).
|
|
84
|
+
rel_path() {
|
|
85
|
+
local from="${1#./}" to="${2#./}"
|
|
86
|
+
local -a fa ta
|
|
87
|
+
IFS='/' read -r -a fa <<< "$from"
|
|
88
|
+
IFS='/' read -r -a ta <<< "$to"
|
|
89
|
+
local i=0
|
|
90
|
+
while [[ $i -lt ${#fa[@]} && $i -lt ${#ta[@]} && "${fa[$i]}" == "${ta[$i]}" ]]; do
|
|
91
|
+
i=$((i + 1))
|
|
92
|
+
done
|
|
93
|
+
local up="" j
|
|
94
|
+
for (( j=i; j<${#fa[@]}; j++ )); do up="../$up"; done
|
|
95
|
+
local down="" k
|
|
96
|
+
for (( k=i; k<${#ta[@]}; k++ )); do down="$down${ta[$k]}/"; done
|
|
97
|
+
down="${down%/}"
|
|
98
|
+
printf '%s%s' "$up" "$down"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Every worker path must be inside the scope dir.
|
|
102
|
+
for wp in "${worker_paths[@]}"; do
|
|
103
|
+
wp="${wp#./}"
|
|
104
|
+
if ! rel_under "$wp" "$scope_dir"; then
|
|
105
|
+
echo "error: worker path '$wp' is not under scope dir '$scope_dir'" >&2
|
|
106
|
+
echo " canonical must live in the narrowest scope covering all sharers;" >&2
|
|
107
|
+
echo " cross-scope symlinks are disallowed. Aborting (no changes made)." >&2
|
|
108
|
+
exit 2
|
|
109
|
+
fi
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
note "Canonical: $canonical"
|
|
113
|
+
note "Scope: $scope_dir"
|
|
114
|
+
[[ $APPLY -eq 1 ]] || note "(dry run — pass --apply to make changes)"
|
|
115
|
+
|
|
116
|
+
# 1) Establish the canonical file.
|
|
117
|
+
if [[ -e "$canonical" && ! -L "$canonical" ]]; then
|
|
118
|
+
note "canonical already exists (regular file) — reusing it"
|
|
119
|
+
else
|
|
120
|
+
# Promote the first worker path that is a real (non-symlink) file.
|
|
121
|
+
seed=""
|
|
122
|
+
for wp in "${worker_paths[@]}"; do
|
|
123
|
+
if [[ -f "$wp" && ! -L "$wp" ]]; then seed="$wp"; break; fi
|
|
124
|
+
done
|
|
125
|
+
if [[ -z "$seed" ]]; then
|
|
126
|
+
echo "error: no canonical exists and no worker path is a regular file to seed it from" >&2
|
|
127
|
+
exit 2
|
|
128
|
+
fi
|
|
129
|
+
would "mkdir -p $scope_dir/_shared-skills"
|
|
130
|
+
would "git mv $seed $canonical (promote first copy to canonical)"
|
|
131
|
+
if [[ $APPLY -eq 1 ]]; then
|
|
132
|
+
mkdir -p "$scope_dir/_shared-skills"
|
|
133
|
+
git mv "$seed" "$canonical" 2>/dev/null || mv "$seed" "$canonical"
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# 2) Point each worker path at the canonical via a relative symlink.
|
|
138
|
+
for wp in "${worker_paths[@]}"; do
|
|
139
|
+
wp="${wp#./}"
|
|
140
|
+
# Already the canonical file itself (post-promotion) → link it too so every
|
|
141
|
+
# worker slot is a symlink and the canonical lives only under _shared-skills.
|
|
142
|
+
target_dir="$(dirname "$wp")"
|
|
143
|
+
# Relative path from the worker's skills dir to the canonical (portable).
|
|
144
|
+
rel="$(rel_path "$target_dir" "$canonical")"
|
|
145
|
+
|
|
146
|
+
if [[ -L "$wp" ]]; then
|
|
147
|
+
cur="$(readlink "$wp")"
|
|
148
|
+
if [[ "$cur" == "$rel" ]]; then
|
|
149
|
+
note "ok: $wp already links to canonical"
|
|
150
|
+
continue
|
|
151
|
+
fi
|
|
152
|
+
would "relink $wp -> $rel (was: $cur)"
|
|
153
|
+
if [[ $APPLY -eq 1 ]]; then ln -sfn "$rel" "$wp"; fi
|
|
154
|
+
continue
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
if [[ -e "$wp" ]]; then
|
|
158
|
+
# Regular file present — must match canonical or be reconciled first.
|
|
159
|
+
if cmp -s "$wp" "$canonical"; then
|
|
160
|
+
would "replace identical copy $wp with symlink -> $rel"
|
|
161
|
+
if [[ $APPLY -eq 1 ]]; then rm -f "$wp"; ln -s "$rel" "$wp"; fi
|
|
162
|
+
else
|
|
163
|
+
if [[ $FORCE -eq 1 ]]; then
|
|
164
|
+
would "FORCE replace DRIFTED copy $wp with symlink -> $rel (content discarded)"
|
|
165
|
+
if [[ $APPLY -eq 1 ]]; then rm -f "$wp"; ln -s "$rel" "$wp"; fi
|
|
166
|
+
else
|
|
167
|
+
echo " DRIFT: $wp differs from canonical — reconcile by hand, then re-run" >&2
|
|
168
|
+
echo " (diff $wp $canonical), or pass --force to accept canonical." >&2
|
|
169
|
+
exit 3
|
|
170
|
+
fi
|
|
171
|
+
fi
|
|
172
|
+
else
|
|
173
|
+
would "create symlink $wp -> $rel"
|
|
174
|
+
if [[ $APPLY -eq 1 ]]; then mkdir -p "$target_dir"; ln -s "$rel" "$wp"; fi
|
|
175
|
+
fi
|
|
176
|
+
done
|
|
177
|
+
|
|
178
|
+
note "done."
|
package/dist/commands/core.d.ts
CHANGED
|
@@ -68,6 +68,21 @@ export type RebuildIndexTarget = ScaffoldAsset & {
|
|
|
68
68
|
/** Rebuild target — `hq core rebuild-index <target>`. */
|
|
69
69
|
target: string;
|
|
70
70
|
};
|
|
71
|
+
export type WorkerSubcommand = ScaffoldAsset & {
|
|
72
|
+
/** Subcommand name under the `worker` group — `hq core worker <name>`. */
|
|
73
|
+
name: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The `hq core worker <name>` subgroup — worker-scoped skill maintenance.
|
|
77
|
+
*
|
|
78
|
+
* Both are cold, explicitly-invoked operations (a linter and a one-shot
|
|
79
|
+
* migration), which the scaffold-vs-cli-code-ownership policy permits in the
|
|
80
|
+
* CLI. They are `root: "cwd"` because each derives its root from the caller
|
|
81
|
+
* (`git rev-parse --show-toplevel || pwd`): the CLI must inject nothing, or it
|
|
82
|
+
* would retarget the lint at the CLI's own checkout instead of the HQ tree the
|
|
83
|
+
* operator is standing in. See core/knowledge/public/hq-core/shared-worker-skills.md.
|
|
84
|
+
*/
|
|
85
|
+
export declare const WORKER_SUBCOMMANDS: WorkerSubcommand[];
|
|
71
86
|
/**
|
|
72
87
|
* The ten index rebuild targets. The command registration below intentionally
|
|
73
88
|
* loops over this table for lookup, so adding a target never requires another
|
package/dist/commands/core.js
CHANGED
|
@@ -32,6 +32,30 @@ import { Option } from "commander";
|
|
|
32
32
|
import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
|
|
33
33
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
34
34
|
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
35
|
+
/**
|
|
36
|
+
* The `hq core worker <name>` subgroup — worker-scoped skill maintenance.
|
|
37
|
+
*
|
|
38
|
+
* Both are cold, explicitly-invoked operations (a linter and a one-shot
|
|
39
|
+
* migration), which the scaffold-vs-cli-code-ownership policy permits in the
|
|
40
|
+
* CLI. They are `root: "cwd"` because each derives its root from the caller
|
|
41
|
+
* (`git rev-parse --show-toplevel || pwd`): the CLI must inject nothing, or it
|
|
42
|
+
* would retarget the lint at the CLI's own checkout instead of the HQ tree the
|
|
43
|
+
* operator is standing in. See core/knowledge/public/hq-core/shared-worker-skills.md.
|
|
44
|
+
*/
|
|
45
|
+
export const WORKER_SUBCOMMANDS = [
|
|
46
|
+
{
|
|
47
|
+
name: "lint",
|
|
48
|
+
asset: "core/scripts/lint-shared-worker-skills.sh",
|
|
49
|
+
root: "cwd",
|
|
50
|
+
summary: "Fail on worker-scoped skills duplicated by copy instead of single-sourced",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "share",
|
|
54
|
+
asset: "core/scripts/share-worker-skill.sh",
|
|
55
|
+
root: "cwd",
|
|
56
|
+
summary: "Migrate duplicated worker skills onto one canonical file + relative symlinks",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
35
59
|
/**
|
|
36
60
|
* The ten index rebuild targets. The command registration below intentionally
|
|
37
61
|
* loops over this table for lookup, so adding a target never requires another
|
|
@@ -181,6 +205,7 @@ export const SCAFFOLD_COMMANDS = [
|
|
|
181
205
|
export const SCAFFOLD_ASSETS = [
|
|
182
206
|
...REBUILD_INDEX_TARGETS,
|
|
183
207
|
...SCAFFOLD_COMMANDS,
|
|
208
|
+
...WORKER_SUBCOMMANDS,
|
|
184
209
|
];
|
|
185
210
|
/**
|
|
186
211
|
* Resolve the tree an entry runs against, and the cwd to run it in.
|
|
@@ -237,6 +262,27 @@ export function registerCoreCommands(program) {
|
|
|
237
262
|
// This group primarily hosts manifest-driven bundled assets, but it also
|
|
238
263
|
// hosts native TypeScript plumbing when a scaffold contract needs it.
|
|
239
264
|
registerCoreCheckpointCommand(core);
|
|
265
|
+
// `hq core worker <name>` — a nested subgroup for worker-scoped skill
|
|
266
|
+
// maintenance. Nested (rather than flat `hq core worker-<name>`) so the two
|
|
267
|
+
// related operations read as one family.
|
|
268
|
+
const worker = core
|
|
269
|
+
.command("worker")
|
|
270
|
+
.description("Worker-scoped skill maintenance (lint, share)");
|
|
271
|
+
for (const entry of WORKER_SUBCOMMANDS) {
|
|
272
|
+
worker
|
|
273
|
+
.command(entry.name)
|
|
274
|
+
.description(entry.summary)
|
|
275
|
+
// Pure passthrough: the wrapped script owns its own argument grammar.
|
|
276
|
+
.allowUnknownOption()
|
|
277
|
+
.allowExcessArguments()
|
|
278
|
+
.helpOption(false)
|
|
279
|
+
.argument("[args...]", "arguments passed through to the script")
|
|
280
|
+
.action((args = [], _opts, cmd) => {
|
|
281
|
+
const scope = core.opts();
|
|
282
|
+
const operands = cmd.args.length > 0 ? cmd.args : args;
|
|
283
|
+
runEntry(entry, scope, operands);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
240
286
|
const runCommand = (entry, args = [], cmd, target) => {
|
|
241
287
|
const scope = core.opts();
|
|
242
288
|
// `cmd.args` is the authoritative operand list: with
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from '../lib/search-index/index.js';
|
|
3
|
+
import { type BackgroundDependencies, type BackgroundResult, type BackgroundStatus } from '../lib/search-index/background.js';
|
|
3
4
|
export type SearchIndexDependencies = {
|
|
4
5
|
reconcileCollections: (hqRoot: string) => unknown;
|
|
5
6
|
deriveCollections: (hqRoot: string) => SearchCollection[];
|
|
@@ -7,6 +8,9 @@ export type SearchIndexDependencies = {
|
|
|
7
8
|
resolveQmdBin: () => string;
|
|
8
9
|
resolveQmdVersion: () => string | undefined;
|
|
9
10
|
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
11
|
+
runBackgroundLauncher?: (dependencies: BackgroundDependencies) => BackgroundResult;
|
|
12
|
+
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult;
|
|
13
|
+
backgroundStatus?: (dependencies: BackgroundDependencies) => BackgroundStatus;
|
|
10
14
|
};
|
|
11
15
|
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
12
16
|
export declare function syncSearchIndex(hqRoot: string, embed: boolean, dependencies?: SearchIndexDependencies): void;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { Option } from 'commander';
|
|
1
2
|
import { deriveCollections, listRegisteredCollections, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
|
|
3
|
+
import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
|
|
2
4
|
import { findHqRoot } from '../utils/manifest.js';
|
|
3
5
|
const defaults = {
|
|
4
6
|
reconcileCollections,
|
|
@@ -7,6 +9,9 @@ const defaults = {
|
|
|
7
9
|
resolveQmdBin,
|
|
8
10
|
resolveQmdVersion,
|
|
9
11
|
runQmd,
|
|
12
|
+
runBackgroundLauncher,
|
|
13
|
+
runBackgroundWorker,
|
|
14
|
+
backgroundStatus,
|
|
10
15
|
};
|
|
11
16
|
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
12
17
|
export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
|
|
@@ -18,6 +23,14 @@ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
|
|
|
18
23
|
function resolveRoot(hqRoot) {
|
|
19
24
|
return hqRoot ?? findHqRoot();
|
|
20
25
|
}
|
|
26
|
+
function makeBackgroundDependencies(hqRoot, dependencies) {
|
|
27
|
+
return {
|
|
28
|
+
...defaultBackgroundDependencies(hqRoot),
|
|
29
|
+
resolveQmdBin: dependencies.resolveQmdBin,
|
|
30
|
+
reconcileCollections: dependencies.reconcileCollections,
|
|
31
|
+
runQmd: dependencies.runQmd,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
21
34
|
export function collectionStatusLines(expected, registered) {
|
|
22
35
|
const expectedNames = new Set(expected.map((collection) => collection.name));
|
|
23
36
|
const managed = expected.map((collection) => `${registered.has(collection.name) ? 'registered' : 'missing'} ${collection.name} ${collection.path}`);
|
|
@@ -54,6 +67,25 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
54
67
|
for (const line of collectionStatusLines(dependencies.deriveCollections(hqRoot), registered))
|
|
55
68
|
console.log(line);
|
|
56
69
|
});
|
|
70
|
+
index
|
|
71
|
+
.command('background')
|
|
72
|
+
.description('Run a detached, single-flight qmd cleanup and reindex')
|
|
73
|
+
.option('--log <path>', 'Write worker output to this log file')
|
|
74
|
+
.addOption(new Option('--worker').hideHelp())
|
|
75
|
+
.option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
|
|
76
|
+
.action((options) => {
|
|
77
|
+
const hqRoot = resolveRoot(options.hqRoot);
|
|
78
|
+
const background = makeBackgroundDependencies(hqRoot, dependencies);
|
|
79
|
+
if (options.log)
|
|
80
|
+
background.env = { ...background.env, QMD_REINDEX_LOG: options.log };
|
|
81
|
+
const result = options.worker
|
|
82
|
+
? (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
83
|
+
: (dependencies.runBackgroundLauncher ?? runBackgroundLauncher)(background);
|
|
84
|
+
if (!options.worker && result.state === 'launched')
|
|
85
|
+
console.log(result.pid);
|
|
86
|
+
else if (!options.worker && (result.state === 'skipped-agent' || result.state === 'skipped'))
|
|
87
|
+
console.log(result.state);
|
|
88
|
+
});
|
|
57
89
|
index
|
|
58
90
|
.command('status')
|
|
59
91
|
.description('Show qmd binary, collection, and index status')
|
|
@@ -65,8 +97,10 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
65
97
|
const expected = dependencies.deriveCollections(hqRoot);
|
|
66
98
|
const qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
|
|
67
99
|
const qmdVersion = dependencies.resolveQmdVersion();
|
|
100
|
+
const background = (dependencies.backgroundStatus ?? backgroundStatus)(makeBackgroundDependencies(hqRoot, dependencies));
|
|
68
101
|
console.log(`qmd: ${bin}${qmdVersion ? ` (version ${qmdVersion})` : ''}`);
|
|
69
102
|
console.log(collectionSummary(expected, registered));
|
|
103
|
+
console.log(`background: lock ${background.lock}; last completed ${background.completedAt ?? 'never'}`);
|
|
70
104
|
if (qmdStatus.stdout)
|
|
71
105
|
process.stdout.write(qmdStatus.stdout);
|
|
72
106
|
if (qmdStatus.stderr)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type QmdProcessResult, type RunQmdOptions } from './index.js';
|
|
2
|
+
export type BackgroundResult = {
|
|
3
|
+
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed';
|
|
4
|
+
} | {
|
|
5
|
+
state: 'launched';
|
|
6
|
+
pid: number;
|
|
7
|
+
};
|
|
8
|
+
export type BackgroundDependencies = {
|
|
9
|
+
env: NodeJS.ProcessEnv;
|
|
10
|
+
hqRoot: string;
|
|
11
|
+
now: () => number;
|
|
12
|
+
pid: number;
|
|
13
|
+
random: () => string;
|
|
14
|
+
isProcessAlive: (pid: number) => boolean;
|
|
15
|
+
resolveQmdBin: () => string;
|
|
16
|
+
reconcileCollections: (hqRoot: string) => unknown;
|
|
17
|
+
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
18
|
+
spawnWorker: (options: {
|
|
19
|
+
logPath: string;
|
|
20
|
+
}) => number;
|
|
21
|
+
/** Test seam for simulating a competing owner replacing the atomic record. */
|
|
22
|
+
afterOwnerPublish?: (ownerFile: string) => void;
|
|
23
|
+
};
|
|
24
|
+
export type BackgroundStatus = {
|
|
25
|
+
lock: 'held' | 'stale' | 'free';
|
|
26
|
+
completedAt?: number;
|
|
27
|
+
};
|
|
28
|
+
/** Defaults used by the CLI; tests supply every nondeterministic dependency. */
|
|
29
|
+
export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
|
|
30
|
+
/** Match the shell forwarder's hosted-agent markers before looking up qmd. */
|
|
31
|
+
export declare function isHostedAgent(env?: NodeJS.ProcessEnv): boolean;
|
|
32
|
+
export declare function installWorkerCleanup(cleanup: () => void, processEvents?: Pick<NodeJS.Process, 'once'>, exit?: (code: number) => void): void;
|
|
33
|
+
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
34
|
+
export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
|
|
35
|
+
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
36
|
+
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): BackgroundResult;
|
|
37
|
+
/** Report the background lock and latest successful completion for `hq index status`. */
|
|
38
|
+
export declare function backgroundStatus(dependencies: BackgroundDependencies): BackgroundStatus;
|
|
39
|
+
//# sourceMappingURL=background.d.ts.map
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
|
|
5
|
+
const LOCK_NAME = 'qmd-reindex-bg.lock';
|
|
6
|
+
const COMPLETE_NAME = 'qmd-reindex-bg.completed';
|
|
7
|
+
function defaultSpawnWorker({ logPath }) {
|
|
8
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
9
|
+
const log = fs.openSync(logPath, 'a');
|
|
10
|
+
const entry = process.argv[1];
|
|
11
|
+
if (!entry)
|
|
12
|
+
throw new Error('Cannot determine hq CLI entrypoint for background worker');
|
|
13
|
+
const child = spawn(process.execPath, [entry, 'index', 'background', '--worker', '--log', logPath], {
|
|
14
|
+
detached: true,
|
|
15
|
+
stdio: ['ignore', log, log],
|
|
16
|
+
});
|
|
17
|
+
fs.closeSync(log);
|
|
18
|
+
child.unref();
|
|
19
|
+
if (!child.pid)
|
|
20
|
+
throw new Error('Unable to start qmd background worker');
|
|
21
|
+
return child.pid;
|
|
22
|
+
}
|
|
23
|
+
function alive(pid) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Defaults used by the CLI; tests supply every nondeterministic dependency. */
|
|
33
|
+
export function defaultBackgroundDependencies(hqRoot) {
|
|
34
|
+
return {
|
|
35
|
+
env: process.env,
|
|
36
|
+
hqRoot,
|
|
37
|
+
now: () => Math.floor(Date.now() / 1_000),
|
|
38
|
+
pid: process.pid,
|
|
39
|
+
random: () => Math.random().toString(36).slice(2),
|
|
40
|
+
isProcessAlive: alive,
|
|
41
|
+
resolveQmdBin: defaultResolveQmdBin,
|
|
42
|
+
reconcileCollections: defaultReconcileCollections,
|
|
43
|
+
runQmd: defaultRunQmd,
|
|
44
|
+
spawnWorker: defaultSpawnWorker,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Match the shell forwarder's hosted-agent markers before looking up qmd. */
|
|
48
|
+
export function isHostedAgent(env = process.env) {
|
|
49
|
+
if (env.HQ_QMD_REINDEX_MODE === 'skip-agent' || env.HQ_QMD_REINDEX_MODE === 'skip')
|
|
50
|
+
return true;
|
|
51
|
+
if (env.HQ_AGENT_BOX && env.HQ_AGENT_BOX !== '0')
|
|
52
|
+
return true;
|
|
53
|
+
try {
|
|
54
|
+
fs.accessSync('/usr/local/bin/hq-agent-qmd-index', fs.constants.X_OK);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch { /* probe next marker */ }
|
|
58
|
+
try {
|
|
59
|
+
fs.accessSync('/usr/local/lib/hq-agent/qmd-index-user', fs.constants.X_OK);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch { /* probe next marker */ }
|
|
63
|
+
return fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.timer')
|
|
64
|
+
|| fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.service')
|
|
65
|
+
|| fs.existsSync('/var/lib/hq-agent');
|
|
66
|
+
}
|
|
67
|
+
function lockRoot(home) {
|
|
68
|
+
return path.join(home, '.hq', 'locks');
|
|
69
|
+
}
|
|
70
|
+
function lockPath(home) {
|
|
71
|
+
return path.join(lockRoot(home), LOCK_NAME);
|
|
72
|
+
}
|
|
73
|
+
function completionPath(home) {
|
|
74
|
+
return path.join(lockRoot(home), COMPLETE_NAME);
|
|
75
|
+
}
|
|
76
|
+
function parseFields(file) {
|
|
77
|
+
try {
|
|
78
|
+
return Object.fromEntries(fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
|
|
79
|
+
const index = line.indexOf('=');
|
|
80
|
+
return index === -1 ? [] : [[line.slice(0, index), line.slice(index + 1)]];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function readOwner(directory) {
|
|
88
|
+
const fields = parseFields(path.join(directory, 'owner'));
|
|
89
|
+
if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
|
|
90
|
+
return undefined;
|
|
91
|
+
return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
|
|
92
|
+
}
|
|
93
|
+
function graceSeconds(dependencies) {
|
|
94
|
+
const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
|
|
95
|
+
return value && /^\d+$/.test(value) ? Number(value) : 5;
|
|
96
|
+
}
|
|
97
|
+
function dedupeSeconds(dependencies) {
|
|
98
|
+
const value = dependencies.env.QMD_HANDOFF_DEDUPE_SEC;
|
|
99
|
+
return value && /^\d+$/.test(value) ? Number(value) : 90;
|
|
100
|
+
}
|
|
101
|
+
function isRecentCompletion(home, dependencies) {
|
|
102
|
+
const dedupe = dedupeSeconds(dependencies);
|
|
103
|
+
if (dedupe === 0)
|
|
104
|
+
return false;
|
|
105
|
+
const fields = parseFields(completionPath(home));
|
|
106
|
+
if (!fields || !/^\d+$/.test(fields.ts ?? ''))
|
|
107
|
+
return false;
|
|
108
|
+
const age = dependencies.now() - Number(fields.ts);
|
|
109
|
+
return age >= 0 && age < dedupe;
|
|
110
|
+
}
|
|
111
|
+
function writeCompletion(home, dependencies) {
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(completionPath(home), `ts=${dependencies.now()}\npid=${dependencies.pid}\nmode=raw\n`);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Completion is a best-effort dedupe hint, never a worker failure.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function isOwnerlessLockWithinGrace(directory, dependencies) {
|
|
120
|
+
try {
|
|
121
|
+
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
122
|
+
return age < graceSeconds(dependencies);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function lockState(directory, dependencies) {
|
|
129
|
+
if (!fs.existsSync(directory))
|
|
130
|
+
return 'free';
|
|
131
|
+
const owner = readOwner(directory);
|
|
132
|
+
if (owner)
|
|
133
|
+
return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
|
|
134
|
+
return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
|
|
135
|
+
}
|
|
136
|
+
function generation(directory) {
|
|
137
|
+
const owner = readOwner(directory);
|
|
138
|
+
if (owner)
|
|
139
|
+
return owner.nonce;
|
|
140
|
+
if (!fs.existsSync(directory))
|
|
141
|
+
return undefined;
|
|
142
|
+
return 'empty';
|
|
143
|
+
}
|
|
144
|
+
function removeDirectoryIfEmpty(directory) {
|
|
145
|
+
try {
|
|
146
|
+
fs.rmdirSync(directory);
|
|
147
|
+
}
|
|
148
|
+
catch { /* another claimant owns it, or it is already gone */ }
|
|
149
|
+
}
|
|
150
|
+
function claimWithinGrace(directory, dependencies) {
|
|
151
|
+
try {
|
|
152
|
+
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
153
|
+
return age < Math.max(5, graceSeconds(dependencies));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function acquireClaim(home, observedGeneration, dependencies) {
|
|
160
|
+
const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
|
|
161
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
162
|
+
try {
|
|
163
|
+
fs.mkdirSync(claim);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
|
|
167
|
+
return undefined;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(claimant);
|
|
173
|
+
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
174
|
+
return { claim, claimant };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
fs.rmSync(claimant, { recursive: true, force: true });
|
|
178
|
+
removeDirectoryIfEmpty(claim);
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reclaim only exact dead claimant names, then remove the claim directory if
|
|
186
|
+
* empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
|
|
187
|
+
* recreated claim.G with a new marker makes rmdir fail and wins the race.
|
|
188
|
+
*/
|
|
189
|
+
function recoverAbandonedClaim(claim, dependencies) {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(claim, { withFileTypes: true });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
|
|
198
|
+
const marker = path.join(claim, entry.name);
|
|
199
|
+
const fields = parseFields(path.join(marker, 'owner'));
|
|
200
|
+
const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
|
|
201
|
+
if (pid !== undefined && dependencies.isProcessAlive(pid))
|
|
202
|
+
return false;
|
|
203
|
+
if (pid === undefined && claimWithinGrace(claim, dependencies))
|
|
204
|
+
return false;
|
|
205
|
+
const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
|
|
206
|
+
try {
|
|
207
|
+
fs.renameSync(marker, abandoned);
|
|
208
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (entries.length === 0 && claimWithinGrace(claim, dependencies))
|
|
215
|
+
return false;
|
|
216
|
+
removeDirectoryIfEmpty(claim);
|
|
217
|
+
return !fs.existsSync(claim);
|
|
218
|
+
}
|
|
219
|
+
function createAndPublishLock(home, dependencies) {
|
|
220
|
+
const directory = lockPath(home);
|
|
221
|
+
try {
|
|
222
|
+
fs.mkdirSync(directory);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
|
|
228
|
+
try {
|
|
229
|
+
fs.mkdirSync(marker);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
removeDirectoryIfEmpty(directory);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const nonce = `${dependencies.pid}.${dependencies.random()}`;
|
|
236
|
+
const ownerFile = path.join(directory, 'owner');
|
|
237
|
+
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
238
|
+
try {
|
|
239
|
+
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
240
|
+
fs.renameSync(temporary, ownerFile);
|
|
241
|
+
dependencies.afterOwnerPublish?.(ownerFile);
|
|
242
|
+
const verified = readOwner(directory);
|
|
243
|
+
if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
|
|
244
|
+
throw new Error('owner publish verification failed');
|
|
245
|
+
fs.rmSync(marker, { recursive: true, force: true });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// The marker identifies only the directory this process created. Do not
|
|
250
|
+
// recursively remove the fixed lock path after a failed publication.
|
|
251
|
+
fs.rmSync(marker, { recursive: true, force: true });
|
|
252
|
+
removeDirectoryIfEmpty(directory);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function claimAndReclaim(home, dependencies) {
|
|
257
|
+
const directory = lockPath(home);
|
|
258
|
+
const observedGeneration = generation(directory);
|
|
259
|
+
if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
260
|
+
return false;
|
|
261
|
+
const acquired = acquireClaim(home, observedGeneration, dependencies);
|
|
262
|
+
if (!acquired)
|
|
263
|
+
return false;
|
|
264
|
+
try {
|
|
265
|
+
// Generation fencing: only move exactly the stale generation we observed.
|
|
266
|
+
if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
267
|
+
return false;
|
|
268
|
+
const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
|
|
269
|
+
fs.renameSync(directory, abandoned);
|
|
270
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
271
|
+
return createAndPublishLock(home, dependencies);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
fs.rmSync(acquired.claimant, { recursive: true, force: true });
|
|
278
|
+
removeDirectoryIfEmpty(acquired.claim);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function acquireLock(home, dependencies) {
|
|
282
|
+
fs.mkdirSync(lockRoot(home), { recursive: true });
|
|
283
|
+
return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies);
|
|
284
|
+
}
|
|
285
|
+
function releaseLock(home, dependencies) {
|
|
286
|
+
const directory = lockPath(home);
|
|
287
|
+
if (readOwner(directory)?.pid === dependencies.pid)
|
|
288
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
|
|
291
|
+
processEvents.once('exit', cleanup);
|
|
292
|
+
// Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
|
|
293
|
+
// catchable cleanup. SIGINT/SIGTERM are registered here and the CLI's normal
|
|
294
|
+
// process exit then runs the same idempotent owner release. Exiting prevents
|
|
295
|
+
// a synchronous pipeline from continuing after it has released ownership.
|
|
296
|
+
processEvents.once('SIGINT', () => { cleanup(); exit(0); });
|
|
297
|
+
processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
|
|
298
|
+
}
|
|
299
|
+
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
300
|
+
export function runBackgroundLauncher(dependencies) {
|
|
301
|
+
if (isHostedAgent(dependencies.env))
|
|
302
|
+
return { state: 'skipped-agent' };
|
|
303
|
+
const home = dependencies.env.HOME;
|
|
304
|
+
if (!home)
|
|
305
|
+
return { state: 'quiet' };
|
|
306
|
+
try {
|
|
307
|
+
dependencies.resolveQmdBin();
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return { state: 'skipped' };
|
|
311
|
+
}
|
|
312
|
+
const logPath = dependencies.env.QMD_REINDEX_LOG
|
|
313
|
+
?? dependencies.env.QMD_HANDOFF_LOG
|
|
314
|
+
?? path.join(dependencies.env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
+
return { state: 'launched', pid: dependencies.spawnWorker({ logPath }) };
|
|
316
|
+
}
|
|
317
|
+
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
318
|
+
export function runBackgroundWorker(dependencies) {
|
|
319
|
+
if (isHostedAgent(dependencies.env))
|
|
320
|
+
return { state: 'skipped-agent' };
|
|
321
|
+
const home = dependencies.env.HOME;
|
|
322
|
+
if (!home)
|
|
323
|
+
return { state: 'quiet' };
|
|
324
|
+
try {
|
|
325
|
+
dependencies.resolveQmdBin();
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return { state: 'skipped' };
|
|
329
|
+
}
|
|
330
|
+
if (isRecentCompletion(home, dependencies) || !acquireLock(home, dependencies))
|
|
331
|
+
return { state: 'busy' };
|
|
332
|
+
let released = false;
|
|
333
|
+
const cleanup = () => {
|
|
334
|
+
if (released)
|
|
335
|
+
return;
|
|
336
|
+
released = true;
|
|
337
|
+
releaseLock(home, dependencies);
|
|
338
|
+
};
|
|
339
|
+
installWorkerCleanup(cleanup, process, (code) => process.exit(code));
|
|
340
|
+
try {
|
|
341
|
+
if (isRecentCompletion(home, dependencies))
|
|
342
|
+
return { state: 'busy' };
|
|
343
|
+
// Collection reconciliation is the existing #306 seam; cleanup/update/embed
|
|
344
|
+
// retain their shell-script ordering after that policy setup.
|
|
345
|
+
try {
|
|
346
|
+
dependencies.reconcileCollections(dependencies.hqRoot);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// The shell worker has no collection-registration step. Keep this #306
|
|
350
|
+
// integration best-effort so it cannot suppress a later index update.
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot });
|
|
354
|
+
}
|
|
355
|
+
catch { /* cleanup is intentionally best-effort */ }
|
|
356
|
+
try {
|
|
357
|
+
dependencies.runQmd(['update'], { cwd: dependencies.hqRoot });
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return { state: 'update-failed' };
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot });
|
|
364
|
+
}
|
|
365
|
+
catch { /* a completed embed attempt still permits the completion stamp */ }
|
|
366
|
+
writeCompletion(home, dependencies);
|
|
367
|
+
return { state: 'completed' };
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
cleanup();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Report the background lock and latest successful completion for `hq index status`. */
|
|
374
|
+
export function backgroundStatus(dependencies) {
|
|
375
|
+
const home = dependencies.env.HOME;
|
|
376
|
+
if (!home)
|
|
377
|
+
return { lock: 'free' };
|
|
378
|
+
const fields = parseFields(completionPath(home));
|
|
379
|
+
const completedAt = fields && /^\d+$/.test(fields.ts ?? '') ? Number(fields.ts) : undefined;
|
|
380
|
+
return { lock: lockState(lockPath(home), dependencies), ...(completedAt === undefined ? {} : { completedAt }) };
|
|
381
|
+
}
|
|
382
|
+
//# sourceMappingURL=background.js.map
|