@indigoai-us/hq-cloud 6.14.49 → 6.15.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/.github/workflows/unreleased-commits-nag.yml +256 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +153 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +11 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
- package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.test.js +51 -0
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +11 -12
- 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 +173 -52
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex-knowledge.d.ts +7 -0
- package/dist/cli/reindex-knowledge.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.js +389 -0
- package/dist/cli/reindex-knowledge.js.map +1 -0
- package/dist/cli/reindex-knowledge.test.d.ts +12 -0
- package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.test.js +260 -0
- package/dist/cli/reindex-knowledge.test.js.map +1 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +8 -0
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +8 -2
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +85 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +390 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-settings-reconcile.test.js +523 -0
- package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +81 -51
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -19
- package/dist/journal.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +8 -25
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -48
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +6 -32
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +20 -117
- package/dist/sync/feature-flags.d.ts.map +1 -1
- package/dist/sync/feature-flags.js +24 -147
- package/dist/sync/feature-flags.js.map +1 -1
- package/dist/sync/feature-flags.test.js +15 -61
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/lease-client.d.ts +67 -0
- package/dist/sync/lease-client.d.ts.map +1 -0
- package/dist/sync/lease-client.js +158 -0
- package/dist/sync/lease-client.js.map +1 -0
- package/dist/sync/lease-client.test.d.ts +2 -0
- package/dist/sync/lease-client.test.d.ts.map +1 -0
- package/dist/sync/lease-client.test.js +119 -0
- package/dist/sync/lease-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +8 -19
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +6 -50
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +6 -57
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/realtime-rollout.d.ts +85 -0
- package/dist/sync/realtime-rollout.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.js +206 -0
- package/dist/sync/realtime-rollout.js.map +1 -0
- package/dist/sync/realtime-rollout.test.d.ts +2 -0
- package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.test.js +71 -0
- package/dist/sync/realtime-rollout.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +40 -0
- package/dist/sync/state-store.d.ts.map +1 -0
- package/dist/sync/state-store.js +680 -0
- package/dist/sync/state-store.js.map +1 -0
- package/dist/sync/state-store.test.d.ts +2 -0
- package/dist/sync/state-store.test.d.ts.map +1 -0
- package/dist/sync/state-store.test.js +159 -0
- package/dist/sync/state-store.test.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +176 -32
- package/src/bin/sync-runner-watch-routes.test.ts +71 -0
- package/src/bin/sync-runner-watch-routes.ts +27 -0
- package/src/bin/sync-runner.test.ts +188 -53
- package/src/bin/sync-runner.ts +11 -12
- package/src/cli/reindex-knowledge.test.ts +307 -0
- package/src/cli/reindex-knowledge.ts +450 -0
- package/src/cli/reindex.ts +9 -0
- package/src/cli/rescue-classify-ordering.test.ts +9 -3
- package/src/cli/rescue-core.ts +459 -1
- package/src/cli/rescue-settings-reconcile.test.ts +637 -0
- package/src/index.ts +2 -4
- package/src/journal.test.ts +19 -21
- package/src/journal.ts +87 -54
- package/src/sync/event-sync.test.ts +9 -44
- package/src/sync/event-sync.ts +26 -52
- package/src/sync/feature-flags.test.ts +22 -72
- package/src/sync/feature-flags.ts +32 -199
- package/src/sync/index.ts +2 -4
- package/src/sync/lease-client.test.ts +128 -0
- package/src/sync/lease-client.ts +207 -0
- package/src/sync/push-receiver.test.ts +6 -58
- package/src/sync/push-receiver.ts +9 -74
- package/src/sync/realtime-rollout.test.ts +86 -0
- package/src/sync/realtime-rollout.ts +262 -0
- package/src/sync/state-store.test.ts +194 -0
- package/src/sync/state-store.ts +727 -0
- package/src/types.ts +14 -0
- package/test/e2e/sync/transient-company-leg.test.ts +8 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
name: Nag unreleased commits on main
|
|
2
|
+
|
|
3
|
+
# Two weekly release nags for hq-cloud:
|
|
4
|
+
#
|
|
5
|
+
# nag — `main` is carrying work nobody has released (below).
|
|
6
|
+
# pin-lag — the latest hq-cloud release is not pinned by hq-cli and
|
|
7
|
+
# hq-desktop-app `main`. See that job's own header.
|
|
8
|
+
#
|
|
9
|
+
# A commit counts as UNRELEASED when it is reachable from `main` but not from
|
|
10
|
+
# any STABLE tag. Stable means `vMAJOR.MINOR.PATCH` with no prerelease suffix —
|
|
11
|
+
# `-rc.N`, `-beta.N` and `-alpha.N` tags deliberately do NOT silence the nag,
|
|
12
|
+
# because a prerelease is not a release.
|
|
13
|
+
#
|
|
14
|
+
# Release sync-back commits (`chore(release): …`) are ignored. The publish
|
|
15
|
+
# workflow pushes them to `main` *after* cutting the tag, so they are
|
|
16
|
+
# unreachable from that tag by construction and would nag forever otherwise.
|
|
17
|
+
#
|
|
18
|
+
# Releasing this repo is one command — `git push origin v<next>` — so the fix
|
|
19
|
+
# for a nag is always to cut the tag (or to say out loud why you are holding).
|
|
20
|
+
#
|
|
21
|
+
# Requires repo secret SLACK_BOT_TOKEN_RELEASE_MANAGER (the same release-manager
|
|
22
|
+
# bot hq-core's `release-pr-nag.yml` uses). The bot MUST be a member of #hq-dev.
|
|
23
|
+
# Without the secret the job logs a warning and exits 0 rather than failing.
|
|
24
|
+
|
|
25
|
+
on:
|
|
26
|
+
schedule:
|
|
27
|
+
# Mondays 15:00 UTC (~08:00 PT). Staggered against the sibling repos'
|
|
28
|
+
# nags so all four do not land in #hq-dev in the same second.
|
|
29
|
+
- cron: '10 15 * * 1'
|
|
30
|
+
workflow_dispatch:
|
|
31
|
+
inputs:
|
|
32
|
+
threshold_hours:
|
|
33
|
+
description: 'Age of the OLDEST unreleased commit, in hours, before nagging (default 24)'
|
|
34
|
+
required: false
|
|
35
|
+
default: '24'
|
|
36
|
+
|
|
37
|
+
permissions:
|
|
38
|
+
contents: read
|
|
39
|
+
|
|
40
|
+
jobs:
|
|
41
|
+
nag:
|
|
42
|
+
name: Nag when main has unreleased commits
|
|
43
|
+
if: github.repository == 'indigoai-us/hq-cloud'
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
env:
|
|
46
|
+
SLACK_TOKEN: ${{ secrets.SLACK_BOT_TOKEN_RELEASE_MANAGER }}
|
|
47
|
+
SLACK_CHANNEL: '#hq-dev'
|
|
48
|
+
THRESHOLD: ${{ github.event.inputs.threshold_hours || '24' }}
|
|
49
|
+
steps:
|
|
50
|
+
- name: Check out full history and tags
|
|
51
|
+
uses: actions/checkout@v4
|
|
52
|
+
with:
|
|
53
|
+
ref: main
|
|
54
|
+
fetch-depth: 0
|
|
55
|
+
|
|
56
|
+
- name: Find unreleased commits on main and nag
|
|
57
|
+
run: |
|
|
58
|
+
set -euo pipefail
|
|
59
|
+
|
|
60
|
+
if [ -z "${SLACK_TOKEN:-}" ]; then
|
|
61
|
+
echo "::warning::SLACK_BOT_TOKEN_RELEASE_MANAGER is not set on this repository; cannot post the nag."
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
git fetch --force --tags --quiet origin main
|
|
66
|
+
|
|
67
|
+
# Newest stable tag reachable from main — display only.
|
|
68
|
+
LAST_TAG="$(git tag --list 'v*' --merged origin/main --sort=-v:refname \
|
|
69
|
+
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)"
|
|
70
|
+
|
|
71
|
+
# Commits on main not reachable from ANY stable tag. `--exclude` applies
|
|
72
|
+
# to the `--tags` glob that follows it, so `v*-*` drops every prerelease.
|
|
73
|
+
git log origin/main --format='%H%x09%ct%x09%s' \
|
|
74
|
+
--not --exclude='v*-*' --tags='v*' > /tmp/unreleased_all.tsv
|
|
75
|
+
|
|
76
|
+
# Drop the post-tag release sync-back commits.
|
|
77
|
+
awk -F'\t' '$3 !~ /^chore\(release\)/' /tmp/unreleased_all.tsv > /tmp/unreleased.tsv
|
|
78
|
+
|
|
79
|
+
COUNT="$(wc -l < /tmp/unreleased.tsv | tr -d ' ')"
|
|
80
|
+
echo "Last stable tag: ${LAST_TAG:-<none>}"
|
|
81
|
+
echo "Unreleased commits on main: ${COUNT}"
|
|
82
|
+
|
|
83
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
84
|
+
echo "main is fully released. Nothing to nag about."
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
OLDEST_TS="$(tail -1 /tmp/unreleased.tsv | cut -f2)"
|
|
89
|
+
NOW="$(date -u +%s)"
|
|
90
|
+
AGE_H=$(( (NOW - OLDEST_TS) / 3600 ))
|
|
91
|
+
echo "Oldest unreleased commit age: ${AGE_H}h (threshold ${THRESHOLD}h)"
|
|
92
|
+
|
|
93
|
+
if [ "$AGE_H" -lt "$THRESHOLD" ]; then
|
|
94
|
+
echo "Under threshold. Skipping."
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
REPO_NAME="${GITHUB_REPOSITORY##*/}"
|
|
99
|
+
DAYS=$(( AGE_H / 24 ))
|
|
100
|
+
if [ -n "$LAST_TAG" ]; then
|
|
101
|
+
LINK="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...main"
|
|
102
|
+
TAG_LABEL="\`${LAST_TAG}\`"
|
|
103
|
+
else
|
|
104
|
+
LINK="https://github.com/${GITHUB_REPOSITORY}/commits/main"
|
|
105
|
+
TAG_LABEL="_never released_"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# Oldest-first sample of what is waiting, capped at 5 lines.
|
|
109
|
+
SAMPLE="$(tail -5 /tmp/unreleased.tsv | tac | cut -f3 | sed 's/^/• /')"
|
|
110
|
+
if [ "$COUNT" -gt 5 ]; then
|
|
111
|
+
SAMPLE="${SAMPLE}"$'\n'"• …and $(( COUNT - 5 )) more"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
TEXT=":package: *\`${REPO_NAME}\`: ${COUNT} commit(s) have been sitting unreleased on \`main\` for ~${AGE_H}h (${DAYS}d).* Last stable release: ${TAG_LABEL} — prereleases don't count. Shipping is one command: \`git push origin v<next>\`. <${LINK}|See what's waiting>."$'\n'"${SAMPLE}"
|
|
115
|
+
|
|
116
|
+
RESP="$(curl -s -X POST \
|
|
117
|
+
-H "Authorization: Bearer ${SLACK_TOKEN}" \
|
|
118
|
+
-H 'Content-type: application/json; charset=utf-8' \
|
|
119
|
+
--data "$(jq -nc --arg c "$SLACK_CHANNEL" --arg t "$TEXT" '{channel:$c,text:$t,unfurl_links:false}')" \
|
|
120
|
+
https://slack.com/api/chat.postMessage)"
|
|
121
|
+
OK="$(echo "$RESP" | jq -r '.ok')"
|
|
122
|
+
echo "Slack post ok=${OK} err=$(echo "$RESP" | jq -r '.error // "-"')"
|
|
123
|
+
if [ "$OK" != "true" ]; then
|
|
124
|
+
echo "::error::Slack rejected the nag: $(echo "$RESP" | jq -r '.error // "unknown"')"
|
|
125
|
+
exit 1
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
pin-lag:
|
|
129
|
+
name: Nag when consumers lag the latest hq-cloud release
|
|
130
|
+
# hq-cloud ships the sync runner that hq-cli and hq-desktop-app spawn. A
|
|
131
|
+
# release nobody has pinned is a release nobody is running, so this job
|
|
132
|
+
# compares the newest STABLE hq-cloud tag against the version each
|
|
133
|
+
# consumer's `main` actually declares:
|
|
134
|
+
#
|
|
135
|
+
# hq-cli pnpm-lock.yaml -> resolved @indigoai-us/hq-cloud version
|
|
136
|
+
# hq-desktop-app crates/hq-desktop-core/src/hq_cloud.rs -> HQ_CLOUD_VERSION
|
|
137
|
+
#
|
|
138
|
+
# Comparison is EXACT on the version string (the desktop pin's leading `~`
|
|
139
|
+
# is stripped first). A tilde range like `~6.14.47` does resolve to 6.14.48
|
|
140
|
+
# at npx spawn time, so this will flag a pin that is already functionally
|
|
141
|
+
# current — that is deliberate: the written pin is kept literally in step
|
|
142
|
+
# with the latest release.
|
|
143
|
+
#
|
|
144
|
+
# Reading the two sibling repos needs a cross-repo token, so this mints an
|
|
145
|
+
# installation token from the hq-audit-bot App (same pattern as
|
|
146
|
+
# hq-core's promote-to-hq-core.yml). The App must be installed on
|
|
147
|
+
# indigoai-us/hq-cli and indigoai-us/hq-desktop-app with `contents: read`.
|
|
148
|
+
if: github.repository == 'indigoai-us/hq-cloud'
|
|
149
|
+
runs-on: ubuntu-latest
|
|
150
|
+
env:
|
|
151
|
+
SLACK_TOKEN: ${{ secrets.SLACK_BOT_TOKEN_RELEASE_MANAGER }}
|
|
152
|
+
SLACK_CHANNEL: '#hq-dev'
|
|
153
|
+
HQ_AUDIT_BOT_APP_ID: ${{ secrets.HQ_AUDIT_BOT_APP_ID }}
|
|
154
|
+
HQ_AUDIT_BOT_PRIVATE_KEY: ${{ secrets.HQ_AUDIT_BOT_PRIVATE_KEY }}
|
|
155
|
+
steps:
|
|
156
|
+
- name: Check out full history and tags
|
|
157
|
+
uses: actions/checkout@v4
|
|
158
|
+
with:
|
|
159
|
+
ref: main
|
|
160
|
+
fetch-depth: 0
|
|
161
|
+
|
|
162
|
+
- name: Preflight credentials
|
|
163
|
+
id: pre
|
|
164
|
+
run: |
|
|
165
|
+
set -euo pipefail
|
|
166
|
+
if [ -z "${SLACK_TOKEN:-}" ]; then
|
|
167
|
+
echo "::warning::SLACK_BOT_TOKEN_RELEASE_MANAGER is not set on this repository; cannot post the nag."
|
|
168
|
+
echo "ready=false" >> "$GITHUB_OUTPUT"
|
|
169
|
+
exit 0
|
|
170
|
+
fi
|
|
171
|
+
if [ -z "${HQ_AUDIT_BOT_APP_ID:-}" ] || [ -z "${HQ_AUDIT_BOT_PRIVATE_KEY:-}" ]; then
|
|
172
|
+
echo "::warning::hq-audit-bot App credentials are not set; cannot read hq-cli / hq-desktop-app main."
|
|
173
|
+
echo "ready=false" >> "$GITHUB_OUTPUT"
|
|
174
|
+
exit 0
|
|
175
|
+
fi
|
|
176
|
+
echo "ready=true" >> "$GITHUB_OUTPUT"
|
|
177
|
+
|
|
178
|
+
- name: Mint installation token for the consumer repos
|
|
179
|
+
id: app-token
|
|
180
|
+
if: steps.pre.outputs.ready == 'true'
|
|
181
|
+
uses: actions/create-github-app-token@v1
|
|
182
|
+
with:
|
|
183
|
+
app-id: ${{ env.HQ_AUDIT_BOT_APP_ID }}
|
|
184
|
+
private-key: ${{ env.HQ_AUDIT_BOT_PRIVATE_KEY }}
|
|
185
|
+
owner: indigoai-us
|
|
186
|
+
repositories: |
|
|
187
|
+
hq-cli
|
|
188
|
+
hq-desktop-app
|
|
189
|
+
|
|
190
|
+
- name: Compare consumer pins against the latest hq-cloud release
|
|
191
|
+
if: steps.pre.outputs.ready == 'true'
|
|
192
|
+
env:
|
|
193
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
194
|
+
run: |
|
|
195
|
+
set -euo pipefail
|
|
196
|
+
|
|
197
|
+
git fetch --force --tags --quiet origin main
|
|
198
|
+
|
|
199
|
+
LATEST_TAG="$(git tag --list 'v*' --merged origin/main --sort=-v:refname \
|
|
200
|
+
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)"
|
|
201
|
+
if [ -z "$LATEST_TAG" ]; then
|
|
202
|
+
echo "No stable hq-cloud release yet; nothing to compare against."
|
|
203
|
+
exit 0
|
|
204
|
+
fi
|
|
205
|
+
LATEST="${LATEST_TAG#v}"
|
|
206
|
+
echo "Latest stable hq-cloud release: ${LATEST}"
|
|
207
|
+
|
|
208
|
+
# --- hq-cli: resolved version in the lockfile's importers section ---
|
|
209
|
+
gh api "repos/indigoai-us/hq-cli/contents/pnpm-lock.yaml?ref=main" \
|
|
210
|
+
-H 'Accept: application/vnd.github.raw' > /tmp/hq-cli-lock.yaml
|
|
211
|
+
CLI_PIN="$(grep -A2 "^ '@indigoai-us/hq-cloud':" /tmp/hq-cli-lock.yaml \
|
|
212
|
+
| awk '/^ version:/{print $2; exit}' | sed 's/(.*//')"
|
|
213
|
+
if [ -z "$CLI_PIN" ]; then
|
|
214
|
+
echo "::error::Could not read the resolved @indigoai-us/hq-cloud version from hq-cli's pnpm-lock.yaml. The lockfile layout changed — fix this parser rather than letting the check pass silently."
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
echo "hq-cli pins: ${CLI_PIN}"
|
|
218
|
+
|
|
219
|
+
# --- hq-desktop-app: HQ_CLOUD_VERSION constant, tilde stripped ---
|
|
220
|
+
gh api "repos/indigoai-us/hq-desktop-app/contents/crates/hq-desktop-core/src/hq_cloud.rs?ref=main" \
|
|
221
|
+
-H 'Accept: application/vnd.github.raw' > /tmp/hq_cloud.rs
|
|
222
|
+
DESK_RAW="$(grep -oP 'HQ_CLOUD_VERSION\s*:\s*&str\s*=\s*"\K[^"]+' /tmp/hq_cloud.rs | head -1)"
|
|
223
|
+
if [ -z "$DESK_RAW" ]; then
|
|
224
|
+
echo "::error::Could not read HQ_CLOUD_VERSION from hq-desktop-app's crates/hq-desktop-core/src/hq_cloud.rs. The constant moved or was renamed — fix this parser rather than letting the check pass silently."
|
|
225
|
+
exit 1
|
|
226
|
+
fi
|
|
227
|
+
DESK_PIN="${DESK_RAW#\~}"
|
|
228
|
+
DESK_PIN="${DESK_PIN#^}"
|
|
229
|
+
echo "hq-desktop-app pins: ${DESK_RAW} (compared as ${DESK_PIN})"
|
|
230
|
+
|
|
231
|
+
LAGGARDS=""
|
|
232
|
+
if [ "$CLI_PIN" != "$LATEST" ]; then
|
|
233
|
+
LAGGARDS="${LAGGARDS}"$'\n'"• \`hq-cli\` is on \`${CLI_PIN}\` (pnpm-lock.yaml)"
|
|
234
|
+
fi
|
|
235
|
+
if [ "$DESK_PIN" != "$LATEST" ]; then
|
|
236
|
+
LAGGARDS="${LAGGARDS}"$'\n'"• \`hq-desktop-app\` is on \`${DESK_RAW}\` (HQ_CLOUD_VERSION)"
|
|
237
|
+
fi
|
|
238
|
+
|
|
239
|
+
if [ -z "$LAGGARDS" ]; then
|
|
240
|
+
echo "Both consumers are pinned to ${LATEST}. Nothing to nag about."
|
|
241
|
+
exit 0
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
TEXT=":link: *hq-cloud \`${LATEST}\` is released but not pinned everywhere.* A runner nobody pins is a runner nobody runs:${LAGGARDS}"$'\n'"Bump the pin(s) to \`${LATEST}\` and ship. <https://github.com/indigoai-us/hq-cloud/releases/tag/${LATEST_TAG}|Release ${LATEST_TAG}>."
|
|
245
|
+
|
|
246
|
+
RESP="$(curl -s -X POST \
|
|
247
|
+
-H "Authorization: Bearer ${SLACK_TOKEN}" \
|
|
248
|
+
-H 'Content-type: application/json; charset=utf-8' \
|
|
249
|
+
--data "$(jq -nc --arg c "$SLACK_CHANNEL" --arg t "$TEXT" '{channel:$c,text:$t,unfurl_links:false}')" \
|
|
250
|
+
https://slack.com/api/chat.postMessage)"
|
|
251
|
+
OK="$(echo "$RESP" | jq -r '.ok')"
|
|
252
|
+
echo "Slack post ok=${OK} err=$(echo "$RESP" | jq -r '.error // "-"')"
|
|
253
|
+
if [ "$OK" != "true" ]; then
|
|
254
|
+
echo "::error::Slack rejected the nag: $(echo "$RESP" | jq -r '.error // "unknown"')"
|
|
255
|
+
exit 1
|
|
256
|
+
fi
|
|
@@ -10,9 +10,21 @@ import type { Direction, RunnerLoopDeps, RunnerPassOutcome } from "./sync-runner
|
|
|
10
10
|
* can be unit-tested without a real host or a 10-minute wait.
|
|
11
11
|
*/
|
|
12
12
|
export declare function adaptivePollMs(load1: number, cpuCount: number, floorMs?: number, ceilMs?: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* Advance the cadence from its previous monotonic deadline, rather than from
|
|
15
|
+
* the end of a pass. A slow pass is allowed one immediate catch-up pass; the
|
|
16
|
+
* returned deadline is always the first future slot, so missed slots cannot
|
|
17
|
+
* turn into an unbounded burst.
|
|
18
|
+
*/
|
|
19
|
+
export declare function advanceMonotonicDeadline(previousDueAt: number, now: number, intervalMs: number): {
|
|
20
|
+
nextDueAt: number;
|
|
21
|
+
delayMs: number;
|
|
22
|
+
};
|
|
13
23
|
export interface ParsedLoopArgs {
|
|
14
24
|
hqRoot: string;
|
|
15
25
|
lockTimeoutSec?: number;
|
|
26
|
+
/** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */
|
|
27
|
+
skipPersonal?: boolean;
|
|
16
28
|
/**
|
|
17
29
|
* The run's resolved sync direction, straight from `parseArgs` (the single
|
|
18
30
|
* source of truth — same default as the parser). The drain reads this to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-runner-watch-loop.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner-watch-loop.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sync-runner-watch-loop.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner-watch-loop.ts"],"names":[],"mappings":"AAyBA,OAAO,EAIL,WAAW,EAEZ,MAAM,eAAe,CAAC;AAWvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAW9D,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,iBAAiB,EAElB,MAAM,kBAAkB,CAAC;AAiB1B;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,MAA+B,EACxC,MAAM,GAAE,MAA8B,GACrC,MAAM,CAKR;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,aAAa,EAAE,MAAM,EACrB,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GACjB;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAYxC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kFAAkF;IAClF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC,EAAE,CACnC,QAAQ,EAAE,MAAM,EAAE,KACf,OAAO,CAAC,MAAM,GAAG,iBAAiB,CAAC,CAAC;IACzC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,uBAAuB,EAAE,MAAM,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,eAAe,CAAC,GAAG,IAAI,CAAC;IAC7E,qBAAqB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,OAAO,CAAC,CAAC;AAE5D;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,WAAW,GAC3B,KAAK,CAAC,CAAC,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,CASvC;AAWD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oCAAoC,CAClD,eAAe,EAAE,MAAM,EACvB,cAAc,EAAE,MAAM,GACrB,OAAO,CAIT;AAuBD,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,CA49BjB"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as os from "os";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as fs from "fs";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
4
5
|
import { inventoryManagedSnapshots } from "../backup-prune.js";
|
|
5
6
|
import { VaultClient } from "../index.js";
|
|
6
7
|
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
@@ -14,7 +15,7 @@ import { TreeWatcher, systemClock, } from "../watcher.js";
|
|
|
14
15
|
import { clearLocalDeleteIntent, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, readJournal, writeJournal, } from "../journal.js";
|
|
15
16
|
import { NoopPushReceiver, } from "../sync/push-receiver.js";
|
|
16
17
|
import { resolveEventSync, startEventSync as defaultStartEventSync, } from "../sync/event-sync.js";
|
|
17
|
-
import { buildScopedDrainPullArgv, buildScopedPushArgv, buildTargetedPullArgv, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
|
|
18
|
+
import { buildScopedDrainPullArgv, buildScopedPushArgv, buildTargetedPullArgv, isWatchRouteSelected, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
|
|
18
19
|
import { resolveSkipCompanies } from "./sync-runner-planning.js";
|
|
19
20
|
const FULL_RECONCILE_EVERY_TICKS = 10;
|
|
20
21
|
/**
|
|
@@ -43,6 +44,24 @@ export function adaptivePollMs(load1, cpuCount, floorMs = ADAPTIVE_POLL_FLOOR_MS
|
|
|
43
44
|
const ratio = Math.max(0, Math.min(1, saturation));
|
|
44
45
|
return Math.round(floorMs + ratio * (ceilMs - floorMs));
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Advance the cadence from its previous monotonic deadline, rather than from
|
|
49
|
+
* the end of a pass. A slow pass is allowed one immediate catch-up pass; the
|
|
50
|
+
* returned deadline is always the first future slot, so missed slots cannot
|
|
51
|
+
* turn into an unbounded burst.
|
|
52
|
+
*/
|
|
53
|
+
export function advanceMonotonicDeadline(previousDueAt, now, intervalMs) {
|
|
54
|
+
let nextDueAt = previousDueAt + intervalMs;
|
|
55
|
+
if (nextDueAt > now) {
|
|
56
|
+
return { nextDueAt, delayMs: nextDueAt - now };
|
|
57
|
+
}
|
|
58
|
+
// The pass crossed at least one deadline. Run exactly one catch-up now,
|
|
59
|
+
// then retain only the first future cadence slot.
|
|
60
|
+
do {
|
|
61
|
+
nextDueAt += intervalMs;
|
|
62
|
+
} while (nextDueAt <= now);
|
|
63
|
+
return { nextDueAt, delayMs: 0 };
|
|
64
|
+
}
|
|
46
65
|
/**
|
|
47
66
|
* Select journal entries covered by one watcher delete. Exact file unlinks
|
|
48
67
|
* deliberately avoid enumeration; only directory unlinks expand descendants.
|
|
@@ -116,6 +135,7 @@ export async function runOneShotWithOperationLock(argv, parsed, deps, runtime) {
|
|
|
116
135
|
export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
117
136
|
const sleep = deps.sleep ??
|
|
118
137
|
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
138
|
+
const monotonicNow = deps.monotonicNow ?? (() => performance.now());
|
|
119
139
|
const runPass = deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
|
|
120
140
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
121
141
|
// An explicit --poll-remote-ms pins a fixed interval (menubar/CLI still set
|
|
@@ -126,6 +146,21 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
126
146
|
const cpuCount = os.cpus().length;
|
|
127
147
|
const eventPush = argv.includes("--event-push");
|
|
128
148
|
const companiesMode = argv.includes("--companies");
|
|
149
|
+
const companyMode = argv.includes("--company");
|
|
150
|
+
const personalMode = argv.includes("--personal");
|
|
151
|
+
// Keep this at the watcher ingress rather than importing the runner's
|
|
152
|
+
// resolver: sync-runner imports this loop, and a reverse value import would
|
|
153
|
+
// create a runtime cycle. These are the parser's documented truthy values.
|
|
154
|
+
const skipPersonalEnv = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
155
|
+
const watchRouteSelection = {
|
|
156
|
+
companies: companiesMode,
|
|
157
|
+
company: companyMode,
|
|
158
|
+
personal: personalMode,
|
|
159
|
+
skipPersonal: parsed.skipPersonal === true ||
|
|
160
|
+
skipPersonalEnv === "1" ||
|
|
161
|
+
skipPersonalEnv === "true" ||
|
|
162
|
+
skipPersonalEnv === "yes",
|
|
163
|
+
};
|
|
129
164
|
const hqRoot = parsed.hqRoot;
|
|
130
165
|
// Delayed and daily: this must never be attached to the 15s poll cadence.
|
|
131
166
|
// Stage 0 remains dry-run only; the standalone hq-backup-prune command is
|
|
@@ -220,6 +255,45 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
220
255
|
}
|
|
221
256
|
};
|
|
222
257
|
let stopped = false;
|
|
258
|
+
let resolveStopped = null;
|
|
259
|
+
const stoppedSignal = new Promise((resolve) => {
|
|
260
|
+
resolveStopped = resolve;
|
|
261
|
+
});
|
|
262
|
+
// A signal can arrive while a guarded pass is running, before the next wait
|
|
263
|
+
// has registered. Keep a monotonic generation so that event is retained and
|
|
264
|
+
// consumed by the next scheduler wait instead of being lost.
|
|
265
|
+
let interruptGeneration = 0;
|
|
266
|
+
let observedInterruptGeneration = 0;
|
|
267
|
+
let resolveInterruptWait = null;
|
|
268
|
+
const interruptWait = () => {
|
|
269
|
+
if (stopped)
|
|
270
|
+
return;
|
|
271
|
+
interruptGeneration += 1;
|
|
272
|
+
const resolve = resolveInterruptWait;
|
|
273
|
+
resolveInterruptWait = null;
|
|
274
|
+
resolve?.();
|
|
275
|
+
};
|
|
276
|
+
const waitForNextPass = async (delayMs) => {
|
|
277
|
+
if (interruptGeneration !== observedInterruptGeneration) {
|
|
278
|
+
observedInterruptGeneration = interruptGeneration;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
await Promise.race([
|
|
282
|
+
sleep(delayMs),
|
|
283
|
+
new Promise((resolve) => {
|
|
284
|
+
resolveInterruptWait = resolve;
|
|
285
|
+
// Do not lose an event that arrived between the first generation
|
|
286
|
+
// check and registration of this wait.
|
|
287
|
+
if (interruptGeneration !== observedInterruptGeneration) {
|
|
288
|
+
resolveInterruptWait = null;
|
|
289
|
+
resolve();
|
|
290
|
+
}
|
|
291
|
+
}),
|
|
292
|
+
stoppedSignal,
|
|
293
|
+
]);
|
|
294
|
+
resolveInterruptWait = null;
|
|
295
|
+
observedInterruptGeneration = interruptGeneration;
|
|
296
|
+
};
|
|
223
297
|
let activePass = null;
|
|
224
298
|
const pendingPasses = [];
|
|
225
299
|
const resolveStoppedQueue = () => {
|
|
@@ -269,11 +343,45 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
269
343
|
const pendingWatcherPaths = new Map();
|
|
270
344
|
const pendingWatcherChanges = new Map();
|
|
271
345
|
let pendingWatcherOriginalBatch = null;
|
|
346
|
+
// Every coalesced watcher batch gets one durable in-process generation.
|
|
347
|
+
// A wake can cause the scheduler to revisit a drained batch, but an event
|
|
348
|
+
// announcement is an external side effect and must occur at most once for
|
|
349
|
+
// that batch after its targeted push succeeds.
|
|
350
|
+
let nextWatcherBatchGeneration = 0;
|
|
351
|
+
let pendingWatcherBatchGeneration = null;
|
|
352
|
+
const lastPublishedWatcherBatchGenerationByRoute = new Map();
|
|
272
353
|
let pendingWatcherBareChange = false;
|
|
273
354
|
let pendingWatcherOverflowed = false;
|
|
274
355
|
let pendingWatcherDroppedPaths = 0;
|
|
275
356
|
let pendingWatcherDroppedBytes = 0;
|
|
276
357
|
const addPendingWatcherChange = (changedRelPath, batch) => {
|
|
358
|
+
if (batch) {
|
|
359
|
+
const paths = new Map();
|
|
360
|
+
const changes = new Map();
|
|
361
|
+
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
362
|
+
const route = routeChangeToTarget(relativePath);
|
|
363
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection))
|
|
364
|
+
continue;
|
|
365
|
+
paths.set(absolutePath, relativePath);
|
|
366
|
+
const change = batch.changes?.get(absolutePath);
|
|
367
|
+
if (change)
|
|
368
|
+
changes.set(absolutePath, change);
|
|
369
|
+
}
|
|
370
|
+
if (paths.size === 0)
|
|
371
|
+
return false;
|
|
372
|
+
batch = {
|
|
373
|
+
...batch,
|
|
374
|
+
paths,
|
|
375
|
+
changes,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
else if (changedRelPath) {
|
|
379
|
+
const route = routeChangeToTarget(changedRelPath);
|
|
380
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
|
|
277
385
|
if (batch) {
|
|
278
386
|
pendingWatcherOriginalBatch =
|
|
279
387
|
pendingWatcherPaths.size === 0 &&
|
|
@@ -293,7 +401,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
293
401
|
pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
|
|
294
402
|
pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
|
|
295
403
|
}
|
|
296
|
-
return;
|
|
404
|
+
return true;
|
|
297
405
|
}
|
|
298
406
|
if (changedRelPath) {
|
|
299
407
|
pendingWatcherOriginalBatch = null;
|
|
@@ -307,10 +415,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
307
415
|
// a prior batch left for this path so the stale intent cannot leak
|
|
308
416
|
// into the coalesced batch (#227).
|
|
309
417
|
pendingWatcherChanges.delete(absolutePath);
|
|
310
|
-
return;
|
|
418
|
+
return true;
|
|
311
419
|
}
|
|
312
420
|
pendingWatcherOriginalBatch = null;
|
|
313
421
|
pendingWatcherBareChange = true;
|
|
422
|
+
return true;
|
|
314
423
|
};
|
|
315
424
|
const takePendingWatcherChange = () => {
|
|
316
425
|
const batch = pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
|
|
@@ -331,6 +440,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
331
440
|
}
|
|
332
441
|
: null;
|
|
333
442
|
const bare = pendingWatcherBareChange;
|
|
443
|
+
const generation = pendingWatcherBatchGeneration;
|
|
334
444
|
pendingWatcherPaths.clear();
|
|
335
445
|
pendingWatcherChanges.clear();
|
|
336
446
|
pendingWatcherOriginalBatch = null;
|
|
@@ -338,9 +448,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
338
448
|
pendingWatcherOverflowed = false;
|
|
339
449
|
pendingWatcherDroppedPaths = 0;
|
|
340
450
|
pendingWatcherDroppedBytes = 0;
|
|
341
|
-
|
|
451
|
+
pendingWatcherBatchGeneration = null;
|
|
452
|
+
return { batch, bare, generation };
|
|
342
453
|
};
|
|
343
|
-
const restorePendingWatcherPaths = (paths) => {
|
|
454
|
+
const restorePendingWatcherPaths = (paths, generation) => {
|
|
455
|
+
pendingWatcherBatchGeneration ??=
|
|
456
|
+
generation ?? ++nextWatcherBatchGeneration;
|
|
344
457
|
pendingWatcherOriginalBatch = null;
|
|
345
458
|
for (const [absolutePath, relativePath] of paths.entries()) {
|
|
346
459
|
pendingWatcherPaths.set(absolutePath, relativePath);
|
|
@@ -382,7 +495,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
382
495
|
};
|
|
383
496
|
const journalCoordinates = (relativePath) => {
|
|
384
497
|
const route = routeChangeToTarget(relativePath);
|
|
385
|
-
if (!route)
|
|
498
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection))
|
|
386
499
|
return null;
|
|
387
500
|
if (route.kind === "personal") {
|
|
388
501
|
return { slug: PERSONAL_VAULT_JOURNAL_SLUG, key: relativePath };
|
|
@@ -572,9 +685,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
572
685
|
const publishBatchIfAvailable = (paths) => {
|
|
573
686
|
if (!stopped && eventSync && paths.size > 0) {
|
|
574
687
|
eventSync.publishBatch({ paths: new Map(paths) });
|
|
688
|
+
return true;
|
|
575
689
|
}
|
|
690
|
+
return false;
|
|
576
691
|
};
|
|
577
|
-
const runScopedDrain = async (batch) => {
|
|
692
|
+
const runScopedDrain = async (batch, watcherBatchGeneration) => {
|
|
578
693
|
if (drainRunsPush && batch && batch.paths.size > 0) {
|
|
579
694
|
const skipCompanies = resolveSkipCompanies();
|
|
580
695
|
const grouped = groupBatchByRoute(batch);
|
|
@@ -596,21 +711,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
596
711
|
: (result.result?.pushPathResults ?? [])
|
|
597
712
|
.filter((pathResult) => pathResult.status === "refused")
|
|
598
713
|
.map((pathResult) => pathResult.relativePath));
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
714
|
+
const route = routeKey(group.route);
|
|
715
|
+
const generation = watcherBatchGeneration ?? 0;
|
|
716
|
+
if (lastPublishedWatcherBatchGenerationByRoute.get(route) !== generation) {
|
|
717
|
+
const published = publishBatchIfAvailable(new Map([...group.paths.entries()].filter(([absolutePath, relativePath]) => {
|
|
718
|
+
if (refusedPaths.has(relativePath))
|
|
719
|
+
return false;
|
|
720
|
+
if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
return ![...refusedPaths].some((refusedPath) => refusedPath.startsWith(`${relativePath}/`));
|
|
724
|
+
})));
|
|
725
|
+
if (published) {
|
|
726
|
+
lastPublishedWatcherBatchGenerationByRoute.set(route, generation);
|
|
604
727
|
}
|
|
605
|
-
|
|
606
|
-
})));
|
|
728
|
+
}
|
|
607
729
|
}
|
|
608
730
|
else {
|
|
609
|
-
restorePendingWatcherPaths(group.paths);
|
|
731
|
+
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
610
732
|
}
|
|
611
733
|
}
|
|
612
734
|
catch (err) {
|
|
613
|
-
restorePendingWatcherPaths(group.paths);
|
|
735
|
+
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
614
736
|
process.stderr.write(`watch scoped push failed, will retry next tick: ${describeError(err)}\n`);
|
|
615
737
|
}
|
|
616
738
|
}
|
|
@@ -654,12 +776,18 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
654
776
|
watcher.onChange((changedRelPath, batch) => {
|
|
655
777
|
if (stopped)
|
|
656
778
|
return;
|
|
657
|
-
addPendingWatcherChange(changedRelPath, batch)
|
|
779
|
+
if (addPendingWatcherChange(changedRelPath, batch))
|
|
780
|
+
interruptWait();
|
|
658
781
|
});
|
|
659
782
|
watcher.start();
|
|
660
783
|
const receiverSyncFn = async (ctx) => {
|
|
661
784
|
if (stopped)
|
|
662
785
|
return;
|
|
786
|
+
// A received wake/high-water signal means there is bounded work now.
|
|
787
|
+
// It wakes the scheduler but is still funneled through runGuarded below,
|
|
788
|
+
// so it cannot overlap the pass that may currently own the operation
|
|
789
|
+
// lock.
|
|
790
|
+
interruptWait();
|
|
663
791
|
const route = routeChangeToTarget(ctx.event.relativePath);
|
|
664
792
|
if (!route)
|
|
665
793
|
return;
|
|
@@ -672,8 +800,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
672
800
|
const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
|
|
673
801
|
receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
|
|
674
802
|
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
675
|
-
|
|
676
|
-
if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
|
|
803
|
+
if (resolveEventSync(process.env.HQ_SYNC_EVENT_SYNC)) {
|
|
677
804
|
const startES = deps.startEventSync ?? defaultStartEventSync;
|
|
678
805
|
void (async () => {
|
|
679
806
|
const eventSyncClient = new VaultClient({
|
|
@@ -711,10 +838,6 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
711
838
|
});
|
|
712
839
|
}
|
|
713
840
|
}
|
|
714
|
-
let resolveStopped = null;
|
|
715
|
-
const stoppedSignal = new Promise((resolve) => {
|
|
716
|
-
resolveStopped = resolve;
|
|
717
|
-
});
|
|
718
841
|
const shutdown = () => {
|
|
719
842
|
if (stopped)
|
|
720
843
|
return;
|
|
@@ -752,6 +875,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
752
875
|
};
|
|
753
876
|
});
|
|
754
877
|
detachSignal = onShutdownSignal(shutdown);
|
|
878
|
+
// The initial reconcile is due immediately. Later passes advance from this
|
|
879
|
+
// deadline, not from pass completion, so cadence stays monotonic.
|
|
880
|
+
let nextDueAt = monotonicNow();
|
|
755
881
|
let pollTick = 0;
|
|
756
882
|
try {
|
|
757
883
|
while (!stopped) {
|
|
@@ -772,7 +898,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
772
898
|
pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
|
|
773
899
|
const result = shouldRunFull
|
|
774
900
|
? await runGuarded(passArgv)
|
|
775
|
-
: await runScopedDrain(pending.batch);
|
|
901
|
+
: await runScopedDrain(pending.batch, pending.generation);
|
|
776
902
|
// This batch's episode has now been prepared and pushed, so a later
|
|
777
903
|
// removal under the same path is judged fresh — a root that never
|
|
778
904
|
// expired would silently refuse ordinary deletes from here on.
|
|
@@ -829,7 +955,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
829
955
|
}
|
|
830
956
|
}
|
|
831
957
|
const nextPollMs = explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
|
|
832
|
-
|
|
958
|
+
const schedule = advanceMonotonicDeadline(nextDueAt, monotonicNow(), nextPollMs);
|
|
959
|
+
nextDueAt = schedule.nextDueAt;
|
|
960
|
+
await waitForNextPass(schedule.delayMs);
|
|
833
961
|
}
|
|
834
962
|
return 0;
|
|
835
963
|
}
|