grix-connector 4.1.0 → 4.2.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.
@@ -164,11 +164,37 @@ function isPathInside(parent, child) {
164
164
  return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel);
165
165
  }
166
166
 
167
+ /**
168
+ * 事务自述的激活策略。guardian 按它分支而不看运行平台:
169
+ * `wrapper-runtime` 是 Windows 现状(wrapper 承接、切 per-root runtime);
170
+ * `service-start` 是 macOS——切包后运行目标 CLI `start` 刷新受管 runtime 并重启
171
+ * 服务,不做 runtime 目录 rename,也不需要杀进程(`start` 自己完成服务重启)。
172
+ * 历史事务没有该字段,视为 `wrapper-runtime`。
173
+ */
174
+ export function transactionStrategy(pending) {
175
+ return pending?.activation_strategy === 'service-start' ? 'service-start' : 'wrapper-runtime';
176
+ }
177
+
178
+ function defaultRunServiceStart(cliPath) {
179
+ execFileSync(process.execPath, [cliPath, 'start'], {
180
+ timeout: 180_000,
181
+ windowsHide: true,
182
+ stdio: 'ignore',
183
+ });
184
+ }
185
+
167
186
  export function validateTransaction(pending) {
168
187
  if (!pending || typeof pending !== 'object') throw new Error('pending transaction is missing');
169
188
  if (typeof pending.phase !== 'string' || !TRANSACTION_PHASES.has(pending.phase)) {
170
189
  throw new Error(`unknown upgrade transaction phase: ${String(pending.phase)}`);
171
190
  }
191
+ if (
192
+ pending.activation_strategy !== undefined
193
+ && pending.activation_strategy !== 'wrapper-runtime'
194
+ && pending.activation_strategy !== 'service-start'
195
+ ) {
196
+ throw new Error(`unknown upgrade activation strategy: ${String(pending.activation_strategy)}`);
197
+ }
172
198
  for (const key of ['transaction_id', 'from_version', 'target_version', 'npm_prefix', 'npm_lock_file', 'package_dir', 'backup_dir', 'staged_package_dir', 'staging_prefix']) {
173
199
  if (typeof pending[key] !== 'string' || pending[key].trim() === '') {
174
200
  throw new Error(`pending transaction is missing ${key}`);
@@ -762,6 +788,11 @@ async function restorePreviousVersion(rootDir, pending, deps = {}) {
762
788
  writeJsonAtomic(paths.pause, { transaction_id: pending.transaction_id });
763
789
  updatePending(paths.pending, { phase: 'rolling_back', error_code: 'STARTUP_CRASH' });
764
790
 
791
+ if (transactionStrategy(pending) === 'service-start') {
792
+ await restorePreviousVersionByServiceStart(rootDir, pending, paths, deps);
793
+ return;
794
+ }
795
+
765
796
  const daemonStatus = await (deps.readDaemonStatus ?? readDaemonStatus)(rootDir).catch(() => null);
766
797
  const daemonLock = await (deps.readDaemonLock ?? readDaemonLock)(rootDir).catch(() => null);
767
798
  const wrapperChild = await (deps.readWrapperChild ?? readWrapperChild)(rootDir).catch(() => null);
@@ -837,6 +868,39 @@ async function restorePreviousVersion(rootDir, pending, deps = {}) {
837
868
  appendLog(paths.log, `rolled back managed runtime to ${pending.from_version}; global package is ${packageVersion(pending.package_dir)}`);
838
869
  }
839
870
 
871
+ /**
872
+ * service-start 策略的回滚:daemon 跑的是受管 runtime 拷贝,不占用全局包目录,
873
+ * 包可以在服务运行中原子换回;随后由恢复出的旧版 CLI `start` 刷新 runtime 并
874
+ * 重启服务——它自己会替换正在运行的实例,无需在这里识别并杀进程。
875
+ */
876
+ async function restorePreviousVersionByServiceStart(rootDir, pending, paths, deps = {}) {
877
+ const packageSourceVersion = pending.package_from_version ?? pending.from_version;
878
+ const packageWasSwitched = pending.package_activation_mode === 'switched'
879
+ || (pending.package_activation_mode === undefined && packageSourceVersion !== pending.target_version);
880
+ const needsPackageRestore = packageWasSwitched
881
+ && packageVersion(pending.package_dir) !== packageSourceVersion;
882
+ if (needsPackageRestore && packageVersion(pending.backup_dir) !== packageSourceVersion) {
883
+ throw new Error('previous package backup is missing or invalid');
884
+ }
885
+ if (needsPackageRestore) {
886
+ await restoreBackupTree(pending.package_dir, pending.backup_dir, deps);
887
+ }
888
+ validatePackage(
889
+ pending.package_dir,
890
+ packageWasSwitched ? packageSourceVersion : pending.target_version,
891
+ );
892
+ const restoredCli = join(pending.package_dir, 'dist', 'grix.js');
893
+ if (!existsSync(restoredCli)) throw new Error(`restored package CLI is missing: ${restoredCli}`);
894
+ // 激活失败必须让事务落 failed:包虽已恢复,但受管 runtime 仍是目标版本,
895
+ // 不刷新就宣告 rolled_back 会让 launchd 继续跑没人守护的坏版本。
896
+ (deps.runServiceStart ?? defaultRunServiceStart)(restoredCli);
897
+
898
+ updatePending(paths.pending, { phase: 'rolled_back', error_code: 'STARTUP_CRASH' });
899
+ rmSync(paths.pause, { force: true });
900
+ clearRollbackIntent(paths, pending.transaction_id);
901
+ appendLog(paths.log, `service-start rollback restored ${packageVersion(pending.package_dir)} and reactivated the managed service`);
902
+ }
903
+
840
904
  async function launchVerificationMonitor(rootDir, pending, paths, injected) {
841
905
  const current = validateTransaction(readJson(paths.pending));
842
906
  if (monitorIdentityAlive(current, paths, injected)) {
@@ -951,7 +1015,11 @@ export async function activateWindowsUpgrade(rootDir, injected = {}) {
951
1015
  if (pending.phase === 'verifying') {
952
1016
  try {
953
1017
  validatePackage(pending.package_dir, pending.target_version);
954
- validatePackage(pending.runtime_dir ?? runtimeDir, pending.target_version);
1018
+ // service-start 策略没有 guardian 管理的 runtime 目录,健康与否由 monitor
1019
+ // 按 healthz/daemon-status 裁决即可。
1020
+ if (transactionStrategy(pending) === 'wrapper-runtime') {
1021
+ validatePackage(pending.runtime_dir ?? runtimeDir, pending.target_version);
1022
+ }
955
1023
  return await launchVerificationMonitor(rootDir, pending, paths, injected);
956
1024
  } catch (error) {
957
1025
  appendLog(paths.log, `verification resume failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -972,8 +1040,15 @@ export async function activateWindowsUpgrade(rootDir, injected = {}) {
972
1040
  try {
973
1041
  updatePending(paths.pending, { phase: 'activating', guardian_pid: process.pid });
974
1042
  await switchPackage(pending, { ...injected, pendingFile: paths.pending });
975
- const current = validateTransaction(readJson(paths.pending));
976
- await deployManagedRuntime(current, runtimeDir, { ...injected, pendingFile: paths.pending });
1043
+ if (transactionStrategy(pending) === 'wrapper-runtime') {
1044
+ const current = validateTransaction(readJson(paths.pending));
1045
+ await deployManagedRuntime(current, runtimeDir, { ...injected, pendingFile: paths.pending });
1046
+ } else {
1047
+ // service-start:已切换的目标 CLI `start` 会刷新受管 runtime 副本、重写
1048
+ // 服务定义并重启服务(替换 launchd 拉起的旧 runtime 实例)。失败视同激活
1049
+ // 失败,走下方回滚。
1050
+ (injected.runServiceStart ?? defaultRunServiceStart)(join(pending.package_dir, 'dist', 'grix.js'));
1051
+ }
977
1052
  try { rmSync(pending.staging_prefix, { recursive: true, force: true }); } catch { /* best effort */ }
978
1053
  return await launchVerificationMonitor(rootDir, pending, paths, injected);
979
1054
  } catch (error) {
@@ -1,14 +1,15 @@
1
1
  #!/bin/bash
2
2
  # upgrade-guardian.sh — post-upgrade health monitor
3
3
  # Started by daemon after npm install, runs as detached background process.
4
- # Monitors healthz for 90s, rolls back on crash_count >= 3.
4
+ # Monitors healthz for 90s and rolls back on failure. The decision is
5
+ # single-run: nothing relaunches this guardian after it exits, so any
6
+ # crash-count threshold above one run could never be reached.
5
7
  # This file is NOT inside the npm package — installed once to ~/.grix/bin/.
6
8
 
7
9
  GRIX_HOME="${GRIX_CONNECTOR_HOME:-$HOME/.grix}"
8
10
  PENDING_FILE="$GRIX_HOME/data/upgrade-pending.json"
9
11
  LOCK_FILE="$GRIX_HOME/upgrade-guardian.lock"
10
12
  LOG_FILE="$GRIX_HOME/log/upgrade.log"
11
- MAX_CRASHES=3
12
13
  HEALTH_TIMEOUT=90
13
14
  HEALTH_PORT_FILE="$GRIX_HOME/data/health-port"
14
15
  HEALTH_PORT=$(cat "$HEALTH_PORT_FILE" 2>/dev/null || echo "19579")
@@ -38,22 +39,43 @@ if [ ! -f "$PENDING_FILE" ]; then
38
39
  exit 0
39
40
  fi
40
41
 
41
- # Extract fields from JSON (portable: no jq dependency)
42
- FROM_VERSION=$(cat "$PENDING_FILE" | grep -o '"from_version":"[^"]*"' | cut -d'"' -f4)
43
- TARGET_VERSION=$(cat "$PENDING_FILE" | grep -o '"target_version":"[^"]*"' | cut -d'"' -f4)
44
- UPGRADED_AT=$(cat "$PENDING_FILE" | grep -o '"upgraded_at":"[^"]*"' | cut -d'"' -f4)
45
- CRASH_COUNT=$(cat "$PENDING_FILE" | grep -o '"crash_count":[0-9]*' | cut -d: -f2)
46
- CRASH_COUNT=${CRASH_COUNT:-0}
47
- PACKAGE_DIR=$(cat "$PENDING_FILE" | grep -o '"package_dir":"[^"]*"' | cut -d'"' -f4)
48
- BACKUP_DIR=$(cat "$PENDING_FILE" | grep -o '"backup_dir":"[^"]*"' | cut -d'"' -f4)
42
+ # 用 node 解析 pending:grep/cut 拼 JSON 对转义和 schema 演进都脆弱,而 guardian
43
+ # 的激活与回滚本来就依赖 node,可用性等价。输出为单引号安全的 shell 赋值。
44
+ PENDING_VARS=$(node -e '
45
+ const f = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
46
+ const q = (v) => "\x27" + String(v ?? "").replace(/\x27/g, "\x27\\\x27\x27") + "\x27";
47
+ process.stdout.write([
48
+ "FROM_VERSION=" + q(f.from_version),
49
+ "TARGET_VERSION=" + q(f.target_version),
50
+ "CRASH_COUNT=" + q(f.crash_count ?? 0),
51
+ "PACKAGE_DIR=" + q(f.package_dir),
52
+ "BACKUP_DIR=" + q(f.backup_dir),
53
+ ].join("\n"));
54
+ ' "$PENDING_FILE" 2>> "$LOG_FILE") || PENDING_VARS=""
55
+ if [ -z "$PENDING_VARS" ]; then
56
+ log "pending file corrupt or unreadable, removing"
57
+ rm -f "$PENDING_FILE"
58
+ exit 0
59
+ fi
60
+ eval "$PENDING_VARS"
49
61
 
50
62
  # Validate pending file
51
- if [ -z "$FROM_VERSION" ]; then
52
- log "pending file corrupt, missing from_version, removing"
63
+ if [ -z "$FROM_VERSION" ] || [ -z "$TARGET_VERSION" ]; then
64
+ log "pending file corrupt, missing from_version/target_version, removing"
53
65
  rm -f "$PENDING_FILE"
54
66
  exit 0
55
67
  fi
56
68
 
69
+ # healthz 必须由目标版本的进程应答才算健康。只看 HTTP 200 会把仍在跑的旧进程
70
+ # (或激活间隙被 launchd 拉起的旧 runtime)误判为升级成功。
71
+ target_healthy() {
72
+ RESP=$(curl -sf --max-time 5 "$HEALTH_URL" 2>/dev/null) || return 1
73
+ case "$RESP" in
74
+ *"\"version\":\"$TARGET_VERSION\""*) return 0 ;;
75
+ *) return 1 ;;
76
+ esac
77
+ }
78
+
57
79
  log "started: from=$FROM_VERSION target=$TARGET_VERSION crash_count=$CRASH_COUNT"
58
80
 
59
81
  # Wait for old daemon to exit (SIGTERM + shutdown + restart)
@@ -74,16 +96,16 @@ else
74
96
  log "forward activation failed for target $TARGET_VERSION; treating as startup failure"
75
97
  fi
76
98
 
77
- # Monitor loop: wait for healthz to return 200 (only after successful activation)
99
+ # Monitor loop: wait for healthz to report the target version (only after successful activation)
78
100
  if [ $ACTIVATED -eq 1 ]; then
79
101
  ELAPSED=0
80
102
  while [ $ELAPSED -lt $HEALTH_TIMEOUT ]; do
81
- if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
103
+ if target_healthy; then
82
104
  # healthz 通过=新版健康。这里不删 pending:删了会抢在新进程的
83
105
  # handlePendingOnStartup 之前,使其读不到 pending、success 回执发不出去
84
106
  # (后端只见 installed 不见 success)。交由新进程上报 success 后自行删 pending,
85
107
  # guardian 只负责崩溃回滚。
86
- log "healthz passed, upgrade healthy; leaving pending for in-process success report"
108
+ log "healthz passed for $TARGET_VERSION, upgrade healthy; leaving pending for in-process success report"
87
109
  exit 0
88
110
  fi
89
111
  sleep 3
@@ -92,75 +114,78 @@ if [ $ACTIVATED -eq 1 ]; then
92
114
 
93
115
  # Extra grace period for slow systems
94
116
  sleep 15
95
- if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
117
+ if target_healthy; then
96
118
  # 同上:不删 pending,交由进程内 handlePendingOnStartup 上报 success 并删除。
97
- log "healthz passed (delayed), upgrade healthy; leaving pending for in-process success report"
119
+ log "healthz passed for $TARGET_VERSION (delayed), upgrade healthy; leaving pending for in-process success report"
98
120
  exit 0
99
121
  fi
100
122
  fi
101
123
 
102
- # Health check failed (or forward activation failed)
103
- CRASH_COUNT=$((CRASH_COUNT + 1))
104
- log "healthz timeout or forward activation failed, crash_count=$CRASH_COUNT"
105
-
106
- if [ $CRASH_COUNT -ge $MAX_CRASHES ]; then
107
- # 新版升级器会把完整旧包保留在同文件系统的固定备份目录。优先原子恢复,
108
- # 不依赖网络/npm,也不会再次制造全局 bin 缺失窗口。
109
- case "$PACKAGE_DIR" in
110
- */node_modules/*)
111
- if [ -n "$BACKUP_DIR" ] && [ "$BACKUP_DIR" = "${PACKAGE_DIR}.grix-upgrade-backup" ] && [ -d "$BACKUP_DIR" ]; then
112
- FAILED_DIR="${PACKAGE_DIR}.grix-upgrade-failed-$$"
113
- log "atomic rollback: restoring $BACKUP_DIR"
124
+ # Health check failed (or forward activation failed). This guardian runs exactly
125
+ # once per upgrade — no supervisor relaunches it — so the verdict is made here:
126
+ # roll back now instead of counting crashes toward a threshold that can never
127
+ # be reached again.
128
+ # 回滚前用 node 原位递增并持久化 crash_count:保留 pending 里的其余字段
129
+ # (transaction_id、路径等),恢复后的旧运行时上报 rolled_back 时要带真实计数。
130
+ CRASH_COUNT=$(node -e '
131
+ const fs = require("fs");
132
+ const file = process.argv[1];
133
+ const f = JSON.parse(fs.readFileSync(file, "utf8"));
134
+ f.crash_count = (Number(f.crash_count) || 0) + 1;
135
+ const tmp = file + ".tmp";
136
+ fs.writeFileSync(tmp, JSON.stringify(f));
137
+ fs.renameSync(tmp, file);
138
+ process.stdout.write(String(f.crash_count));
139
+ ' "$PENDING_FILE" 2>> "$LOG_FILE") || CRASH_COUNT=$((CRASH_COUNT + 1))
140
+ log "healthz timeout or forward activation failed, crash_count=$CRASH_COUNT; rolling back"
141
+
142
+ # 新版升级器会把完整旧包保留在同文件系统的固定备份目录。优先原子恢复,
143
+ # 不依赖网络/npm,也不会再次制造全局 bin 缺失窗口。
144
+ case "$PACKAGE_DIR" in
145
+ */node_modules/*)
146
+ if [ -n "$BACKUP_DIR" ] && [ "$BACKUP_DIR" = "${PACKAGE_DIR}.grix-upgrade-backup" ] && [ -d "$BACKUP_DIR" ]; then
147
+ FAILED_DIR="${PACKAGE_DIR}.grix-upgrade-failed-$$"
148
+ log "atomic rollback: restoring $BACKUP_DIR"
149
+ rm -rf "$FAILED_DIR"
150
+ if { [ ! -e "$PACKAGE_DIR" ] || mv "$PACKAGE_DIR" "$FAILED_DIR"; } \
151
+ && mv "$BACKUP_DIR" "$PACKAGE_DIR"; then
114
152
  rm -rf "$FAILED_DIR"
115
- if { [ ! -e "$PACKAGE_DIR" ] || mv "$PACKAGE_DIR" "$FAILED_DIR"; } \
116
- && mv "$BACKUP_DIR" "$PACKAGE_DIR"; then
117
- rm -rf "$FAILED_DIR"
118
- # 原子恢复只换回全局包;macOS 服务跑的是受管 runtime 副本,必须用
119
- # 旧包 CLI start 刷新副本并触发重启,激活成功才删 pending。
120
- NPM_ROOT="$(npm root -g 2>> "$LOG_FILE")"
121
- ROLLBACK_CLI="$NPM_ROOT/$NPM_PACKAGE/dist/grix.js"
122
- if [ -n "$NPM_ROOT" ] && [ -f "$ROLLBACK_CLI" ] \
123
- && node "$ROLLBACK_CLI" start >> "$LOG_FILE" 2>&1; then
124
- log "atomic rollback succeeded and managed runtime activated; leaving pending for rollback report"
125
- exit 0
126
- fi
127
- # 激活失败不退出:落到下面的 npm 回滚路径再试一次(npm install 旧版幂等)。
128
- log "atomic rollback package restored but managed runtime activation failed; falling through to npm rollback"
129
- else
130
- log "atomic rollback failed; restoring current package before npm fallback"
131
- if [ ! -e "$PACKAGE_DIR" ] && [ -e "$FAILED_DIR" ]; then
132
- mv "$FAILED_DIR" "$PACKAGE_DIR" 2>/dev/null || true
133
- fi
153
+ # 原子恢复只换回全局包;macOS 服务跑的是受管 runtime 副本,必须用
154
+ # 旧包 CLI start 刷新副本并触发重启,激活成功才删 pending。
155
+ NPM_ROOT="$(npm root -g 2>> "$LOG_FILE")"
156
+ ROLLBACK_CLI="$NPM_ROOT/$NPM_PACKAGE/dist/grix.js"
157
+ if [ -n "$NPM_ROOT" ] && [ -f "$ROLLBACK_CLI" ] \
158
+ && node "$ROLLBACK_CLI" start >> "$LOG_FILE" 2>&1; then
159
+ log "atomic rollback succeeded and managed runtime activated; leaving pending for rollback report"
160
+ exit 0
161
+ fi
162
+ # 激活失败不退出:落到下面的 npm 回滚路径再试一次(npm install 旧版幂等)。
163
+ log "atomic rollback package restored but managed runtime activation failed; falling through to npm rollback"
164
+ else
165
+ log "atomic rollback failed; restoring current package before npm fallback"
166
+ if [ ! -e "$PACKAGE_DIR" ] && [ -e "$FAILED_DIR" ]; then
167
+ mv "$FAILED_DIR" "$PACKAGE_DIR" 2>/dev/null || true
134
168
  fi
135
169
  fi
136
- ;;
137
- esac
138
-
139
- log "rollback: installing ${NPM_PACKAGE}@${FROM_VERSION}"
140
- # P5: 先用默认源,失败再显式回退官方 npm(默认源可能是淘宝镜像,旧版若没同步同样会失败)。
141
- if npm install -g "${NPM_PACKAGE}@${FROM_VERSION}" --prefer-online --no-audit --no-fund >> "$LOG_FILE" 2>&1 \
142
- || { log "rollback via default registry failed, retrying official npm"; \
143
- npm install -g "${NPM_PACKAGE}@${FROM_VERSION}" --registry https://registry.npmjs.org --prefer-online --no-audit --no-fund >> "$LOG_FILE" 2>&1; }; then
144
- log "rollback package installed; activating managed runtime"
145
- NPM_ROOT="$(npm root -g 2>> "$LOG_FILE")"
146
- ROLLBACK_CLI="$NPM_ROOT/$NPM_PACKAGE/dist/grix.js"
147
- if [ -n "$NPM_ROOT" ] && [ -f "$ROLLBACK_CLI" ] \
148
- && node "$ROLLBACK_CLI" start >> "$LOG_FILE" 2>&1; then
149
- log "rollback succeeded and managed runtime activated; leaving pending for rollback report"
150
- else
151
- log "ROLLBACK ACTIVATION FAILED — package restored but managed runtime was not switched"
152
170
  fi
171
+ ;;
172
+ esac
173
+
174
+ log "rollback: installing ${NPM_PACKAGE}@${FROM_VERSION}"
175
+ # P5: 先用默认源,失败再显式回退官方 npm(默认源可能是淘宝镜像,旧版若没同步同样会失败)。
176
+ if npm install -g "${NPM_PACKAGE}@${FROM_VERSION}" --prefer-online --no-audit --no-fund >> "$LOG_FILE" 2>&1 \
177
+ || { log "rollback via default registry failed, retrying official npm"; \
178
+ npm install -g "${NPM_PACKAGE}@${FROM_VERSION}" --registry https://registry.npmjs.org --prefer-online --no-audit --no-fund >> "$LOG_FILE" 2>&1; }; then
179
+ log "rollback package installed; activating managed runtime"
180
+ NPM_ROOT="$(npm root -g 2>> "$LOG_FILE")"
181
+ ROLLBACK_CLI="$NPM_ROOT/$NPM_PACKAGE/dist/grix.js"
182
+ if [ -n "$NPM_ROOT" ] && [ -f "$ROLLBACK_CLI" ] \
183
+ && node "$ROLLBACK_CLI" start >> "$LOG_FILE" 2>&1; then
184
+ log "rollback succeeded and managed runtime activated; leaving pending for rollback report"
153
185
  else
154
- log "ROLLBACK FAILED — manual intervention required"
186
+ log "ROLLBACK ACTIVATION FAILED — package restored but managed runtime was not switched"
155
187
  fi
156
- exit 0
157
- fi
158
-
159
- # Under threshold — update crash_count and exit, let process manager restart
160
- PATH_FIELDS=""
161
- if [ -n "$PACKAGE_DIR" ] && [ -n "$BACKUP_DIR" ]; then
162
- PATH_FIELDS=",\"package_dir\":\"$PACKAGE_DIR\",\"backup_dir\":\"$BACKUP_DIR\""
188
+ else
189
+ log "ROLLBACK FAILED — manual intervention required"
163
190
  fi
164
- echo "{\"from_version\":\"$FROM_VERSION\",\"target_version\":\"$TARGET_VERSION\",\"upgraded_at\":\"$UPGRADED_AT\",\"crash_count\":$CRASH_COUNT$PATH_FIELDS}" > "$PENDING_FILE.tmp"
165
- mv "$PENDING_FILE.tmp" "$PENDING_FILE"
166
- log "updated crash_count to $CRASH_COUNT, exiting"
191
+ exit 0