diff --git a/.env.release.example b/.env.release.example index 63faba6aa..e7bde2024 100644 --- a/.env.release.example +++ b/.env.release.example @@ -31,6 +31,19 @@ SKILLHUB_TRUST_FORWARDED_PROTO=false # Leave empty so a fixed-base image keeps its baked base; set to a sub-path to override. SKILLHUB_WEB_BASE_PATH= +# 可选前端动作统计,默认关闭。配置方法见 docs/frontend-analytics-integration.md。 +# 只放公开参数,不放平台密钥。JSON 保持一行;Compose .env 中可用单引号包裹。 +# 修改后需重新创建 Web(up -d --force-recreate web),restart 不会重新读取本文件。 +# Managed by runtime.sh analytics-enable/analytics-disable. For manual setup, +# restart Web after editing. Local Umami requires the external network overlay. +SKILLHUB_ANALYTICS_NETWORK_ENABLED=false +# Give each local installation a unique project and network name if sharing a host. +SKILLHUB_ANALYTICS_PROJECT=skillhub-analytics +SKILLHUB_ANALYTICS_NETWORK=skillhub-analytics +SKILLHUB_WEB_ANALYTICS_CONFIG= +# 仅 Web 代理使用的 HTTP(S) origin,无路径、凭证、查询或末尾斜杠。 +SKILLHUB_WEB_ANALYTICS_UPSTREAM= + POSTGRES_BIND_ADDRESS=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=skillhub diff --git a/.github/workflows/check-dsh-plugin.yml b/.github/workflows/check-dsh-plugin.yml index 6c90778d9..a25a2ce85 100644 --- a/.github/workflows/check-dsh-plugin.yml +++ b/.github/workflows/check-dsh-plugin.yml @@ -81,9 +81,26 @@ jobs: run: | mkdir -p artifacts npm pack --ignore-scripts --pack-destination artifacts + - name: Validate public download cache before restore + working-directory: plugins/dsh + run: node --input-type=module -e "import { prepareDownloadCache } from './scripts/smoke-native-host.mjs'; await prepareDownloadCache(process.argv[1]);" "${{ runner.temp }}/skillhub-public-npm" + - name: Restore public npm download content + id: public-downloads + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-0.2.0-rc.2 + restore-keys: | + dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}- - name: Verify native DSH Host in an isolated Web Profile working-directory: plugins/dsh - run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-smoke-summary.json + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-smoke-summary.json --download-cache "${{ runner.temp }}/skillhub-public-npm" + - name: Save public npm download content + if: matrix.node == '24.2.0' && steps.public-downloads.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-0.2.0-rc.2 - name: Verify retained Cordis family in an isolated Web Profile if: matrix.os == 'ubuntu-latest' && matrix.node == '24.2.0' working-directory: plugins/dsh @@ -147,3 +164,60 @@ jobs: path: plugins/dsh/host-release-summary.json if-no-files-found: warn retention-days: 7 + + activation: + name: Installed skill activation (${{ matrix.os }}, DSH ${{ matrix.dsh }}) + needs: check + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + dsh: ['0.2.0-rc.2', '0.2.1-rc.2'] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24.2.0' + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + - name: Install local Registry fixture dependencies + working-directory: cli + run: bun install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: skillhub-dsh-development-package-${{ matrix.os }}-node-24.2.0 + path: plugins/dsh/artifacts + - name: Validate public download cache before restore + working-directory: plugins/dsh + run: node --input-type=module -e "import { prepareDownloadCache } from './scripts/smoke-native-host.mjs'; await prepareDownloadCache(process.argv[1]);" "${{ runner.temp }}/skillhub-public-npm" + - name: Restore public npm download content + id: public-downloads + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-${{ matrix.dsh }} + restore-keys: | + dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}- + - name: Verify installed skill activation in a fresh isolated Profile + working-directory: plugins/dsh + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-toggle-summary.json --download-cache "${{ runner.temp }}/skillhub-public-npm" --scenario skill-toggle --dsh-version '${{ matrix.dsh }}' + - name: Verify watch-off activation in a fresh isolated Profile + working-directory: plugins/dsh + run: node scripts/smoke-native-host.mjs --artifact artifacts --report host-toggle-watch-off-summary.json --download-cache "${{ runner.temp }}/skillhub-public-npm" --scenario skill-toggle-watch-off --dsh-version '${{ matrix.dsh }}' + - name: Save public npm download content + if: steps.public-downloads.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/skillhub-public-npm/_cacache + key: dsh-public-npm-${{ runner.os }}-${{ runner.arch }}-fresh-${{ hashFiles('plugins/dsh/scripts/smoke-native-host.mjs') }}-${{ matrix.dsh }} + - name: Upload sanitized activation summaries + if: always() + uses: actions/upload-artifact@v4 + with: + name: skillhub-dsh-activation-${{ matrix.os }}-${{ matrix.dsh }} + path: plugins/dsh/host-toggle*-summary.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index b9d919741..fc4a1fd1b 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -50,6 +50,9 @@ jobs: - name: Validate configuration contracts run: bash tests/configuration-contracts.sh + - name: Verify analytics smoke isolation + run: bash tests/analytics-smoke-test.sh + - name: Validate chart metadata run: | CHART_VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}') diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 834f7c998..a813412b6 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -9,6 +9,8 @@ on: - '.env.release.example' - '.env.release.draft' - 'compose.release.yml' + - 'compose.release.analytics.yml' + - 'compose.analytics-umami.yml' - 'compose.storage.*.yml' - 'deploy/storage/**' - 'deploy/runtime-mirror-images.txt' @@ -55,6 +57,7 @@ jobs: - run: python -m unittest discover -s scanner/tests -p 'test_*.py' - run: bash scripts/tests/publish-cli-test.sh - run: bash scripts/tests/runtime-secret-test.sh + - run: bash scripts/tests/runtime-analytics-test.sh - run: bash scripts/tests/validate-release-config-test.sh - run: bash scripts/tests/storage-compose-test.sh - run: bash scripts/tests/storage-compose-render-test.sh diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index c54b7e6fb..129829624 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -297,6 +297,7 @@ jobs: EOF gh release create "$TAG" \ + --latest=false \ --title "CLI ${VERSION}" \ --notes-file release-notes.md \ "skillhub-cli-${VERSION}.tar.gz" \ diff --git a/README.md b/README.md index 73b300d97..13b973023 100644 --- a/README.md +++ b/README.md @@ -282,11 +282,28 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u | Parameter | Description | Example | |-----------|-------------|---------| | `--public-url ` | Public access URL (recommended) | `--public-url https://skill.example.com` | -| `--version ` | Specific image tag | `--version v0.2.0` | +| `--version ` | Specific image tag | `--version v0.2.25` | +| `--ref ` | Git ref for deployment files (GitHub raw mode) | `--ref v0.2.25` | | `--aliyun` | Use Aliyun mirror (China) | `--aliyun` | | `--home ` | Runtime directory | `--home /opt/skillhub` | | `--no-scanner` | Disable security scanner | `--no-scanner` | +`--version` selects image tags; it does not pin the deployment files. By default, +those files come from `main`. To pin the installer, Compose file, environment +template and application image tag together, use a released Git ref and tag: + +```bash +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/v0.2.25/scripts/runtime.sh | sh -s -- up --ref v0.2.25 --version v0.2.25 --home /opt/skillhub --public-url https://skillhub.your-company.com +``` + +This example uses GitHub raw and requires access to it. `--aliyun` switches runtime +file downloads to the OSS root, where `--ref` does not select a versioned directory. +Likewise, a custom `SKILLHUB_RAW_BASE` takes precedence over the Git-ref URL. +Neither should be described as pinning deployment files with `--ref`. +The script preserves an existing `.env.release`; record its settings and explicit +image overrides separately. Pinning the application tag does not freeze every +dependency image or replace a database backup. + > **Important**: Configure `--public-url` for production deployments to ensure CLI install commands and Agent setup instructions display the correct URLs. For sub-path deployments, keep the public URL and runtime base path aligned in diff --git a/README_zh.md b/README_zh.md index cf1305862..875de021b 100644 --- a/README_zh.md +++ b/README_zh.md @@ -249,11 +249,25 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u | 参数 | 说明 | 示例 | |------|------|------| | `--public-url ` | 公网访问地址(推荐配置) | `--public-url https://skill.example.com` | -| `--version ` | 指定镜像版本 | `--version v0.2.0` | +| `--version ` | 指定镜像版本 | `--version v0.2.25` | +| `--ref ` | 指定部署文件的 Git ref(GitHub raw 模式) | `--ref v0.2.25` | | `--aliyun` | 使用阿里云镜像(国内推荐) | `--aliyun` | | `--home ` | 指定运行时目录 | `--home /opt/skillhub` | | `--no-scanner` | 禁用安全扫描服务 | `--no-scanner` | +`--version` 选择镜像标签,不固定部署文件;部署文件默认取自 `main`。 +如需同时固定安装脚本、Compose 清单、环境模板及应用镜像标签,可使用已发布的 ref 和标签: + +```bash +curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/v0.2.25/scripts/runtime.sh | sh -s -- up --ref v0.2.25 --version v0.2.25 --home /opt/skillhub --public-url https://skillhub.your-company.com +``` + +此例需要能访问 GitHub raw。`--aliyun` 会改用 OSS 根目录下载部署文件, +此时 `--ref` 不会选择版本子目录;自定义 `SKILLHUB_RAW_BASE` 也优先于按 Git ref 生成的地址, +不能把这两种情况称为已通过 `--ref` 固定部署文件。 +脚本保留已有 `.env.release`,请另行记录其中配置及显式镜像覆盖值。 +固定应用镜像标签不等于冻结全部依赖镜像,也不能代替数据库备份。 + > **重要**:生产环境请务必配置 `--public-url`,确保 CLI 安装命令和 Agent 设置指引显示正确的地址。 如果通过 `/skillhub/` 这类子路径对外发布,需要让公网地址和前端基础路径保持一致。 diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 1bd884044..02c81852a 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -50,7 +50,8 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ``` 未显式设置 `deviceAuthVerificationUri` 时,Chart 使用 -`/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验, +`/device`,保留部署子路径。`/cli/auth` 是旧版 loopback 回调入口, +需要客户端提供回调参数,不能作为设备码授权地址。所有 values 会先经过 `values.schema.json` 和跨字段校验, 无效的组件、Ingress、HPA 与存储组合会在安装前失败。 首次安装可通过 `auth.initialSettings.passwordLoginEnabled` 和 @@ -106,6 +107,29 @@ PVC UID 与绑定 PV 在升级前后保持不变。四个场景分别覆盖默 Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingress 路由。 默认清理自己创建的 namespace;设置 `KEEP_HELM_SMOKE=true` 可保留现场用于排查。 +### 可选前端动作统计 + +默认关闭。`web.analytics.config` 接受 JSON 字符串,`web.analytics.upstream` 接受仅部署可见的 +HTTP(S) origin。支持 Umami 和 DataFinder,不需要编辑 JS 或 Nginx;Umami 先创建网站取得 UUID, +DataFinder 先取得应用 app_id 和采集域名。 +值放在 Web 专用 ConfigMap,只改统计配置时仅 Web 滚动更新、后端 Pod 不替换;旧标签页不会自动更新。字段、授权接口、初始化、隐私及验收见 +[前端动作统计接入指南](../../docs/frontend-analytics-integration.md)。 + +统计专项验收脚本会发送测试事件、查询 `stats` 中的独立数据库,并对指定的已安装 Release +执行真实 `helm upgrade --reuse-values`,将统计规则改为空对象;它不是只读检查,也不会自动恢复规则。 +仅用于隔离验收安装,要求单个 Ready Web/Server Pod、独立 `stats` 平台和提前配置好的 Web port-forward。 +从仓库根目录运行(先替换网站 UUID、values 和安装名称): + +```sh +ANALYTICS_SMOKE_WEB_URL=http://127.0.0.1:18082 \ + bash charts/skillhub/tests/analytics-smoke.sh "<网站UUID>" values-acceptance.yaml sh1037 skillhub +``` + +第二个参数必须是该安装使用的配置;脚本复用已有 Release 值,不会隐式加载 +`tests/test-values.yaml`。显式文件仍可覆盖原值,勿混用其他环境的 values 或重新生成 Secret。 +脚本成功只证明代理入库及本次统计配置升级时 Web 滚动、Server 未替换;浏览器授权、 +业务回归和真实生产配置仍须按接入指南验证。验收后用原安装配置再次升级以恢复规则。 + ### 高可用模式 ```bash @@ -161,6 +185,11 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ --set existingSecret=my-custom-secret ``` +### 自动生成凭据的升级边界 + +应用 Secret 与 Server/GitHub Sync 的校验值在一次渲染中共用同一结果,真正修改凭据仍会触发相应滚动。 +旧版 Chart 曾分别生成随机值;若已安装实例采用自动生成应用凭据,首次升级到此修复版本可能纠正校验值并额外滚动一次后端,Secret 内容保持不变。预留滚动窗口,不要为避免滚动重新生成或替换凭据。 + ### GitOps 稳定 Secret Argo CD 等 GitOps 工具使用离线 `helm template`,无法通过 Helm `lookup` 读取集群 diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 3130fb623..409b70209 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -49,7 +49,7 @@ data: public-base-url: {{ .Values.publicBaseUrl | quote }} {{- $deviceAuthVerificationUri := .Values.deviceAuthVerificationUri }} {{- if and (not $deviceAuthVerificationUri) .Values.publicBaseUrl }} - {{- $deviceAuthVerificationUri = printf "%s/cli/auth" (trimSuffix "/" .Values.publicBaseUrl) }} + {{- $deviceAuthVerificationUri = printf "%s/device" (trimSuffix "/" .Values.publicBaseUrl) }} {{- end }} device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }} auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 0c8266b13..aae9c2f03 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -1,3 +1,4 @@ +{{- define "skillhub.appSecretManifest" -}} {{- /* SkillHub 应用 Secret - 内置 PostgreSQL/Redis:密码由 Bitnami 管理,从对应 Secret 读取 @@ -106,3 +107,11 @@ stringData: skillhub-storage-s3-secret-key: {{ .Values.s3.secretKey | quote }} {{- end }} {{- end }} +{{ end -}} + +{{- /* Reuse one render across the Secret resource and all checksum consumers. + Keep this cache on the render context, never in persisted user Values. */ -}} +{{- if not (hasKey . "_skillhubAppSecretManifest") -}} +{{- $_ := set . "_skillhubAppSecretManifest" (include "skillhub.appSecretManifest" .) -}} +{{- end -}} +{{- index . "_skillhubAppSecretManifest" -}} diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index f8f9a29c6..15ae50fa9 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -39,7 +39,7 @@ {{- fail (printf "web.basePath must be '/' or a normalized sub-path that starts and ends with '/' and has no '.'/'..' or empty segments (matches the runtime and release-config checks): %s" $webBasePath) -}} {{- end -}} {{- $firstSegment := index (splitList "/" $webBasePath) 1 -}} -{{- if has $firstSegment (list "api" "oauth2" "login" "assets" "registry" "nginx-health" ".well-known" "runtime-config.js") -}} +{{- if has $firstSegment (list "api" "oauth2" "login" "assets" "registry" "nginx-health" ".well-known" "runtime-config.js" "_skillhub-analytics") -}} {{- fail (printf "web.basePath must not start with a segment reserved by the SkillHub server (%s); it would shadow the server's own Nginx location: %s" $firstSegment $webBasePath) -}} {{- end -}} {{- $suffix := trimSuffix "/" $webBasePath -}} diff --git a/charts/skillhub/templates/web-analytics-configmap.yaml b/charts/skillhub/templates/web-analytics-configmap.yaml new file mode 100644 index 000000000..8dc5f54a3 --- /dev/null +++ b/charts/skillhub/templates/web-analytics-configmap.yaml @@ -0,0 +1,13 @@ +{{- /* +Web-only analytics settings. Kept out of the shared ConfigMap so that changing +analytics only rolls Web; the server checksum hashes the shared ConfigMap. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "skillhub.fullname" . }}-web-analytics + labels: + {{- include "skillhub.web.labels" . | nindent 4 }} +data: + config: {{ dig "analytics" "config" "" .Values.web | quote }} + upstream: {{ dig "analytics" "upstream" "" .Values.web | quote }} diff --git a/charts/skillhub/templates/web-deployment.yaml b/charts/skillhub/templates/web-deployment.yaml index b92a5ef4e..6a2b2e3a7 100644 --- a/charts/skillhub/templates/web-deployment.yaml +++ b/charts/skillhub/templates/web-deployment.yaml @@ -58,6 +58,16 @@ spec: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: web-api-base-url + - name: SKILLHUB_WEB_ANALYTICS_CONFIG + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-web-analytics + key: config + - name: SKILLHUB_WEB_ANALYTICS_UPSTREAM + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-web-analytics + key: upstream {{- with .Values.web.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/skillhub/tests/analytics-smoke-test.sh b/charts/skillhub/tests/analytics-smoke-test.sh new file mode 100755 index 000000000..083c0e1ab --- /dev/null +++ b/charts/skillhub/tests/analytics-smoke-test.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Exercise the installed-release smoke without a cluster or third-party packages. +set -euo pipefail +TEST_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +python3 - "${ANALYTICS_SMOKE_SCRIPT:-$TEST_DIR/analytics-smoke.sh}" <<'PY' +import copy +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +script = Path(sys.argv[1]).resolve() +website = '00000000-0000-4000-8000-000000000037' +# JSON is valid Helm YAML. The caller file deliberately omits settings supplied +# at installation through other files/--set, especially private credentials. +original = { + 'secrets': {'bootstrapAdminPassword': 'synthetic-private-bootstrap', + 'downloadAnonCookieSecret': 'synthetic-private-cookie-secret'}, + 'postgresql': {'auth': {'password': 'synthetic-private-postgres'}}, + 'redis': {'auth': {'password': 'synthetic-private-redis'}}, + 'server': {'replicaCount': 1, 'image': {'tag': 'installed-server-tag'}}, + 'web': {'basePath': '/registry/', 'analytics': { + 'upstream': 'http://original-stats.internal:3000', 'config': '{}'}}, + 'existingSecret': '', +} +caller = {'web': {'analytics': {'upstream': 'http://original-stats.internal:3000'}}} + +# These fakes model Helm value layering and process state, not chart rendering. +# They fail on unexpected operations so this test cannot reach a real cluster. +fake = r'''#!/usr/bin/env python3 +import copy, json, os, sys +from pathlib import Path +root = Path(os.environ['FAKE_STATE']) +name = Path(sys.argv[0]).name +args = sys.argv[1:] +scenario = os.environ['FAKE_SCENARIO'] + +def check(condition, message): + if not condition: + sys.exit(message) + +def record(value): + with (root / 'calls').open('a') as f: + f.write(value + '\n') + +def merge(base, values): + for key, value in values.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + merge(base[key], value) + else: + base[key] = copy.deepcopy(value) + +if name == 'helm': + check(args[:2] == ['upgrade', os.environ['FAKE_RELEASE']], 'unexpected Helm operation') + check(args[args.index('-n') + 1] == os.environ['FAKE_NAMESPACE'], 'wrong release namespace') + check('--wait' in args and args[args.index('--timeout') + 1] == '5m', 'upgrade must wait') + original = json.loads((root / 'original.json').read_text()) + effective = copy.deepcopy(original) if '--reuse-values' in args else {} + files = [Path(args[i + 1]) for i, arg in enumerate(args) if arg == '-f'] + check(Path(os.environ['FAKE_CALLER']) in files, 'caller values were omitted') + for path in files: + # Reading the known repository fixture models the old bug as well. + text = path.read_text() + if text.startswith('{'): + values = json.loads(text) + else: + values = {} + stack = [(-1, values)] + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + indent = len(line) - len(line.lstrip()) + key, value = line.strip().split(':', 1) + while stack[-1][0] >= indent: + stack.pop() + parent = stack[-1][1] + value = value.strip() + parent[key] = value.strip("'\"") if value else {} + if not value: + stack.append((indent, parent[key])) + merge(effective, values) + expected = copy.deepcopy(original) + expected['web']['analytics']['config'] = effective.get('web', {}).get('analytics', {}).get('config') + if scenario == 'generated-profile': + check(effective.get('secrets') == original['secrets'], + 'generated secret profile must retain empty values for lookup') + check(effective == expected, 'upgrade changed unrelated release values or secrets') + check(all(p.name != 'test-values.yaml' for p in files), 'repository fixture values injected') + config = json.loads(effective['web']['analytics']['config']) + check(config == {'enabled': True, 'adapter': 'umami', 'consent': 'granted', + 'rules': {}, 'adapterConfig': {'websiteId': os.environ['FAKE_WEBSITE']}}, + 'wrong analytics upgrade') + (root / 'upgraded').touch() + record('upgrade: analytics only; installed values and secrets retained; no test fixture') +elif name == 'kubectl': + check(args[0] == '-n', 'namespace must be explicit') + namespace, command = args[1], args[2:] + if command[:2] == ['get', 'pods']: + check(namespace == os.environ['FAKE_NAMESPACE'], 'wrong pod namespace') + selector = command[command.index('-l') + 1] + component = selector.rsplit('=', 1)[-1] + check(component in ('server', 'web'), 'unexpected component') + upgraded = (root / 'upgraded').exists() + changed = upgraded and ((component == 'web' and scenario != 'web-stable') or + (component == 'server' and scenario == 'server-roll')) + uid = component + ('-after' if changed else '-before') + record('uid: ' + uid) + print(json.dumps({'items': [{'metadata': {'uid': uid}, 'status': { + 'conditions': [{'type': 'Ready', 'status': 'True'}]}}]})) + elif command[:3] == ['exec', 'deployment/umami-postgres', '--']: + check(namespace == 'stats', 'event query must use independent stats namespace') + check(os.environ['FAKE_WEBSITE'] in command[-1] and "event_name='skill.card.open'" in command[-1], + 'wrong event query') + print(11 if (root / 'sent').exists() and scenario != 'no-event' else 10) + record('event count') + elif command[:2] == ['rollout', 'status']: + check(command[2] == 'deployment/' + os.environ['FAKE_RELEASE'] + '-skillhub-web', 'wrong rollout') + check((root / 'upgraded').exists(), 'rollout before upgrade') + record('rollout: web') + else: + sys.exit('unexpected kubectl operation') +elif name == 'curl': + check(args[-1].startswith('http://127.0.0.1:18082/') or '--data' in args, 'unexpected Web URL') + if '-X' in args: + check(args[args.index('-X') + 1] == 'POST', 'expected event POST') + payload = json.loads(args[args.index('--data') + 1])['payload'] + check(payload['website'] == os.environ['FAKE_WEBSITE'] and payload['name'] == 'skill.card.open', + 'wrong event payload') + (root / 'sent').touch() + record('HTTP: event POST') + else: + check(args[-1].endswith('/_skillhub-analytics/script.js'), 'wrong tracker path') + record('HTTP: tracker GET') + print('200', end='') +else: + sys.exit('unexpected fake command') +''' + +with tempfile.TemporaryDirectory(prefix='analytics-smoke-test.') as tmp: + root = Path(tmp) + bin_dir = root / 'bin' + bin_dir.mkdir() + shim = bin_dir / 'fake' + shim.write_text(fake) + shim.chmod(0o755) + for name in ('helm', 'kubectl', 'curl'): + (bin_dir / name).symlink_to(shim) + caller_file = root / 'installed-overrides.json' + caller_file.write_text(json.dumps(caller)) + for scenario, failure in ( + ('generated-profile', None), + ('success', None), + ('server-roll', 'Server pod rolled unexpectedly'), + ('web-stable', 'Web pod did not roll'), + ('no-event', 'HTTP 200 but event not stored'), + ): + state = root / scenario + state.mkdir() + installed = copy.deepcopy(original) + if scenario == 'generated-profile': + # Empty application-secret values preserve the chart's existing-Secret + # lookup path. This value-layer model does not perform that lookup. + installed['secrets'] = {'allowAutoGenerated': True, + 'bootstrapAdminPassword': '', + 'downloadAnonCookieSecret': ''} + (state / 'original.json').write_text(json.dumps(installed)) + env = dict(os.environ, PATH=str(bin_dir) + os.pathsep + os.environ['PATH'], + FAKE_STATE=str(state), FAKE_SCENARIO=scenario, + FAKE_WEBSITE=website, FAKE_CALLER=str(caller_file), + FAKE_RELEASE='original-release', FAKE_NAMESPACE='original-namespace', + ANALYTICS_SMOKE_WEB_URL='http://127.0.0.1:18082', TMPDIR=str(state)) + result = subprocess.run(['bash', str(script), website, str(caller_file), + env['FAKE_RELEASE'], env['FAKE_NAMESPACE']], + env=env, text=True, capture_output=True) + if failure is None: + assert result.returncode == 0, result.stdout + result.stderr + calls = (state / 'calls').read_text().splitlines() + assert calls.count('event count') == 2, calls + for call in ('uid: server-before', 'uid: web-before', 'uid: web-after', 'rollout: web'): + assert call in calls, call + assert calls.count('uid: server-before') == 2, calls + assert 'server UID unchanged' in result.stdout, result.stdout + else: + assert result.returncode != 0 and failure in result.stderr, result.stdout + result.stderr + assert not list(state.glob('skillhub-analytics-smoke.*')), 'temporary override was not cleaned' + print('PASS: ' + scenario) +print('Analytics smoke regression tests passed (fake Helm/kubectl/curl; no deployment).') +PY diff --git a/charts/skillhub/tests/analytics-smoke.sh b/charts/skillhub/tests/analytics-smoke.sh new file mode 100644 index 000000000..3520835ef --- /dev/null +++ b/charts/skillhub/tests/analytics-smoke.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Run against an installed SkillHub Helm release and independent stats namespace. +# Port-forward Web beforehand; the values file must reproduce the installed release. +set -euo pipefail +CHART_DIR=$(cd "$(dirname "$0")/.." && pwd) +WEBSITE_ID=${1:?Usage: analytics-smoke.sh WEBSITE_UUID VALUES_FILE [release] [namespace]} +VALUES_FILE=${2:?Specify the Helm override values used for the installation} +RELEASE=${3:-sh1037} +NAMESPACE=${4:-skillhub} +WEB_URL=${ANALYTICS_SMOKE_WEB_URL:-http://127.0.0.1:18082} +[[ "$WEBSITE_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || { echo 'Invalid website UUID' >&2; exit 1; } +for bin in helm kubectl curl; do command -v "$bin" >/dev/null || exit 1; done +pod_uid() { + kubectl -n "$NAMESPACE" get pods -l "app.kubernetes.io/name=skillhub,app.kubernetes.io/component=$1" -o json \ + | python3 -c 'import json,sys; pods=[p for p in json.load(sys.stdin)["items"] if not p["metadata"].get("deletionTimestamp") and any(c.get("type")=="Ready" and c.get("status")=="True" for c in p.get("status",{}).get("conditions",[]))]; assert len(pods)==1,pods; print(pods[0]["metadata"]["uid"])' +} +count_events() { + kubectl -n stats exec deployment/umami-postgres -- psql -U umami -d umami -tAc \ + "select count(*) from website_event where website_id='$WEBSITE_ID' and event_name='skill.card.open'" | tr -d '[:space:]' +} +server_before=$(pod_uid server) +web_before=$(pod_uid web) +before=$(count_events) +script_code=$(curl -sS -o /dev/null -w '%{http_code}' "$WEB_URL/_skillhub-analytics/script.js") +[[ "$script_code" == 200 ]] || { echo "Tracker failed: $script_code" >&2; exit 1; } +send_code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$WEB_URL/_skillhub-analytics/api/send" \ + -H 'content-type: application/json' -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' \ + -H "origin: $WEB_URL" \ + --data "{\"type\":\"event\",\"payload\":{\"website\":\"$WEBSITE_ID\",\"name\":\"skill.card.open\",\"data\":{\"event\":\"click\"},\"hostname\":\"localhost\",\"url\":\"/skills\",\"screen\":\"1440x900\",\"language\":\"en-US\",\"title\":\"SkillHub\"}}") +[[ "$send_code" == 200 ]] || { echo "Event send failed: $send_code" >&2; exit 1; } +after=$(count_events) +[[ "$after" -gt "$before" ]] || { echo 'HTTP 200 but event not stored' >&2; exit 1; } +echo "Independent stats DB: event count $before -> $after (script=$script_code send=$send_code)" +# Reuse installed values (including secrets); the caller file must reproduce the install. +# Never apply chart test fixtures to an existing release. +changed="{\"enabled\":true,\"adapter\":\"umami\",\"consent\":\"granted\",\"rules\":{},\"adapterConfig\":{\"websiteId\":\"$WEBSITE_ID\"}}" +change_values=$(mktemp "${TMPDIR:-/tmp}/skillhub-analytics-smoke.XXXXXX") +trap 'rm -f "$change_values"' EXIT +printf "web:\n analytics:\n config: '%s'\n" "$changed" >"$change_values" +helm upgrade "$RELEASE" "$CHART_DIR" -n "$NAMESPACE" --reuse-values -f "$VALUES_FILE" \ + -f "$change_values" --wait --timeout 5m >/dev/null +kubectl -n "$NAMESPACE" rollout status "deployment/$RELEASE-skillhub-web" --timeout=180s >/dev/null +server_after=$(pod_uid server) +web_after=$(pod_uid web) +[[ "$server_before" == "$server_after" ]] || { echo 'Server pod rolled unexpectedly' >&2; exit 1; } +[[ "$web_before" != "$web_after" ]] || { echo 'Web pod did not roll' >&2; exit 1; } +echo "analytics-only Helm upgrade: web pod UID $web_before -> $web_after; server UID unchanged $server_after" diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 311989971..db3ed740c 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -297,7 +297,18 @@ grep -Fq 'bootstrap-admin-display-name: "Ops: Admin"' "$TMP_DIR/special.yaml" render device "$CHART_DIR" \ --set publicBaseUrl=https://skills.example.com \ --show-only templates/configmap.yaml >"$TMP_DIR/device.yaml" -grep -Fq 'device-auth-verification-uri: "https://skills.example.com/cli/auth"' "$TMP_DIR/device.yaml" +grep -Fq 'device-auth-verification-uri: "https://skills.example.com/device"' "$TMP_DIR/device.yaml" + +render device-subpath "$CHART_DIR" \ + --set publicBaseUrl=https://skills.example.com/portal/ \ + --show-only templates/configmap.yaml >"$TMP_DIR/device-subpath.yaml" +grep -Fq 'device-auth-verification-uri: "https://skills.example.com/portal/device"' "$TMP_DIR/device-subpath.yaml" + +render device-override "$CHART_DIR" \ + --set publicBaseUrl=https://skills.example.com \ + --set-string 'deviceAuthVerificationUri=https://login.example.com/custom/device?source=cli' \ + --show-only templates/configmap.yaml >"$TMP_DIR/device-override.yaml" +grep -Fq 'device-auth-verification-uri: "https://login.example.com/custom/device?source=cli"' "$TMP_DIR/device-override.yaml" render tls "$CHART_DIR" \ --set ingress.enabled=true \ @@ -460,6 +471,75 @@ assert_rejected subpath-reserved-api --set-string web.basePath=/api/ assert_rejected subpath-reserved-assets --set-string web.basePath=/assets/ assert_rejected subpath-reserved-well-known --set-string web.basePath=/.well-known/ assert_rejected subpath-reserved-nested --set-string web.basePath=/api/nested/ +assert_rejected subpath-reserved-analytics --set-string web.basePath=/_skillhub-analytics/ +assert_rejected subpath-reserved-analytics-nested --set-string web.basePath=/_skillhub-analytics/nested/ + +render analytics-default "$CHART_DIR" >"$TMP_DIR/analytics-default.yaml" +grep -Fq 'name: analytics-default-skillhub-web-analytics' "$TMP_DIR/analytics-default.yaml" \ + || fail "analytics must render its Web-only ConfigMap" +grep -Fq ' config: ""' "$TMP_DIR/analytics-default.yaml" || fail "analytics must default off" +if grep -Eq '^ web-analytics-(config|upstream):' "$TMP_DIR/analytics-default.yaml"; then + fail "analytics keys must stay out of the shared ConfigMap hashed by the server" +fi +# --set treats {...} as a list, so pass the JSON string through a values file, +# the same way the operator guide documents it. +cat >"$TMP_DIR/analytics-values.yaml" <<'VALUES' +web: + analytics: + config: '{"enabled":true,"adapter":"umami","consent":"granted","adapterConfig":{"websiteId":"4398de8e-40df-4483-9eea-ed213643525f"}}' + upstream: https://stats.example.com +VALUES +render analytics-default "$CHART_DIR" -f "$TMP_DIR/analytics-values.yaml" >"$TMP_DIR/analytics-enabled.yaml" +for key in SKILLHUB_WEB_ANALYTICS_CONFIG SKILLHUB_WEB_ANALYTICS_UPSTREAM; do + grep -A4 -F "name: $key" "$TMP_DIR/analytics-enabled.yaml" \ + | grep -Fq 'name: analytics-default-skillhub-web-analytics' \ + || fail "$key must come from the Web-only analytics ConfigMap" +done +grep -Fq ' upstream: "https://stats.example.com"' "$TMP_DIR/analytics-enabled.yaml" || fail "missing analytics origin" + +# Pod template annotations: " " per line. +pod_checksums() { + awk ' + $1 == "kind:" { kind=$2; name="" } + kind == "Deployment" && name == "" && $1 == "name:" { name=$2 } + kind == "Deployment" && $1 ~ /^checksum\// { print name, $1, $2 } + ' "$1" | sort +} +checksum_of() { + pod_checksums "$1" | awk -v d="$2" -v k="$3" '$1 == d && $2 == k { print $3 }' +} +assert_checksum() { + local before=$1 after=$2 deployment=$3 annotation=$4 expect=$5 + local a b + a=$(checksum_of "$before" "$deployment" "$annotation") + b=$(checksum_of "$after" "$deployment" "$annotation") + [[ -n $a && -n $b ]] || fail "$deployment must render $annotation" + if [[ $expect == same && $a != "$b" ]]; then + fail "$deployment $annotation must not change" + elif [[ $expect == changed && $a == "$b" ]]; then + fail "$deployment $annotation must change" + fi +} +web=analytics-default-skillhub-web +server=analytics-default-skillhub-server +# Changing only analytics must roll Web and leave every other workload alone. +assert_checksum "$TMP_DIR/analytics-default.yaml" "$TMP_DIR/analytics-enabled.yaml" "$web" checksum/config: changed +diff <(pod_checksums "$TMP_DIR/analytics-default.yaml" | grep -v "^$web ") \ + <(pod_checksums "$TMP_DIR/analytics-enabled.yaml" | grep -v "^$web ") >/dev/null \ + || fail "analytics-only changes must not replace server or other pods" +# A second adapter uses the same opaque JSON/ConfigMap contract, via a values file. +render analytics-default "$CHART_DIR" -f "$CHART_DIR/tests/datafinder-values.yaml" >"$TMP_DIR/analytics-datafinder.yaml" +grep -Fq 'appId\":123456' "$TMP_DIR/analytics-datafinder.yaml" || fail "DataFinder public config missing" +grep -Fq ' upstream: "https://collector.example.com"' "$TMP_DIR/analytics-datafinder.yaml" || fail "DataFinder origin missing" +assert_checksum "$TMP_DIR/analytics-default.yaml" "$TMP_DIR/analytics-datafinder.yaml" "$web" checksum/config: changed +diff <(pod_checksums "$TMP_DIR/analytics-default.yaml" | grep -v "^$web ") \ + <(pod_checksums "$TMP_DIR/analytics-datafinder.yaml" | grep -v "^$web ") >/dev/null \ + || fail "DataFinder must not roll non-Web pods" + +# Backend configuration still rolls the server. +render analytics-default "$CHART_DIR" -f "$TMP_DIR/analytics-values.yaml" \ + --set-string bootstrapAdmin.displayName=Changed >"$TMP_DIR/analytics-server-change.yaml" +assert_checksum "$TMP_DIR/analytics-enabled.yaml" "$TMP_DIR/analytics-server-change.yaml" "$server" checksum/config: changed # publicBaseUrl is concatenated with paths (/cli/auth, /.well-known/clawhub.json), # so a query or fragment corrupts the generated URLs. Reject it independently of @@ -483,4 +563,73 @@ grep -Fq 'key: skillhub-storage-s3-access-key' "$TMP_DIR/storage-example.yaml" \ grep -Fq 'key: skillhub-storage-s3-secret-key' "$TMP_DIR/storage-example.yaml" \ || fail "Storage secret key Secret mapping changed" +# Generated application secrets must be resolved once per render, including +# independent Server/GitHub Sync checksum includes and the actual Secret output. +python3 - "$CHART_DIR" "$TMP_DIR" <<'PY_SECRET_RENDER' +import base64 +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys + +chart = Path(sys.argv[2]) / 'secret-render-chart' +shutil.copytree(Path(sys.argv[1]), chart) +(chart / 'templates/secret-render-probe.yaml').write_text('''apiVersion: v1 +kind: ConfigMap +metadata: + name: secret-render-probe +data: + first: {{ include (print $.Template.BasePath "/secret.yaml") . | b64enc | quote }} + second: {{ include (print $.Template.BasePath "/secret.yaml") . | b64enc | quote }} + values-cache: {{ hasKey .Values "_skillhubAppSecretManifest" | quote }} +''') +base = ['helm', 'template', 'secret-render', str(chart), '-f', + str(chart / 'tests/test-values.yaml'), '--set', 'githubSync.enabled=true', + '--set-string', 'secrets.githubSyncServiceToken=synthetic-github-sync-token-at-least-32-characters'] + +def render(*args): + output = subprocess.check_output([*base, *args], text=True) + parts = re.split(r'^---\s*$', output, flags=re.M) + def resource(name): + return next(part for part in parts if '# Source: skillhub/templates/' + name in part) + probe = resource('secret-render-probe.yaml') + manifests = [base64.b64decode(re.search(r'^ ' + key + r': "([A-Za-z0-9+/=]*)"$', probe, re.M).group(1)) + for key in ['first', 'second']] + assert manifests[0] == manifests[1], 'Secret includes must share one generated result' + assert ' values-cache: "false"' in probe, 'Render cache must not mutate user Values' + digest = hashlib.sha256(manifests[0]).hexdigest() + for name in ['server-deployment.yaml', 'github-sync-deployment.yaml']: + checksum = re.search(r'checksum/secret: ([0-9a-f]+)', resource(name)).group(1) + assert checksum == digest, name + ' checksum must describe the emitted Secret' + if manifests[0].strip(): + emitted = resource('secret.yaml').split('# Source: skillhub/templates/secret.yaml', 1)[1].strip() + assert emitted == manifests[0].decode().strip(), 'Actual Secret and checksum input must match' + else: + assert not any('# Source: skillhub/templates/secret.yaml' in part for part in parts), 'existingSecret must not emit an application Secret' + return digest, manifests[0] + +generated_args = ['--set', 'secrets.allowAutoGenerated=true', + '--set-string', 'secrets.bootstrapAdminPassword=', + '--set-string', 'secrets.downloadAnonCookieSecret=', + '--set-string', 'bootstrapAdmin.password='] +generated, manifest = render(*generated_args) +assert re.search(rb'bootstrap-admin-password: "[A-Za-z0-9]{16}"', manifest), 'Bootstrap password must be generated' +assert re.search(rb'skillhub-download-anon-cookie-secret: "[A-Za-z0-9]{48}"', manifest), 'Download signing secret must be generated' +assert render(*generated_args)[0] != generated, 'A fresh offline render must not reuse another render context' +fixed, _ = render() +for key in ['bootstrapAdminPassword', 'downloadAnonCookieSecret']: + assert render('--set-string', 'secrets.' + key + '=rotated-synthetic-secret-at-least-48-characters-long')[0] != fixed, 'Explicit secret rotation must change checksums' +analytics = chart / 'analytics-values.yaml' +analytics.write_text(json.dumps({'web': {'analytics': { + 'config': json.dumps({'enabled': True, 'adapter': 'umami', 'consent': 'granted', + 'adapterConfig': {'websiteId': '4398de8e-40df-4483-9eea-ed213643525f'}}), + 'upstream': 'http://umami.stats.svc.cluster.local:3000'}}})) +assert render('-f', str(analytics))[0] == fixed, 'Analytics-only changes must preserve explicit-secret checksums' +assert not render('--set-string', 'existingSecret=external-application-secret')[1].strip(), 'External Secret path must not generate credentials' +print('Helm application Secret render/rotation contracts passed') +PY_SECRET_RENDER + echo "Helm configuration contract tests passed" diff --git a/charts/skillhub/tests/datafinder-values.yaml b/charts/skillhub/tests/datafinder-values.yaml new file mode 100644 index 000000000..1fd170771 --- /dev/null +++ b/charts/skillhub/tests/datafinder-values.yaml @@ -0,0 +1,4 @@ +web: + analytics: + config: '{"enabled":true,"adapter":"datafinder","consent":"granted","adapterConfig":{"appId":123456,"product":"skillhub"}}' + upstream: https://collector.example.com diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 23aa75111..af42bc723 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -545,6 +545,14 @@ "service": { "$ref": "#/definitions/service" }, "basePath": { "type": "string", "pattern": "^$|^/$|^(/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+/$" }, "apiBaseUrl": { "type": "string" }, + "analytics": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": { "type": "string" }, + "upstream": { "type": "string" } + } + }, "resources": { "$ref": "#/definitions/resources" }, "extraEnv": { "type": "array", "items": { "type": "object" } }, "podAnnotations": { "$ref": "#/definitions/stringMap" }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 3ad732a56..9dfd6b47a 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -455,6 +455,11 @@ web: basePath: "" apiBaseUrl: "" + # 默认关闭。config 是与 Compose 相同的 JSON 字符串;不放平台密钥。 + analytics: + config: "" + upstream: "" + service: enabled: true type: ClusterIP diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 5622c3832..7d36dbbc3 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,29 @@ All notable CLI behavior changes are documented in this file. +## 0.3.0 + +### Changed + +- Retain strict inventory v2 and derive activation from trusted entry files, without a second + persisted activation state. Preserve normal published CLI 0.2.0 interoperability. +- Preserve disabled installations across namespace synchronization and doctor reconciliation. + CLI upgrade refuses disabled targets, including with `--force`; Suite operations cannot adopt + disabled positions. This release does not add CLI enable/disable commands. +- Supply the compatible local installation core for DSH `0.6.0`, including entry-only + enable/disable and recovery. The DSH plugin bundles this core and does not invoke CLI commands. + +### Upgrade and rollback + +- No coordinated CLI upgrade is required for shared inventory. Historical explicit force + install/upgrade/sync may overwrite disabled entries; explicit remove may delete them. + New readers must reflect the resulting files; default paths must not unintentionally enable. +- Unpublished v3 inventories require validated, locked conversion with original-byte backup. + Compatibility implementation and real old-package acceptance are in progress; publication + remains blocked until they pass. +- Candidate versions are not a publication claim. Final package integrity, clean installation + and migration acceptance must be recorded separately for the published packages. + ## 0.2.0 ### Added diff --git a/cli/README.md b/cli/README.md index 66da5873a..9d4db4f0a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -621,7 +621,7 @@ subject to the server's package validation. ### Installation inventory compatibility -The local installation inventory uses schema version 3 in the unreleased enable/disable increment. Each installation directory owns its +Version `0.3.0` retains installation inventory schema version 2. Each installation directory owns its version, fingerprint, timestamp and ownership, so a user installation and a project installation of the same skill can remain on different versions. `list --json` reports the actual version for each target. `upgrade --check --json` and upgrade results contain one row per selected installation @@ -631,16 +631,9 @@ Direct install/upgrade/remove and sync pruning refuse Suite-owned or shared posi with `--force`. Manage those positions through Suite commands; unrelated direct installations remain independently manageable. -Reading a v1/v2 inventory does not change it. The first mutation validates it, preserves its -original bytes in an immutable digest-addressed backup beside `inventory.json`, and atomically -writes v3 (`schemaVersion`, `records`, `suites`). Each target records `installationState` -(`enabled` or `disabled`); legacy targets project to enabled without inventing missing integrity. +Shared inventory remains strict v2 without a persisted `installationState`; activation is derived from trusted `SKILL.md` / `SKILL.md.disabled` entries. A coordinated CLI upgrade is not required. Published CLI `0.2.0` must retain normal queries and ordinary-target management; default update, sync and Suite paths must not unintentionally enable disabled targets. Historical explicit force install, upgrade and sync retain their overwrite semantics and may restore the standard entry; explicit remove retains deletion semantics. New readers must reflect these actual file changes. -Stop incompatible CLI writers and let DSH finish pending recovery before migration. Published -CLI `0.2.0` and `0.1.12` must not share writes with v3. Protocol rejection is not a complete -cross-process guard: historical commands have different read paths. Release the compatible CLI -before exposing this plugin increment. Restoring old inventory alone is not a safe downgrade; -verify entry names, package bytes, metadata and operation logs together. +Reading v1/v2 is non-mutating. Unpublished v3 inventories require validated, locked conversion to v2 with original-byte backup; never restore stale inventory over newer records. Ordinary DSH journals remain compatible; toggle journals must not block unrelated historical operations. Compatibility fixes and real historical-package acceptance are in progress; release remains blocked until they pass. This CLI does not add enable/disable commands. Use DSH for explicit toggles and disabled updates; CLI upgrade refuses disabled targets even with `--force`. Namespace sync retains disabled diff --git a/cli/package.json b/cli/package.json index 990587273..3f60629e6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub", - "version": "0.2.0", + "version": "0.3.0", "description": "Manage and install skills for AI coding agents", "keywords": [ "skillhub", diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index 4391833da..f871a8442 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-pkg-info.ts - do not edit by hand. export const PKG_NAME = "@astron-team/skillhub" -export const PKG_VERSION = "0.2.0" +export const PKG_VERSION = "0.3.0" diff --git a/cli/src/integrations/change-tree-budget.ts b/cli/src/integrations/change-tree-budget.ts new file mode 100644 index 000000000..b9557a12e --- /dev/null +++ b/cli/src/integrations/change-tree-budget.ts @@ -0,0 +1,24 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { MAX_SINGLE_FILE_BYTES, MAX_ZIP_ENTRIES } from '../platform/archive' +import { MAX_PACKAGE_BYTES } from '../platform/download' + +/** New automatic comparison reads enforce the package budget before allocating file bodies. */ +export async function assertChangeTreeBudget(root: string): Promise { + let bytes = 0, files = 0, entries = 0 + const walk = async (path: string): Promise => { + if (++entries > MAX_ZIP_ENTRIES * 4) throw new Error('Change tree exceeds entry limit') + const info = await lstat(path) + if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory()) + || (process.getuid && info.uid !== process.getuid())) throw new Error('Unsafe change tree') + if (info.isDirectory()) { + for (const name of await readdir(path)) await walk(join(path, name)) + } else { + if (info.nlink !== 1 || info.size > MAX_SINGLE_FILE_BYTES) throw new Error('Change file exceeds package limit') + bytes += info.size + if (relative(root, path).split('\\').join('/') !== '.skillhub/metadata.json') files++ + if (bytes > MAX_PACKAGE_BYTES || files > MAX_ZIP_ENTRIES) throw new Error('Change tree exceeds package limit') + } + } + await walk(root) +} diff --git a/cli/src/integrations/install-recovery.ts b/cli/src/integrations/install-recovery.ts index a415041bb..56bc30217 100644 --- a/cli/src/integrations/install-recovery.ts +++ b/cli/src/integrations/install-recovery.ts @@ -1,6 +1,8 @@ +import { verifyBackup } from './native-changes' +import type { NativeUpgradeExecution } from './native-contract' import { snapshotInstalledSkillDirectory, snapshotJournalInstallation } from '../services/installed-skill-snapshot' -import { lstat, realpath, rm } from 'node:fs/promises' -import { basename, dirname, isAbsolute, resolve } from 'node:path' +import { readFile, lstat, realpath, rm } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { captureInstallInventory } from '../services/inventory-cas' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' @@ -224,7 +226,7 @@ async function cleanupBackups(options: InstallRecoveryOptions, current: Operatio await options.authorizeMutation?.([path], 'cleanup') // Policy checks may yield; verify the same bound content again before deletion. if (await verifiedBackupPath(current, entry.move) !== path) throw new Error('Backup path changed') - await assertBackupContent(current, entry.move, path) + await assertBackupContent(options.operations.root, current, entry.move, path) if ((await options.operations.get(current.operationId, current.ownerId))?.revision !== revision) throw new Error('Operation changed before backup cleanup') await rm(path, { recursive: true }) } @@ -244,7 +246,7 @@ async function cleanupBackups(options: InstallRecoveryOptions, current: Operatio return { revision, results } } -async function assertBackupContent(record: OperationRecord, move: InstallMove, path: string): Promise { +async function assertBackupContent(stateDirectory: string, record: OperationRecord, move: InstallMove, path: string): Promise { await assertOwnedRegularTree(path) const identity = { registry: record.operation.context.sourceIdentity, namespace: record.operation.namespace, slug: record.operation.slug } const items = record.transaction!.inventoryCommit!.before.items.filter(item => sameInstalledSkillSource(item, identity)) @@ -265,7 +267,17 @@ async function assertBackupContent(record: OperationRecord, move: InstallMove, p || metadata.fingerprint !== target.fingerprint || metadata.installedAt !== target.installedAt || metadata.agent !== target.agent || metadata.source !== 'skillhub') throw new Error('Backup metadata does not match its baseline') const snapshot = await snapshotJournalInstallation(record, path) - if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) throw new Error('Backup content was modified') + if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { + const proof = JSON.parse(await readFile(join(dirname(stateDirectory), 'dirty-upgrade-proofs', `${record.operationId}.json`), 'utf8')) as { + ownerId: string; authorization: NonNullable + } + const authorization = proof.authorization + if (proof.ownerId !== record.ownerId || authorization.snapshot.baseline.targets.length !== 1 + || authorization.snapshot.baseline.targets[0]!.path !== move.canonicalSkillDir + || !isDeepStrictEqual(metadata, authorization.snapshot.baseline.targets[0]!.metadata) + || !isDeepStrictEqual(snapshot.files, authorization.snapshot.baseline.targets[0]!.files)) throw new Error('Backup content was modified') + await verifyBackup(stateDirectory, authorization.snapshot, authorization.backupReceipt) + } } async function releaseLocks(releases: Array<() => Promise>, operationError: unknown): Promise { diff --git a/cli/src/integrations/installed-projection.ts b/cli/src/integrations/installed-projection.ts index a1c31e11e..d607f18b1 100644 --- a/cli/src/integrations/installed-projection.ts +++ b/cli/src/integrations/installed-projection.ts @@ -3,10 +3,10 @@ import { createHash } from 'node:crypto' import { lstat, realpath } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' import { z } from 'zod' -import type { InventoryStore } from '../stores/inventory-store' +import { readInstallationState, type InventoryStore } from '../stores/inventory-store' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' -import type { InstalledProjection, InstalledProjectionItem, InstalledProjectionTarget } from './native-contract' +import type { InstalledProjection, InstalledProjectionItem, InstalledProjectionTarget, AllInstalledProjection } from './native-contract' export type { InstalledProjection, InstalledProjectionItem, InstalledProjectionTarget } from './native-contract' const text = z.string().min(1) @@ -16,18 +16,50 @@ const itemSchema = z.object({ registry: text, namespace: text, slug: text, targe /** Read only the selected Registry's DSH records in Host-approved roots; never repair or hash all files. */ export async function readInstalledProjection(inventory: Pick, registry: string, + targets: readonly InstalledProjectionTarget[]): Promise { + let records: unknown[] + try { + const raw: unknown = await inventory.read() + records = z.object({ items: z.array(z.unknown()) }).parse(raw).items + } catch { return { items: [], complete: false, issues: ['inventory-unreadable'] } } + return readInstalledProjectionRecords(records, registry, targets) +} + +/** Read trusted DSH installations across sources, without changing the default source-filtered API. */ +export async function readAllInstalledProjection(inventory: Pick, + targets: readonly InstalledProjectionTarget[]): Promise { + let records: unknown[] + try { + records = z.object({ items: z.array(z.unknown()) }).parse(await inventory.read()).items + } catch { return { items: [], complete: false, issues: ['inventory-unreadable'] } } + const result: AllInstalledProjection = { items: [], complete: true, issues: [] } + const registries = new Map() + for (const record of records) { + const parsed = itemSchema.safeParse(record) + if (parsed.success) { + const registry = parsed.data.registry.replace(/\/+$/, '') + const bucket = registries.get(registry) ?? [] + bucket.push(record); registries.set(registry, bucket) + } + else { result.complete = false; result.issues = ['invalid-record'] } + } + for (const [registry, sourceRecords] of registries) { + const projection = await readInstalledProjectionRecords(sourceRecords, registry, targets) + result.items.push(...projection.items.map(item => ({ ...item, registry }))) + result.complete &&= projection.complete + result.issues = [...new Set([...result.issues, ...projection.issues])] + } + return result +} + +/** Internal projection primitive: callers must obtain records from a trusted inventory snapshot. */ +export async function readInstalledProjectionRecords(records: readonly unknown[], registry: string, targets: readonly InstalledProjectionTarget[]): Promise { const result: InstalledProjection = { items: [], complete: true, issues: [] } const issue = (code: InstalledProjection['issues'][number]) => { result.complete = false if (!result.issues.includes(code)) result.issues.push(code) } - let records: unknown[] - try { - const raw: unknown = await inventory.read() - const parsed = z.object({ items: z.array(z.unknown()) }).parse(raw) - records = parsed.items - } catch { issue('inventory-unreadable'); return result } const roots = targets.map(target => { if (!isAbsolute(target.root)) throw new Error('Installed projection requires absolute Host roots') return { ...target, root: resolve(target.root) } @@ -44,7 +76,7 @@ export async function readInstalledProjection(inventory: Pick target.root === resolve(entry.rootDir))) { const path = join(target.root, item.slug) const projected: InstalledProjectionItem = { scope: target.scope, namespace: item.namespace, - slug: item.slug, installationState: entry.installationState ?? 'enabled', version: entry.version, ...(entry.fingerprint ? { fingerprint: entry.fingerprint } : {}), targetPath: path, state: 'present', + slug: item.slug, version: entry.version, ...(entry.fingerprint ? { fingerprint: entry.fingerprint } : {}), targetPath: path, state: 'present', recordRevision: createHash('sha256').update(JSON.stringify([item.registry.replace(/\/+$/, ''), item.namespace, item.slug, entry.installationState ?? 'enabled', entry.agent, entry.rootDir, entry.installDir, entry.version, entry.fingerprint ?? null, entry.installedAt, [...new Set(entry.installedBy ?? ['direct'])].sort()])).digest('hex') } @@ -67,11 +99,14 @@ export async function readInstalledProjection(inventory: Pick, + registry: string, targets: readonly InstalledProjectionTarget[]) { + const identity = async () => { + const stat = await lstat(inventory.path, { bigint: true }).catch(error => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + }) + if (!stat) return 'absent' + if (!stat.isFile()) throw new Error('Inventory is not a regular file') + return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':') + } + const revision = await identity() + const records = (await inventory.read()).items + const assertCurrent = async () => { + if (await identity() !== revision) throw new Error('Inventory changed during update scan') + } + await assertCurrent() + const projection = await readInstalledProjectionRecords(records, registry, targets) + await assertCurrent() + const key = (item: Pick) => JSON.stringify([item.namespace, item.slug, item.scope]) + const index = new Map() + for (const record of records) { + for (const target of targets) { + const entries = record.targets.filter(entry => isAbsolute(entry.rootDir) && resolve(entry.rootDir) === resolve(target.root) && entry.agent === 'dsh') + if (!entries.length) continue + const id = key({ ...record, scope: target.scope }) + const bucket = index.get(id) ?? [] + bucket.push({ ...record, targets: entries }); index.set(id, bucket) + } + } + return { projection, async read(item: InstalledProjectionItem) { + await assertCurrent() + const result = await readInstalledProjectionRecords(index.get(key(item)) ?? [], registry, targets.filter(target => target.scope === item.scope)) + await assertCurrent() + const current = result.items.find(value => value.targetPath === item.targetPath) + if (!current || JSON.stringify(current) !== JSON.stringify(item)) throw new Error('Installed target changed during update scan') + return current + } } +} diff --git a/cli/src/integrations/native-changes.ts b/cli/src/integrations/native-changes.ts new file mode 100644 index 000000000..987d42bac --- /dev/null +++ b/cli/src/integrations/native-changes.ts @@ -0,0 +1,212 @@ +import { createHash, randomUUID } from 'node:crypto' +import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat } from 'node:fs/promises' +import { dirname, isAbsolute, join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { SkillHubClient } from '../clients/skillhub-client' +import { extractZip } from '../platform/archive' +import { MAX_PACKAGE_BYTES, readBoundedResponseBody } from '../platform/download' +import { diffSkillFiles, snapshotSkillDirectory } from '../services/skill-fingerprint' +import { InventoryStore } from '../stores/inventory-store' +import { acquireSkillTargetLock } from '../services/skill-target-lock' +import { inspectUpgradeBaseline } from './upgrade-baseline' +import { assertOwnedRegularTree } from './recovery-files' +import type { BoundedFileDiff, ChangeFile, InstalledProjectionTarget, LocalChangesSnapshot, NativeRemovalExecution, UpgradeSelection, VerifiedBackupReceipt, VersionChangesSnapshot } from './native-contract' + +export type ReadyChanges = Extract +export const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex') +export function changeFiles(before: Record, after: Record): ChangeFile[] { + return diffSkillFiles(before, after).map(path => ({ path, kind: !before[path] ? 'added' : !after[path] ? 'deleted' : 'modified' })) +} +export async function inspectChanges(inventory: InventoryStore, selected: UpgradeSelection, target: InstalledProjectionTarget): Promise { + const baseline = await inspectUpgradeBaseline(inventory, selected, [target], true) + if (baseline.status !== 'ready') return baseline + if (baseline.targets.length !== 1) return { status: 'blocked', reason: 'target-alias' } + const entry = baseline.inventoryItem.targets[0]! + try { await treeBytes(entry.installDir) } catch { return { status: 'blocked', reason: 'target-unavailable' } } + if (!baseline.targets[0]!.files['SKILL.md']) return { status: 'blocked', reason: 'entry-conflict' } + const value = { baseline, selection: { ...selected }, target: { ...target }, installationState: entry.installationState ?? 'enabled', + changes: changeFiles(baseline.targets[0]!.metadata.files!, baseline.targets[0]!.files) } + return { status: 'ready', kind: 'local', ...value, digest: digest(value) } +} +export async function assertChanges(inventory: InventoryStore, snapshot: ReadyChanges): Promise { + const current = await inspectChanges(inventory, snapshot.selection, snapshot.target) + if (!isDeepStrictEqual(current, snapshot)) throw new Error('Local changes snapshot is stale') +} +export function physicalPath(snapshot: ReadyChanges, path: string): string { + if (!path || isAbsolute(path) || path.includes('\\') || path.split('/').some(part => !part || part === '.' || part === '..') + || path === '.skillhub/metadata.json' || path === 'SKILL.md.disabled') throw new Error('Invalid change path') + return path === 'SKILL.md' && snapshot.installationState === 'disabled' ? 'SKILL.md.disabled' : path +} +export async function privateDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }) + if (await realpath(path) !== path) throw new Error('Private directory redirects') + const info = await lstat(path) + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new Error('Unsafe private directory') + if (process.platform !== 'win32') await chmod(path, 0o700) +} +export async function durableJson(path: string, value: unknown): Promise { + const temporary = `${path}.${randomUUID()}.tmp` + const file = await open(temporary, 'wx', 0o600) + try { await file.writeFile(JSON.stringify(value)); await file.sync() } finally { await file.close() } + const { renameAtomicFile } = await import('../platform/atomic-rename') + await renameAtomicFile(temporary, path) + await syncDirectory(dirname(path)) +} +export async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return + const directory = await open(path, 'r'); try { await directory.sync() } finally { await directory.close() } +} +export async function syncTree(path: string): Promise { + for (const entry of await readdir(path, {withFileTypes:true})) { + const child = join(path, entry.name) + if (entry.isDirectory()) await syncTree(child) + else { + // Windows FlushFileBuffers needs write access; r+ preserves the copied bytes without truncation. + const file = await open(child, process.platform === 'win32' ? 'r+' : 'r') + try { await file.sync() } finally { await file.close() } + } + } + await syncDirectory(path) +} +/** Exact release only; temporary content is never trusted without hashes and full release identity. */ +export async function withRelease(state: string, identity: UpgradeSelection & {versionId: number; fingerprint: string}, + run: (directory: string, files: Record) => Promise): Promise { + const parent = join(dirname(state), 'change-staging') + await privateDirectory(parent) + const directory = await mkdtemp(join(parent, 'release-')) + try { + const client = new SkillHubClient(identity.registry) + const release = await client.resolve(identity.namespace, identity.slug, identity.version) + if (release.namespace !== identity.namespace || release.slug !== identity.slug || release.version !== identity.version + || release.versionId !== identity.versionId || release.fingerprint !== identity.fingerprint) throw new Error('Original release identity is unavailable') + const response = await client.downloadFromUrl(release.downloadUrl) + await extractZip(await readBoundedResponseBody(response), directory, { rejectInternalMetadata: true }) + const snapshot = await snapshotSkillDirectory(directory) + if (snapshot.fingerprint !== identity.fingerprint) throw new Error('Release fingerprint mismatch') + return await run(directory, snapshot.files) + } finally { await rm(directory, { recursive: true, force: true }) } +} +export async function withOriginal(state: string, snapshot: ReadyChanges, run: (directory: string) => Promise): Promise { + const metadata = snapshot.baseline.targets[0]!.metadata + if (!metadata.versionId || !metadata.fingerprint || !metadata.files) throw new Error('Original release baseline is unavailable') + return withRelease(state, { ...snapshot.selection, versionId: metadata.versionId, fingerprint: metadata.fingerprint }, async (directory, files) => { + if (!isDeepStrictEqual(files, metadata.files)) throw new Error('Original release file map mismatch') + return run(directory) + }) +} +export function boundedDiff(path: string, before: Buffer | undefined, after: Buffer | undefined): BoundedFileDiff { + const result: BoundedFileDiff = { path, state: 'text', lines: [], truncated: false } + if ((before?.length ?? 0) > 256 * 1024 || (after?.length ?? 0) > 256 * 1024) return { ...result, state: 'too-large' } + let a: string[], b: string[] + try { + if (before?.includes(0) || after?.includes(0)) return { ...result, state: 'binary' } + const decoder = new TextDecoder('utf-8', { fatal: true }) + a = before === undefined ? [] : decoder.decode(before).split('\n') + b = after === undefined ? [] : decoder.decode(after).split('\n') + } catch { return { ...result, state: 'binary' } } + // Linear common prefix/suffix; no quadratic LCS on untrusted authored files. + let prefix = 0, suffix = 0 + while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++ + while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++ + const push = (kind: BoundedFileDiff['lines'][number]['kind'], text: string) => { + if (result.lines.length < 2000) result.lines.push({ kind, text }); else result.truncated = true + } + for (const line of a.slice(Math.max(0, prefix - 3), prefix)) push('context', line) + for (const line of a.slice(prefix, a.length - suffix)) push('deleted', line) + for (const line of b.slice(prefix, b.length - suffix)) push('added', line) + for (const line of a.slice(a.length - suffix, a.length - suffix + 3)) push('context', line) + return result +} +async function readOptional(directory: string, path: string, files: Record, logical = path): Promise { + if (!files[logical]) return undefined + const info = await stat(join(directory, path)) + if (info.size > 256 * 1024) return Buffer.alloc(256 * 1024 + 1) + const content = await readFile(join(directory, path)) + if (createHash('sha256').update(content).digest('hex') !== files[logical]) throw new Error('Compared file changed') + return content +} +export async function readChangeDiff(state: string, inventory: InventoryStore, snapshot: ReadyChanges | VersionChangesSnapshot, path: string): Promise { + const local = snapshot.kind === 'local' ? snapshot : snapshot.local + const physical = physicalPath(local, path) + if (!snapshot.changes.some(file => file.path === path)) throw new Error('Path is not in the change snapshot') + await assertChanges(inventory, local) + const result = await withOriginal(state, local, async directory => { + const before = await readOptional(directory, path, local.baseline.targets[0]!.metadata.files!) + if (snapshot.kind === 'local') return boundedDiff(path, before, + await readOptional(local.baseline.targets[0]!.path, physical, local.baseline.targets[0]!.files, path)) + return withRelease(state, { ...local.selection, ...snapshot.candidate }, async (candidate, files) => { + if (!isDeepStrictEqual(files, snapshot.files)) throw new Error('Candidate changed') + return boundedDiff(path, before, await readOptional(candidate, path, files)) + }) + }) + await assertChanges(inventory, local) + return result +} +export async function prepareVersionChanges(state: string, inventory: InventoryStore, selected: UpgradeSelection, + target: InstalledProjectionTarget, candidate: VersionChangesSnapshot['candidate']): Promise { + const local = await inspectChanges(inventory, selected, target) + if (local.status !== 'ready') throw new Error('Installation cannot be compared') + const assessment = (await import('./update-assessment')).assessSkillUpdate({ version: selected.version, + fingerprint: local.baseline.targets[0]!.metadata.fingerprint! }, candidate) + if (assessment.state !== 'upgradable') throw new Error('Candidate is not a newer version') + return withOriginal(state, local, () => withRelease(state, { ...selected, ...candidate }, async (_directory, files) => { + await assertChanges(inventory, local) + return { status: 'ready', kind: 'version', local, candidate: { ...candidate }, files, + changes: changeFiles(local.baseline.targets[0]!.metadata.files!, files) } + })) +} +export async function rawSnapshot(directory: string): Promise<{files: Record; metadata: string}> { + await assertOwnedRegularTree(directory) + await treeBytes(directory) + return { files: (await snapshotSkillDirectory(directory)).files, metadata: await readFile(join(directory, '.skillhub', 'metadata.json'), 'utf8') } +} +export async function treeBytes(directory: string): Promise { + let bytes = 0 + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name), info = await lstat(path) + if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory()) || (info.isFile() && info.nlink !== 1)) throw new Error('Unsafe backup content') + bytes += info.isDirectory() ? await treeBytes(path) : info.size + if (bytes > MAX_PACKAGE_BYTES * 11) throw new Error('Backup quota exceeded') + } + return bytes +} +export async function backupChanges(state: string, inventory: InventoryStore, snapshot: ReadyChanges, + authorize: NativeRemovalExecution['authorize']): Promise { + const parent = join(dirname(state), 'backups') + await privateDirectory(parent) + const quotaRelease = await acquireSkillTargetLock(parent, 'backup-quota') + const targetRelease = await acquireSkillTargetLock(snapshot.target.root, snapshot.selection.slug) + try { + await assertChanges(inventory, snapshot) + const id = randomUUID(), directory = join(parent, id), target = snapshot.baseline.targets[0]!.path + await authorize([target, parent, directory], 'staging', id) + const entries = await readdir(parent) + const bytes = await treeBytes(target) + if (entries.length >= 20 || bytes > MAX_PACKAGE_BYTES || await treeBytes(parent) + bytes > 1024 * 1024 * 1024) throw new Error('Backup quota exceeded') + await mkdir(directory, { mode: 0o700 }) + await privateDirectory(directory) + try { + const before = await rawSnapshot(target) + await cp(target, join(directory, 'files'), { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true }) + if (!isDeepStrictEqual(before, await rawSnapshot(join(directory, 'files')))) throw new Error('Backup verification failed') + await assertChanges(inventory, snapshot) + if (await treeBytes(target) !== bytes || (await readdir(parent)).length > 20 || await treeBytes(parent) > 1024 * 1024 * 1024) throw new Error('Backup quota exceeded') + await syncTree(join(directory, 'files')) + const receipt = { id, snapshotDigest: snapshot.digest, rawDigest: digest(before), createdAt: new Date().toISOString() } + await durableJson(join(directory, 'manifest.json'), { receipt, snapshot, raw: before, bytes }) + if (await treeBytes(parent) > 1024 * 1024 * 1024) throw new Error('Backup quota exceeded') + await syncDirectory(parent) + return receipt + } catch (error) { await rm(directory, { recursive: true, force: true }); throw error } + } finally { await targetRelease(); await quotaRelease() } +} +export async function verifyBackup(state: string, snapshot: ReadyChanges, receipt: VerifiedBackupReceipt): Promise { + if (!/^[0-9a-f-]{36}$/i.test(receipt.id) || receipt.snapshotDigest !== snapshot.digest) throw new Error('Invalid backup receipt') + const directory = join(dirname(state), 'backups', receipt.id) + if (await realpath(directory) !== directory) throw new Error('Backup redirects') + const manifest = join(directory, 'manifest.json'), info = await lstat(manifest) + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || (process.getuid && info.uid !== process.getuid())) throw new Error('Unsafe backup manifest') + const saved = JSON.parse(await readFile(manifest, 'utf8')) + if (!isDeepStrictEqual(saved.receipt, receipt) || !isDeepStrictEqual(saved.snapshot, snapshot) + || digest(saved.raw) !== receipt.rawDigest || !isDeepStrictEqual(await rawSnapshot(join(directory, 'files')), saved.raw)) throw new Error('Backup changed') +} diff --git a/cli/src/integrations/native-contract.d.ts b/cli/src/integrations/native-contract.d.ts index 2ac698f55..f08c052e1 100644 --- a/cli/src/integrations/native-contract.d.ts +++ b/cli/src/integrations/native-contract.d.ts @@ -2,7 +2,7 @@ export interface NativePendingOperation { operationId: string ownerId: string - kind: 'install' | 'upgrade' | 'uninstall' | 'remove-stale-record' | 'enable' | 'disable' + kind: 'install' | 'upgrade' | 'uninstall' | 'remove-stale-record' | 'enable' | 'disable' | 'reset-files' namespace: string slug: string targetPaths: string[] @@ -26,9 +26,12 @@ export interface InstalledProjectionItem { fingerprint?: string /** Internal digest of this exact target's release, timestamp and ownership. */ recordRevision?: string - installationState: InstallationState + installationState?: InstallationState state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable' } +export interface AllInstalledProjection extends InstalledProjection { + items: (InstalledProjectionItem & { registry: string })[] +} export interface InstalledProjection { items: InstalledProjectionItem[] complete: boolean @@ -61,7 +64,41 @@ export interface NativeToggleExecution { baseline: Extract authorize: NativeRemovalExecution['authorize'] } +export interface ChangeFile { path: string; kind: 'added' | 'modified' | 'deleted' } +export interface BoundedFileDiff { + path: string + state: 'text' | 'binary' | 'too-large' | 'unavailable' + lines: { kind: 'context' | 'added' | 'deleted'; text: string }[] + truncated: boolean +} +export type LocalChangesSnapshot = Extract + | { status: 'ready'; kind: 'local'; baseline: Extract; + selection: UpgradeSelection; target: InstalledProjectionTarget; installationState: InstallationState; + changes: ChangeFile[]; digest: string } +export interface VersionChangesSnapshot { + status: 'ready' + kind: 'version' + local: Extract + candidate: { version: string; versionId: number; fingerprint: string } + files: Record + changes: ChangeFile[] +} +export interface VerifiedBackupReceipt { id: string; snapshotDigest: string; rawDigest: string; createdAt: string } +export interface NativeFileResetExecution { + operationId: string + ownerId: string + snapshot: Extract + paths: string[] + authorize: NativeRemovalExecution['authorize'] +} export interface NativeRuntime { + inspectChanges(selected: UpgradeSelection, target: InstalledProjectionTarget): Promise + readChangeDiff(snapshot: Extract | VersionChangesSnapshot, path: string): Promise + prepareVersionChanges(selected: UpgradeSelection, target: InstalledProjectionTarget, + candidate: {version: string; versionId: number; fingerprint: string}): Promise + backupChanges(snapshot: Extract, authorize: NativeRemovalExecution['authorize']): Promise + acceptFileReset(request: NativeOperationRequest, validateNew: () => Promise): Promise + executeFileReset(request: NativeFileResetExecution): Promise /** Host-only verified raw entry, including disabled entries; never accept a Client path. */ readInstallationEntry(selected: UpgradeSelection, target: InstalledProjectionTarget): Promise inspectToggle(selected: UpgradeSelection, target: InstalledProjectionTarget, desiredState: InstallationState): Promise @@ -81,6 +118,8 @@ export interface NativeRuntime { acceptUpgrade(request: NativeOperationRequest, validateNew: () => Promise): Promise inspectUpgrade(selected: UpgradeSelection, targets: readonly InstalledProjectionTarget[]): Promise assessUpdate(installed: UpdateIdentity, candidate: UpdateIdentity): UpdateAssessment + openInstalledSnapshot(registry: string, targets: readonly InstalledProjectionTarget[]): Promise<{ projection: InstalledProjection; read(item: InstalledProjectionItem): Promise }> + listAllInstalled(targets: readonly InstalledProjectionTarget[]): Promise listInstalled(registry: string, targets: readonly InstalledProjectionTarget[]): Promise readonly hostInstanceId: string listPending(): Promise @@ -131,14 +170,14 @@ export interface NativeInstallAcceptance { export interface NativeOperation { operationId: string revision: number - kind: 'install' | 'upgrade' | 'uninstall' | 'remove-stale-record' | 'enable' | 'disable' + kind: 'install' | 'upgrade' | 'uninstall' | 'remove-stale-record' | 'enable' | 'disable' | 'reset-files' namespace: string slug: string version: string targetLabel: string context: NativeInstallAcceptance['context'] & { hostInstanceId: string; createdAt: string; updatedAt: string } phase: 'accepted' | 'staging' | 'revalidating' | 'committing' | 'recovering' | 'completed' | 'failed' | 'recovery-required' | 'unknown' - files: 'pending' | 'installed' | 'already-installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'enabled' | 'disabled' | 'failed' | 'unknown' + files: 'pending' | 'installed' | 'already-installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'enabled' | 'disabled' | 'files-reset' | 'failed' | 'unknown' discovery: 'unconfirmed' | 'confirmed' | 'failed' recovery: 'not-needed' | 'restored' | 'incomplete' | 'unknown' warningCount: number @@ -172,6 +211,7 @@ export interface NativeCore { } export interface NativeUpgradeExecution extends Omit { + localChangesAuthorization?: { snapshot: Extract; backupReceipt: VerifiedBackupReceipt } baseline: Extract } diff --git a/cli/src/integrations/native-file-reset.ts b/cli/src/integrations/native-file-reset.ts new file mode 100644 index 000000000..a8dcb47a2 --- /dev/null +++ b/cli/src/integrations/native-file-reset.ts @@ -0,0 +1,203 @@ +import { randomUUID } from 'node:crypto' +import { cp, mkdir, readFile, realpath, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type { InventoryStore } from '../stores/inventory-store' +import { acquireSkillTargetLock } from '../services/skill-target-lock' +import { assertChanges, durableJson, physicalPath, privateDirectory, rawSnapshot, syncDirectory, syncTree, withOriginal } from './native-changes' +import type { NativeFileResetExecution, NativeInstallAcceptance, NativeOperation, NativeOperationRequest, NativeRecoveryResult } from './native-contract' +import { operationRequestDigest } from './operation-store' + +type Raw = Awaited> +interface ResetRecord { + schemaVersion: 1; operationId: string; ownerId: string; requestKey: string; requestDigest: string; revision: number + operation: NativeOperation; targetPaths: string[] + intent?: { target: string; root: string; slug: string; staging: string; backup: string; before: Raw; after: Raw; + snapshot: NativeFileResetExecution['snapshot']; stage: 'prepared' | 'old-moved' | 'published' | 'committed' | 'restored' } +} +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const terminal = (record: ResetRecord) => ['completed', 'failed'].includes(record.operation.phase) +/** Separate directory and schema: older OperationStore builds never parse extended reset records. */ +export class FileResetStore { + readonly root: string + constructor(private state: string, private hostInstanceId: string, private inventory: InventoryStore, private assertOtherOperations: () => Promise = async () => {}) { + this.root = join(dirname(state), 'file-reset-v1') + } + async get(id: string, owner: string): Promise { + if (!uuid.test(id)) throw new Error('Invalid file reset id') + await privateDirectory(this.root) + let raw: string + try { raw = await readFile(join(this.root, `${id}.json`), 'utf8') } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error } + const value = JSON.parse(raw) as ResetRecord + if (value.schemaVersion !== 1 || value.operationId !== id || value.operation.operationId !== id + || value.operation.kind !== 'reset-files' || !Array.isArray(value.targetPaths) || !Number.isInteger(value.revision) + || !value.ownerId || !value.requestDigest) throw new Error('Corrupt file reset journal') + if (value.ownerId !== owner) throw new Error('File reset is not owned') + if (value.intent) { + const i = value.intent + if (i.target !== join(i.root, i.slug) || i.target !== value.targetPaths[0] + || i.staging !== `${i.target}.skillhub-reset-stage-${id}` || i.backup !== `${i.target}.skillhub-backup-${id}` + || i.snapshot.baseline.targets[0]?.path !== i.target) throw new Error('Corrupt file reset intent') + } + return value + } + async list(): Promise { + await privateDirectory(this.root) + const result: ResetRecord[] = [] + for (const name of await readdir(this.root)) { + if (!name.endsWith('.json')) continue + const value = JSON.parse(await readFile(join(this.root, name), 'utf8')) as ResetRecord + const record = await this.get(name.slice(0, -5), value.ownerId) + if (record) result.push(record) + } + return result + } + async save(record: ResetRecord): Promise { + record.revision++; record.operation.revision = record.revision; record.operation.context.updatedAt = new Date().toISOString() + await durableJson(join(this.root, `${record.operationId}.json`), record) + } + async accept(request: NativeOperationRequest, validate: () => Promise): Promise { + if (request.hostInstanceId !== this.hostInstanceId) throw new Error('Host changed') + await privateDirectory(this.root) + const release = await acquireSkillTargetLock(this.root, 'journal') + try { + const hash = operationRequestDigest({ ...request.parameters, kind: 'reset-files' }) + const previous = (await this.list()).find(record => record.ownerId === request.ownerId && record.requestKey === request.requestKey) + if (previous) { if (previous.requestDigest !== hash) throw new Error('Request changed'); return previous.operation } + if (request.mode === 'retry') return undefined + const input = await validate() + if ((await this.list()).some(record => !terminal(record))) throw new Error('File reset recovery required') + const now = new Date().toISOString(), operationId = randomUUID() + const operation: NativeOperation = { operationId, revision: 0, kind: 'reset-files', namespace: input.namespace, + slug: input.slug, version: input.version, targetLabel: input.targetLabel, + context: { ...input.context, hostInstanceId: this.hostInstanceId, createdAt: now, updatedAt: now }, + phase: 'accepted', files: 'pending', discovery: 'unconfirmed', recovery: 'not-needed', warningCount: 0 } + await this.save({ schemaVersion: 1, operationId, ownerId: request.ownerId, requestKey: request.requestKey, + requestDigest: hash, revision: 0, operation, targetPaths: [...input.targetPaths] }) + return operation + } finally { await release() } + } + async execute(input: NativeFileResetExecution): Promise { + const request = { ...input, snapshot: structuredClone(input.snapshot), paths: [...input.paths] } + const lease = await acquireSkillTargetLock(this.root, `execution-${request.operationId}`) + try { + const record = await this.get(request.operationId, request.ownerId) + if (!record || record.operation.phase !== 'accepted') throw new Error('File reset is not accepted') + const snapshot = request.snapshot, target = snapshot.baseline.targets[0]!.path + if (record.operation.version !== snapshot.selection.version || record.operation.namespace !== snapshot.selection.namespace + || record.operation.slug !== snapshot.selection.slug || record.operation.context.sourceIdentity !== snapshot.selection.registry + || record.targetPaths.length !== 1 || record.targetPaths[0] !== target || !request.paths.length + || new Set(request.paths).size !== request.paths.length + || request.paths.some(path => !snapshot.changes.some(file => file.path === path))) throw new Error('File reset parameters changed') + const staging = `${target}.skillhub-reset-stage-${request.operationId}`, backup = `${target}.skillhub-backup-${request.operationId}` + let stagingCreated = false + try { + await withOriginal(this.state, snapshot, async original => { + const release = await acquireSkillTargetLock(snapshot.target.root, snapshot.selection.slug) + try { await this.inventory.withLockedSnapshot(async () => { + await this.assertOtherOperations() + await assertChanges(this.inventory, snapshot) + await request.authorize([snapshot.target.root, target, staging, backup], 'staging', record.operationId) + await mkdir(staging, { mode: 0o700 }); stagingCreated = true + const before = await rawSnapshot(target) + await cp(target, staging, { recursive: true, force: false, preserveTimestamps: true }) + if (!isDeepStrictEqual(await rawSnapshot(staging), before)) throw new Error('Target changed while copying') + await assertChanges(this.inventory, snapshot) + for (const path of request.paths) { + const physical = physicalPath(snapshot, path), destination = join(staging, physical) + if (!snapshot.baseline.targets[0]!.metadata.files![path]) await unlink(destination) + else { await mkdir(dirname(destination), { recursive: true }); await writeFile(destination, await readFile(join(original, path)), { mode: 0o600 }) } + } + const after = await rawSnapshot(staging) + await syncTree(staging) + await assertChanges(this.inventory, snapshot) + record.intent = { target, root: snapshot.target.root, slug: snapshot.selection.slug, staging, backup, before, after, snapshot, stage: 'prepared' } + record.operation.phase = 'committing'; await this.save(record) + await request.authorize([target, staging, backup], 'replace', record.operationId) + await this.assertOtherOperations() + await rename(target, backup); await syncDirectory(snapshot.target.root); record.intent.stage = 'old-moved'; await this.save(record) + if (!isDeepStrictEqual(await rawSnapshot(backup), before)) throw new Error('Files changed before reset') + await rename(staging, target); await syncDirectory(snapshot.target.root); record.intent.stage = 'published'; await this.save(record) + if (!isDeepStrictEqual(await rawSnapshot(target), after)) throw new Error('Reset result changed') + record.intent.stage = 'committed'; record.operation.phase = 'committing'; record.operation.files = 'files-reset'; await this.save(record) + await request.authorize([backup], 'cleanup', record.operationId) + if (!isDeepStrictEqual(await rawSnapshot(backup), before)) throw new Error('Reset backup changed') + await rm(backup, { recursive: true }); await syncDirectory(snapshot.target.root) + record.operation.phase = 'completed'; await this.save(record) + }) } finally { await release() } + }) + } catch { + const current = await this.get(record.operationId, record.ownerId) + if (current?.intent) { + current.operation.phase = 'recovery-required'; current.operation.recovery = 'unknown'; current.operation.files = 'unknown'; await this.save(current) + return { operationId: current.operationId, revision: current.revision, outcome: 'unknown', reason: 'file-reset-recovery-required' } + } + // No move intent exists: the skill is untouched. Remove only this operation's staging. + try { if (stagingCreated) { await request.authorize([staging], 'cleanup', record.operationId); await rm(staging, { recursive: true, force: true }) } } catch { /* leave bounded staging for diagnosis */ } + record.operation.phase = 'failed'; record.operation.files = 'failed'; await this.save(record) + } + const current = (await this.get(record.operationId, record.ownerId))! + return { operationId: current.operationId, revision: current.revision, + outcome: current.operation.phase === 'completed' ? 'committed' : 'inventory-before', reason: current.operation.phase } + } finally { await lease() } + } + async recover(id: string, owner: string, trusted: readonly string[], authorize: (paths: readonly string[], phase: 'cleanup' | 'rollback') => Promise): Promise { + const lease = await acquireSkillTargetLock(this.root, `execution-${id}`) + try { + const record = await this.get(id, owner) + if (!record) throw new Error('Reset record missing') + const i = record.intent + if (!i) { + record.operation.phase = 'failed'; record.operation.files = 'failed'; await this.save(record) + return { operationId: id, revision: record.revision, outcome: 'inventory-before', reason: 'no-reset-intent' } + } + if (trusted.length !== 1 || trusted[0] !== i.target) throw new Error('Recovery target changed') + const release = await acquireSkillTargetLock(i.root, i.slug) + try { return await this.inventory.withLockedSnapshot(async () => { + const inventory = await this.inventory.read() + const ownerItem = inventory.items.find(item => item.registry === i.snapshot.selection.registry && item.namespace === i.snapshot.selection.namespace && item.slug === i.slug) + const withoutState = (value: unknown) => { const copy = { ...(value as Record) }; delete copy.installationState; return copy } + if (!ownerItem || !isDeepStrictEqual(withoutState(ownerItem.targets.find(t => t.installDir === i.target)), withoutState(i.snapshot.baseline.inventoryItem.targets[0]))) throw new Error('Reset recovery ownership changed') + const read = async (path: string): Promise => { + try { return await rawSnapshot(path) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error } + } + if (await realpath(i.root) !== i.root) throw new Error('Reset root redirects') + const target = await read(i.target), backup = await read(i.backup), staging = await read(i.staging) + if (i.stage === 'committed') { + if (!isDeepStrictEqual(target, i.after) || (backup && !isDeepStrictEqual(backup, i.before)) || staging) throw new Error('Committed reset changed') + if (backup) { + await authorize([i.backup], 'cleanup') + if (await realpath(i.root) !== i.root) throw new Error('Reset root redirects') + if (!isDeepStrictEqual(await read(i.backup), backup) || !isDeepStrictEqual(await read(i.target), target)) throw new Error('Reset recovery content changed after authorization') + await rm(i.backup, { recursive: true }); await syncDirectory(i.root) + } + record.operation.phase = 'completed'; record.operation.files = 'files-reset'; record.operation.recovery = 'not-needed'; await this.save(record) + return { operationId: id, revision: record.revision, outcome: 'committed', reason: 'reset-committed' } + } + if (target && !isDeepStrictEqual(target, i.before) && !isDeepStrictEqual(target, i.after)) throw new Error('Recovery refuses changed target') + if (backup && !isDeepStrictEqual(backup, i.before)) throw new Error('Recovery backup changed') + if (staging && !isDeepStrictEqual(staging, i.after)) throw new Error('Recovery staging changed') + if (!target && !backup) throw new Error('No verified original remains') + if (backup) { + await authorize([i.target, i.backup], 'rollback') + if (await realpath(i.root) !== i.root) throw new Error('Reset root redirects') + if (!isDeepStrictEqual(await read(i.target), target) || !isDeepStrictEqual(await read(i.backup), backup) || !isDeepStrictEqual(await read(i.staging), staging)) throw new Error('Reset recovery content changed after authorization') + if (target) await rm(i.target, { recursive: true }) + await rename(i.backup, i.target); await syncDirectory(i.root) + } else if (!isDeepStrictEqual(target, i.before)) throw new Error('Original reset target missing') + if (staging) { + await authorize([i.staging], 'cleanup') + if (!isDeepStrictEqual(await read(i.staging), staging)) throw new Error('Reset staging changed after authorization') + await rm(i.staging, { recursive: true }); await syncDirectory(i.root) + } + record.intent!.stage = 'restored'; record.operation.phase = 'failed'; record.operation.files = 'failed'; record.operation.recovery = 'restored'; await this.save(record) + return { operationId: id, revision: record.revision, outcome: 'restored', reason: 'reset-restored' } + }) } finally { await release() } + } catch (error) { + const record = await this.get(id, owner) + if (record) { record.operation.phase = 'recovery-required'; record.operation.recovery = 'unknown'; await this.save(record) } + throw error + } finally { await lease() } + } +} diff --git a/cli/src/integrations/native-runtime.ts b/cli/src/integrations/native-runtime.ts index b3e1b89ef..a22948da4 100644 --- a/cli/src/integrations/native-runtime.ts +++ b/cli/src/integrations/native-runtime.ts @@ -1,3 +1,5 @@ +import { FileResetStore } from './native-file-reset' +import { backupChanges, inspectChanges, prepareVersionChanges, readChangeDiff } from './native-changes' import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -5,7 +7,7 @@ import { isDeepStrictEqual } from 'node:util' import { inspectToggle, executeNativeToggle, recoverToggle } from './native-toggle' import { readSkillExamples } from '../services/skill-examples' import { observeInstallationDiscovery } from './install-discovery' -import { readInstalledProjection } from './installed-projection' +import { readInstalledProjection, readAllInstalledProjection, openInstalledProjectionSnapshot } from './installed-projection' import { assessSkillUpdate } from './update-assessment' import { inspectUpgradeBaseline } from './upgrade-baseline' import { inspectRemovalBaseline } from './removal-baseline' @@ -24,14 +26,30 @@ import { acquireSkillTargetLock } from '../services/skill-target-lock' export async function openNativeRuntime(options: { stateDirectory: string; inventoryHome: string }): Promise { const operations = await OperationStore.open(options.stateDirectory) const inventory = new InventoryStore(options.inventoryHome) + const resets = new FileResetStore(options.stateDirectory, operations.hostInstanceId, inventory, async () => { + if ((await operations.listForRecovery()).length) throw new Error('Operation recovery required') + }) const ensureCompatible = async () => { - if ((await operations.listForRecovery()).some(record => record.schemaVersion < 3)) { + if (await inventory.diskVersion() === 3 && (await operations.listForRecovery()).length) { throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') } + if ((await resets.list()).some(record => !['completed', 'failed'].includes(record.operation.phase))) throw new Error('File reset recovery required') await inventory.ensureWritable() } - return { + const runtime: NativeRuntime = { hostInstanceId: operations.hostInstanceId, + inspectChanges: (selected, target) => inspectChanges(inventory, selected, target), + readChangeDiff: (snapshot, path) => readChangeDiff(options.stateDirectory, inventory, snapshot, path), + prepareVersionChanges: (selected, target, candidate) => prepareVersionChanges(options.stateDirectory, inventory, selected, target, candidate), + backupChanges: (snapshot, authorize) => backupChanges(options.stateDirectory, inventory, snapshot, authorize), + acceptFileReset: async (request, validate) => { + return resets.accept(request, async () => { + await ensureCompatible() + if ((await operations.listForRecovery()).length) throw new Error('Operation recovery required') + return validate() + }) + }, + executeFileReset: request => resets.execute(request), readInstallationEntry: async (selected, target) => { const baseline = await inspectUpgradeBaseline(inventory, { ...selected }, [{ ...target }]) if (baseline.status !== 'ready' || baseline.targets.length !== 1) return undefined @@ -46,6 +64,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven const result = await operations.accept({ hostInstanceId: request.hostInstanceId, ownerId: request.ownerId, requestKey: request.requestKey, mode: request.mode, requestDigest: operationRequestDigest({ ...request.parameters, kind: 'toggle' }) }, async () => { + await ensureCompatible() const input = await validateNew() if (!['enable', 'disable'].includes(input.kind)) throw new Error('Expected toggle acceptance') return input @@ -63,6 +82,11 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven operations, inventory, operationId, ownerId, trustedRoots, observe }), findPlanOperation: async request => { + const reset = (await resets.list()).find(record => record.ownerId === request.ownerId && record.requestKey === request.requestKey) + if (reset) { + if (reset.requestDigest !== operationRequestDigest({ ...request.parameters, kind: 'reset-files' })) throw new OperationStoreError('REQUEST_KEY_CONFLICT') + return reset.operation + } let conflict: OperationStoreError | undefined // Preserve existing per-kind digests, including operations accepted by earlier plugin builds. for (const kind of ['upgrade', 'removal', 'toggle'] as const) { @@ -94,6 +118,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven const result = await operations.accept({ hostInstanceId: request.hostInstanceId, ownerId: request.ownerId, requestKey: request.requestKey, mode: request.mode, requestDigest: operationRequestDigest({ ...request.parameters, kind: 'removal' }) }, async () => { + await ensureCompatible() const input = await validateNew() if (!['uninstall', 'remove-stale-record'].includes(input.kind)) throw new Error('Expected removal acceptance') return input @@ -103,18 +128,20 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven inspectRemoval: (selected, target) => inspectRemovalBaseline(inventory, { ...selected }, { ...target }), executeUpgrade: async request => { await ensureCompatible() - const snapshot = { ...request, baseline: structuredClone(request.baseline) } + const snapshot = { ...request, baseline: structuredClone(request.baseline), ...(request.localChangesAuthorization ? { localChangesAuthorization: structuredClone(request.localChangesAuthorization) } : {}) } return withOperationLease(operations, snapshot.operationId, snapshot.ownerId, () => executeNativeUpgrade(operations, inventory, snapshot)) }, acceptUpgrade: async (request, validateNew) => { const result = await operations.accept({ hostInstanceId: request.hostInstanceId, ownerId: request.ownerId, requestKey: request.requestKey, mode: request.mode, requestDigest: operationRequestDigest({ ...request.parameters, kind: 'upgrade' }) }, - async () => ({ ...await validateNew(), kind: 'upgrade' })) + async () => { await ensureCompatible(); return { ...await validateNew(), kind: 'upgrade' } }) return result ? projectOperation(result) : undefined }, assessUpdate: assessSkillUpdate, inspectUpgrade: (selected, targets) => inspectUpgradeBaseline(inventory, { ...selected }, targets.map(target => ({ ...target }))), + openInstalledSnapshot: (registry, targets) => openInstalledProjectionSnapshot(inventory, registry, targets), + listAllInstalled: targets => readAllInstalledProjection(inventory, targets.map(target => ({ ...target }))), listInstalled: (registry, targets) => readInstalledProjection(inventory, registry, targets.map(target => ({ ...target }))), executeInstall: async request => { await ensureCompatible() @@ -125,19 +152,22 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven acceptInstall: async (request, validateNew) => { const result = await operations.accept({ hostInstanceId: request.hostInstanceId, ownerId: request.ownerId, requestKey: request.requestKey, mode: request.mode, requestDigest: operationRequestDigest(request.parameters) }, - async () => ({ ...await validateNew(), kind: 'install' })) + async () => { await ensureCompatible(); return { ...await validateNew(), kind: 'install' } }) return result ? projectOperation(result) : undefined }, getOperation: async (operationId, ownerId) => { + const reset = await resets.get(operationId, ownerId) + if (reset) return reset.operation const result = await operations.get(operationId, ownerId) return result ? projectOperation(result) : undefined }, - listPending: async () => (await operations.listForRecovery()).map(record => ({ + listPending: async () => [...(await resets.list()).filter(record => !['completed', 'failed'].includes(record.operation.phase)).map(record => ({ operationId: record.operationId, ownerId: record.ownerId, kind: record.operation.kind, namespace: record.operation.namespace, slug: record.operation.slug, targetPaths: record.targetPaths })), ...(await operations.listForRecovery()).map(record => ({ operationId: record.operationId, ownerId: record.ownerId, kind: record.operation.kind, namespace: record.operation.namespace, slug: record.operation.slug, targetPaths: [...record.operation.targetPaths ?? []] - })), + }))], recover: async (operationId, ownerId, trustedTargetPaths, authorize) => { if (typeof authorize !== 'function') throw new Error('Native recovery requires Host authorization') + if (await resets.get(operationId, ownerId)) return resets.recover(operationId, ownerId, trustedTargetPaths, authorize) const paths = [...trustedTargetPaths] const result = await withOperationLease(operations, operationId, ownerId, async () => { const record = await operations.get(operationId, ownerId) @@ -149,6 +179,20 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven return { operationId: result.operationId, revision: result.revision, outcome: result.outcome, reason: result.reason } } } + const wrap = (accept: ( + request: import('./native-contract').NativeOperationRequest, validate: () => Promise + ) => Promise) => async ( + request: import('./native-contract').NativeOperationRequest, validate: () => Promise + ) => { + const release = await acquireAdmission(options.stateDirectory) + try { return await accept(request, validate) } finally { await release() } + } + runtime.acceptFileReset = wrap(runtime.acceptFileReset) + runtime.acceptToggle = wrap(runtime.acceptToggle) + runtime.acceptRemoval = wrap(runtime.acceptRemoval) + runtime.acceptUpgrade = wrap(runtime.acceptUpgrade) + runtime.acceptInstall = wrap(runtime.acceptInstall) + return runtime } /** Serialize a whole native execution/recovery, including staging before target locks are acquired. */ @@ -166,3 +210,16 @@ async function withOperationLease(operations: OperationStore, operationId: st await release() return result } + +/** Admission is short-lived; independent target acceptances queue rather than spuriously failing. */ +async function acquireAdmission(stateDirectory: string): Promise<() => Promise> { + const deadline = Date.now() + 10000 + for (;;) { + try { return await acquireSkillTargetLock(stateDirectory, 'native-admission') } + catch (error) { + if (!(error instanceof Error) || !('details' in error) + || (error.details as {reason?: string})?.reason !== 'TARGET_BUSY' || Date.now() >= deadline) throw error + await new Promise(resolve => setTimeout(resolve, 20)) + } + } +} diff --git a/cli/src/integrations/native-toggle.ts b/cli/src/integrations/native-toggle.ts index 9c0236dd9..fff53f4e9 100644 --- a/cli/src/integrations/native-toggle.ts +++ b/cli/src/integrations/native-toggle.ts @@ -1,7 +1,7 @@ import { link, lstat, open, realpath, unlink } from 'node:fs/promises' import { join } from 'node:path' import { isDeepStrictEqual } from 'node:util' -import type { InventoryStore } from '../stores/inventory-store' +import { inventoryWithoutState, type InventoryStore } from '../stores/inventory-store' import { captureInstallInventory } from '../services/inventory-cas' import { acquireSkillTargetLock } from '../services/skill-target-lock' import { assertEntryAbsent, snapshotJournalInstallation } from '../services/installed-skill-snapshot' @@ -61,18 +61,14 @@ export async function executeNativeToggle(operations: OperationStore, inventory: await syncDirectory(target.installDir) record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'moved' }) const paths = new Set([target.installDir]) - const expected = await captureInstallInventory({ items: [item] }, item, paths) - await inventory.mutateAtomic(current => { - const match = current.items.find(candidate => sameInstalledSkillSource(candidate, item)) - const entry = match?.targets.find(entry => entry.installDir === target.installDir) - if (!entry || !isDeepStrictEqual(entry, target)) throw new Error('Toggle inventory changed') - entry.installationState = baseline.afterState - }, async before => { - if (await captureInstallInventory(before, item, paths) !== expected) throw new Error('Toggle ownership changed') + const expected = await captureInstallInventory(inventoryWithoutState({ items: [item] }), item, paths) + await inventory.withLockedSnapshot(async current => { + if (await captureInstallInventory(inventoryWithoutState(current), item, paths) !== expected) throw new Error('Toggle ownership changed') await request.authorize([target.rootDir, target.installDir], 'replace', request.operationId) await verifyFiles(record!, baseline.afterState) + // The durable journal is the commit point; entry state is only a file projection. + record = await operations.recordToggle(record!.operationId, record!.ownerId, record!.revision, { ...intent, stage: 'committed' }) }) - record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'committed' }) return complete(operations, record) } catch (error) { const current = await operations.get(request.operationId, request.ownerId) @@ -118,19 +114,25 @@ async function reconcileToggle(operations: OperationStore, inventory: InventoryS try { const intent = record.toggle! const before = intent.inventoryItem - const after = structuredClone(before) - after.targets[0]!.installationState = intent.afterState const paths = new Set([intent.target.path]) return await inventory.withLockedSnapshot(async actual => { - const fingerprint = await captureInstallInventory(actual, before, paths) - if (fingerprint === await captureInstallInventory({ items: [after] }, after, paths)) { + const fingerprint = await captureInstallInventory(inventoryWithoutState(actual), before, paths) + if (fingerprint !== await captureInstallInventory(inventoryWithoutState({ items: [before] }), before, paths)) return unknown() + let legacyCommitted = false + if (await inventory.diskVersion() === 3) { + const stored = await inventory.readPersisted() + const entry = stored.items.find(item => sameInstalledSkillSource(item, before))?.targets.find(target => target.installDir === intent.target.path) + if (!entry || ![intent.beforeState, intent.afterState].includes(entry.installationState!)) return unknown() + legacyCommitted = entry.installationState === intent.afterState + if (intent.stage === 'committed' && !legacyCommitted) return unknown() + } + if (intent.stage === 'committed' || legacyCommitted) { await verifyFiles(record, intent.afterState) if (record.operation.status.phase === 'completed') return { operationId: record.operationId, revision: record.revision, outcome: 'committed', reason: intent.afterState } if (['unknown', 'recovery-required'].includes(record.operation.status.phase)) record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'recovering' }) if (record.toggle!.stage !== 'committed') record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'committed' }) return complete(operations, record) } - if (record.toggle!.stage === 'committed' || fingerprint !== await captureInstallInventory({ items: [before] }, before, paths)) return unknown() if (record.operation.status.phase === 'failed' && record.operation.status.recovery === 'restored') { await verifyFiles(record, intent.beforeState) return { operationId: record.operationId, revision: record.revision, outcome: 'restored', reason: 'toggle-restored' } diff --git a/cli/src/integrations/native-upgrade.ts b/cli/src/integrations/native-upgrade.ts index 4a4808def..5d6552a9e 100644 --- a/cli/src/integrations/native-upgrade.ts +++ b/cli/src/integrations/native-upgrade.ts @@ -1,3 +1,6 @@ +import { dirname, join } from 'node:path' +import { durableJson, privateDirectory } from './native-changes' +import { assertChanges, verifyBackup } from './native-changes' import { isDeepStrictEqual } from 'node:util' import { SkillHubClient } from '../clients/skillhub-client' import { installSkill } from '../services/install-service' @@ -14,7 +17,7 @@ import { assertOwnedRegularTree } from './recovery-files' /** Execute only the complete confirmed snapshot; the shared core owns target locks and inventory CAS. */ export async function executeNativeUpgrade(operations: OperationStore, inventory: InventoryStore, input: NativeUpgradeExecution): Promise { - const request = { ...input, baseline: structuredClone(input.baseline) } + const request = { ...input, baseline: structuredClone(input.baseline), ...(input.localChangesAuthorization ? { localChangesAuthorization: structuredClone(input.localChangesAuthorization) } : {}) } const { baseline } = request const record = await operations.get(request.operationId, request.ownerId) if (!record || record.operation.kind !== 'upgrade' || record.operation.status.phase !== 'accepted') throw new Error('Upgrade is not owned and accepted') @@ -30,11 +33,23 @@ export async function executeNativeUpgrade(operations: OperationStore, inventory const verifyBaseline = async () => { for (const target of baseline.targets) await request.authorize([target.root, target.path], 'staging') await operations.assertTargetsAvailable(paths, request) - const current = await inspectUpgradeBaseline(inventory, { ...baseline.inventoryItem, version: baseline.inventoryItem.targets[0]!.version }, baseline.targets) + if (request.localChangesAuthorization) { + const consent = request.localChangesAuthorization + if (!isDeepStrictEqual(consent.snapshot.baseline, baseline) || baseline.targets.length !== 1) throw new Error('Local changes authorization changed') + await assertChanges(inventory, consent.snapshot) + await verifyBackup(operations.root, consent.snapshot, consent.backupReceipt) + } + const current = await inspectUpgradeBaseline(inventory, { ...baseline.inventoryItem, version: baseline.inventoryItem.targets[0]!.version }, baseline.targets, !!request.localChangesAuthorization) if (!isDeepStrictEqual(current, baseline)) throw new Error('Upgrade baseline changed') } try { await verifyBaseline() + if (request.localChangesAuthorization) { + const directory = join(dirname(operations.root), 'dirty-upgrade-proofs') + await privateDirectory(directory) + await durableJson(join(directory, `${record.operationId}.json`), { ownerId: record.ownerId, + authorization: request.localChangesAuthorization }) + } const client = new SkillHubClient(request.registry) const resolved = await client.resolve(request.namespace, request.slug, request.version) if (resolved.namespace !== request.namespace || resolved.slug !== request.slug || resolved.version !== request.version diff --git a/cli/src/integrations/operation-store.ts b/cli/src/integrations/operation-store.ts index a4710e1e5..4fecab694 100644 --- a/cli/src/integrations/operation-store.ts +++ b/cli/src/integrations/operation-store.ts @@ -1,8 +1,10 @@ +import { renameAtomicFile } from '../platform/atomic-rename' +import { inventoryWithoutState, type Inventory } from '../stores/inventory-store' import { isDeepStrictEqual } from 'node:util' import { toggleJournal, validateToggleJournal, type ToggleJournal } from './toggle-journal' import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises' +import { chmod, link, lstat, mkdir, open, readdir, readFile, realpath, rm, unlink } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' import { z } from 'zod' @@ -52,21 +54,24 @@ export function operationRequestDigest(parameters: Record { + static async open(root: string, replaceFile: typeof renameAtomicFile = renameAtomicFile): Promise { if (!isAbsolute(root)) throw new Error('Operation state requires a trusted absolute directory') await mkdir(root, { recursive: true, mode: 0o700 }) const info = await lstat(root) if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new Error('Unsafe operation state directory') if (process.platform !== 'win32') await chmod(root, 0o700) - const pending = new OperationStore(root, '') + const pending = new OperationStore(root, '', replaceFile) return pending.locked(async () => { const path = join(root, 'host.json') const saved = await readJson(path) const hostInstanceId = saved === undefined ? randomUUID() : z.object({ hostInstanceId: id }).strict().parse(saved).hostInstanceId - if (saved === undefined) await atomicWrite(path, { hostInstanceId }) - return new OperationStore(root, hostInstanceId) + if (saved === undefined) await atomicWrite(path, { hostInstanceId }, replaceFile) + const store = new OperationStore(root, hostInstanceId, replaceFile) + await store.isolateExtendedRecords() + return store }) } @@ -82,11 +87,11 @@ export class OperationStore { const duplicate = await this.findRequest(request) if (duplicate) return duplicate const now = new Date().toISOString() - const value: OperationRecord = { schemaVersion: 3, operationId: randomUUID(), ownerId: request.ownerId, + const value: OperationRecord = { schemaVersion: ['enable', 'disable'].includes(accepted.kind) ? 3 : 2, operationId: randomUUID(), ownerId: request.ownerId, requestKey: request.requestKey, requestDigest: request.requestDigest, revision: 0, operation: { ...accepted, context: { ...accepted.context, hostInstanceId: this.hostInstanceId, createdAt: now, updatedAt: now }, status: { phase: 'accepted', files: 'pending', discovery: 'unconfirmed', recovery: 'not-needed', warnings: [] } } } - await writeOperationRecord(this.path(value.operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -138,7 +143,7 @@ export class OperationStore { assertTransition(current, parsed) const value: OperationRecord = { ...current, revision: revision + 1, operation: { ...current.operation, context: { ...current.operation.context, updatedAt: new Date().toISOString() }, status: parsed } } - await writeOperationRecord(this.path(operationId), value) + await this.writeRecord(value) return value }) } @@ -161,7 +166,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, existingInstallation: parsed, operation: { ...current.operation, status: next, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -249,7 +254,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, transaction, operation: { ...current.operation, status: { ...current.operation.status, phase: nextPhase }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -277,7 +282,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, transaction, operation: { ...current.operation, context: { ...current.operation.context, updatedAt: new Date().toISOString() }, status: { ...current.operation.status, phase: 'completed', files, recovery: 'not-needed', warnings } } } - await writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -302,7 +307,7 @@ export class OperationStore { const value: OperationRecord = { ...current, revision: revision + 1, transaction, operation: { ...current.operation, status: { ...current.operation.status, warnings }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -333,7 +338,7 @@ export class OperationStore { transaction.recoveryActions = actions const value: OperationRecord = { ...current, transaction, revision: revision + 1, operation: { ...current.operation, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -351,7 +356,7 @@ export class OperationStore { operation: { ...current.operation, status: { ...current.operation.status, phase: parsed.type === 'beforeCommit' ? 'committing' : current.operation.status.phase }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } } - await writeOperationRecord(this.path(operationId), record.parse(value)) + await this.writeRecord(record.parse(value)) return value }) } @@ -375,7 +380,7 @@ export class OperationStore { operation: { ...current.operation, status: { ...current.operation.status, phase: current.operation.status.phase === 'recovering' ? 'recovering' : parsed.stage === 'intent' ? 'revalidating' : 'committing' }, context: { ...current.operation.context, updatedAt: new Date().toISOString() } } }) - await writeOperationRecord(this.path(operationId), value) + await this.writeRecord(value) return value }) } @@ -388,7 +393,11 @@ export class OperationStore { } private path(operationId: string): string { return join(this.root, `${id.parse(operationId)}.json`) } private async read(operationId: string): Promise { - const json = await readJson(this.path(operationId)) + await this.validateExtendedDirectory() + const extended = await readJson(this.extendedPath(operationId)) + const ordinary = await readJson(this.path(operationId)) + if (extended !== undefined && ordinary !== undefined && !isDeepStrictEqual(extended, ordinary)) throw new OperationStoreError('CORRUPT_RECORD') + const json = extended ?? ordinary if (json === undefined) return undefined const parsed = record.safeParse(json) if (!parsed.success || parsed.data.operationId !== operationId || parsed.data.operation.context.hostInstanceId !== this.hostInstanceId) throw new OperationStoreError('CORRUPT_RECORD') @@ -408,7 +417,9 @@ export class OperationStore { } private async readAll(): Promise { const values: OperationRecord[] = [] - for (const name of (await readdir(this.root)).sort()) { + await this.validateExtendedDirectory() + const extended = await readdir(join(this.root, 'activation')).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return []; throw error }) + for (const name of [...new Set([...(await readdir(this.root)), ...extended])].sort()) { if (name === 'host.json' || !name.endsWith('.json')) continue const value = await this.read(name.slice(0, -5)) if (!value) throw new OperationStoreError('CORRUPT_RECORD') @@ -417,6 +428,83 @@ export class OperationStore { return values } + private extendedPath(operationId: string): string { return join(this.root, 'activation', `${id.parse(operationId)}.json`) } + + private async validateExtendedDirectory(): Promise { + try { + const info = await lstat(join(this.root, 'activation')) + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new OperationStoreError('CORRUPT_RECORD') + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } + } + + private async prepareExtendedDirectory(): Promise { + const path = join(this.root, 'activation') + await mkdir(path, { recursive: true, mode: 0o700 }) + const info = await lstat(path) + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid())) throw new OperationStoreError('CORRUPT_RECORD') + } + + /** The intermediate duplicate must be identical; never prefer a competing revision. */ + private async isolateRecord(operationId: string): Promise { + await this.prepareExtendedDirectory() + const source = this.path(operationId) + const destination = this.extendedPath(operationId) + const original = await readJson(source) + if (original === undefined) return + await this.read(operationId) + const bytes = await readFile(source) + const backup = join(this.root, 'activation', `${operationId}.${createHash('sha256').update(bytes).digest('hex')}.backup`) + try { + const file = await open(backup, 'wx', 0o600) + try { await file.writeFile(bytes); await file.sync() } finally { await file.close() } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } + const info = await lstat(backup) + if (!info.isFile() || info.isSymbolicLink() || !(await readFile(backup)).equals(bytes)) throw new OperationStoreError('CORRUPT_RECORD') + try { await link(source, destination) } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || !isDeepStrictEqual(await readJson(destination), original)) throw error + } + await syncJournalDirectory(join(this.root, 'activation')) + await unlink(source) + await syncJournalDirectory(this.root) + } + + /** Preserve original extended evidence while keeping legacy readers' root scan usable. */ + private async isolateExtendedRecords(): Promise { + await this.validateExtendedDirectory() + for (const name of await readdir(this.root)) { + if (name === 'host.json' || !name.endsWith('.json')) continue + const operationId = name.slice(0, -5) + const raw = await readJson(this.path(operationId)) as OperationRecord | undefined + if (raw?.schemaVersion === 3 || await readJson(this.extendedPath(operationId)) !== undefined) await this.isolateRecord(operationId) + } + } + + private async writeRecord(value: OperationRecord): Promise { + const extended = value.schemaVersion === 3 || await readJson(this.extendedPath(value.operationId)) !== undefined || operationSnapshots(value).some(snapshot => snapshot.items.some(item => item.targets.some(target => target.installationState === 'disabled'))) + if (extended) { + await this.prepareExtendedDirectory() + await this.isolateRecord(value.operationId) + await writeOperationRecord(this.extendedPath(value.operationId), { ...value, schemaVersion: 3 }, this.replaceFile) + return + } + // v2 snapshots contain ownership only, exactly as consumed by published clients. + const compatible = structuredClone(value) + if (compatible.transaction?.preparation) compatible.transaction.preparation.before = inventoryWithoutState(compatible.transaction.preparation.before) + if (compatible.transaction?.inventoryCommit) { + compatible.transaction.inventoryCommit.before = inventoryWithoutState(compatible.transaction.inventoryCommit.before) + compatible.transaction.inventoryCommit.after = inventoryWithoutState(compatible.transaction.inventoryCommit.after) + } + if (compatible.removal) { + compatible.removal.intent.inventoryItem = inventoryWithoutState({ items: [compatible.removal.intent.inventoryItem] }).items[0]! + if (compatible.removal.inventoryCommit) { + compatible.removal.inventoryCommit.before = inventoryWithoutState(compatible.removal.inventoryCommit.before) + compatible.removal.inventoryCommit.after = inventoryWithoutState(compatible.removal.inventoryCommit.after) + } + } + await writeOperationRecord(this.path(value.operationId), compatible, this.replaceFile) + } + private async locked(run: () => Promise): Promise { let release: (() => Promise) | undefined const deadline = Date.now() + 10_000 @@ -484,14 +572,14 @@ async function readJson(path: string): Promise { } finally { await file.close() } } -async function atomicWrite(path: string, value: unknown): Promise { +async function atomicWrite(path: string, value: unknown, replaceFile: typeof renameAtomicFile): Promise { const temporary = `${path}.${randomUUID()}.tmp` try { const payload = JSON.stringify(value) if (Buffer.byteLength(payload) > 1024 * 1024) throw new OperationStoreError('CORRUPT_RECORD') const file = await open(temporary, 'wx', 0o600) try { await file.writeFile(payload); await file.sync() } finally { await file.close() } - await rename(temporary, path) + await replaceFile(temporary, path) if (process.platform !== 'win32') { const directory = await open(dirname(path), 'r') try { await directory.sync() } finally { await directory.close() } @@ -499,10 +587,23 @@ async function atomicWrite(path: string, value: unknown): Promise { } finally { await rm(temporary, { force: true }) } } +function operationSnapshots(value: OperationRecord): Inventory[] { + return [value.transaction?.preparation?.before, value.transaction?.inventoryCommit?.before, + value.transaction?.inventoryCommit?.after, value.removal ? { items: [value.removal.intent.inventoryItem] } : undefined, + value.removal?.inventoryCommit?.before, value.removal?.inventoryCommit?.after].filter((snapshot): snapshot is Inventory => snapshot !== undefined) +} + +async function syncJournalDirectory(path: string): Promise { + if (process.platform === 'win32') return + const directory = await open(path, 'r') + try { await directory.sync() } finally { await directory.close() } +} + /** Recovery may advance v1 status, but never rewrite the original serialized evidence. */ -async function writeOperationRecord(path: string, value: OperationRecord): Promise { - if (value.schemaVersion === 3) return atomicWrite(path, value) +async function writeOperationRecord(path: string, value: OperationRecord, replaceFile: typeof renameAtomicFile): Promise { + if (value.schemaVersion === 3) return atomicWrite(path, value, replaceFile) const previous = await readJson(path) as OperationRecord | undefined + if (!previous && value.schemaVersion === 2) return atomicWrite(path, value, replaceFile) if (!previous || previous.schemaVersion !== value.schemaVersion) throw new Error('Legacy operation evidence missing') const preserved = structuredClone(value) if (preserved.transaction && previous.transaction) { @@ -521,7 +622,7 @@ async function writeOperationRecord(path: string, value: OperationRecord): Promi preserved.removal.inventoryCommit.after = previous.removal.inventoryCommit.after } } - await atomicWrite(path, preserved) + await atomicWrite(path, preserved, replaceFile) } /** A pending install may not exist yet; resolve the nearest existing ancestor. */ diff --git a/cli/src/integrations/removal-inventory.ts b/cli/src/integrations/removal-inventory.ts index 4d2b25ef7..cd80c352d 100644 --- a/cli/src/integrations/removal-inventory.ts +++ b/cli/src/integrations/removal-inventory.ts @@ -1,7 +1,7 @@ import { isDeepStrictEqual } from 'node:util' import { captureInstallInventory } from '../services/inventory-cas' import { sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { targetInstalledBy } from '../stores/inventory-store' +import { inventoryWithoutState, targetInstalledBy } from '../stores/inventory-store' import type { Inventory, InventoryStore } from '../stores/inventory-store' import type { RemovalBaseline } from './native-contract' @@ -21,7 +21,7 @@ export async function commitRemovalInventory(store: InventoryStore, const expectedState = await captureInstallInventory({ items: [expected] }, expected, paths) await store.mutateAtomic(inventory => { const matches = inventory.items.filter(item => sameInstalledSkillSource(item, expected)) - if (!isDeepStrictEqual(matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === path) })), [expected])) throw new Error('Removal inventory changed') + if (!isDeepStrictEqual(inventoryWithoutState({ items: matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === path) })) }), inventoryWithoutState({ items: [expected] }))) throw new Error('Removal inventory changed') const item = matches[0]! item.targets = item.targets.filter(entry => entry.installDir !== path) if (!item.targets.length) inventory.items = inventory.items.filter(entry => entry !== item) diff --git a/cli/src/integrations/removal-journal.ts b/cli/src/integrations/removal-journal.ts index 87ae35101..900cd303a 100644 --- a/cli/src/integrations/removal-journal.ts +++ b/cli/src/integrations/removal-journal.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { inventoryItemSchema, inventorySnapshotSchema } from './transaction-journal' import type { OperationRecord } from './operation-store' import { sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { targetInstalledBy } from '../stores/inventory-store' +import { inventoryWithoutState, targetInstalledBy } from '../stores/inventory-store' const path = z.string().min(1).max(2048).refine(value => isAbsolute(value) && resolve(value) === value) const intent = z.object({ inventoryItem: inventoryItemSchema, @@ -61,7 +61,7 @@ export function applyRemovalEvent(record: OperationRecord, event: RemovalEvent): const before = event.commit.before const expected = structuredClone(before) const matches = expected.items.filter(item => sameInstalledSkillSource(item, state.intent.inventoryItem)) - if (!isDeepStrictEqual(matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === state.intent.target.path) })), [{ ...state.intent.inventoryItem, targets: state.intent.inventoryItem.targets.filter(target => target.installDir === state.intent.target.path) }])) fail() + if (!isDeepStrictEqual(inventoryWithoutState({ items: matches.map(item => ({ ...item, targets: item.targets.filter(target => target.installDir === state.intent.target.path) })) }), inventoryWithoutState({ items: [{ ...state.intent.inventoryItem, targets: state.intent.inventoryItem.targets.filter(target => target.installDir === state.intent.target.path) }] }))) fail() const item = matches[0]! item.targets = item.targets.filter(target => target.installDir !== state.intent.target.path) if (!item.targets.length) expected.items = expected.items.filter(entry => entry !== item) diff --git a/cli/src/integrations/upgrade-baseline.ts b/cli/src/integrations/upgrade-baseline.ts index 1d08090bc..0b57582b6 100644 --- a/cli/src/integrations/upgrade-baseline.ts +++ b/cli/src/integrations/upgrade-baseline.ts @@ -1,3 +1,6 @@ +import { assertChangeTreeBudget } from './change-tree-budget' +import { createHash } from 'node:crypto' +import { snapshotSkillDirectory } from '../services/skill-fingerprint' import { captureInstallInventory } from '../services/inventory-cas' import { realpath } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' @@ -14,7 +17,7 @@ import { assertOwnedRegularTree } from './recovery-files' /** Read-only preparation. Execution must compare this complete snapshot again under mutation locks. */ export async function inspectUpgradeBaseline(store: Pick, selected: Pick & { version: string }, - approvedRoots: readonly InstalledProjectionTarget[]): Promise { + approvedRoots: readonly InstalledProjectionTarget[], allowLocalChanges = false): Promise { const inventory = structuredClone(await store.read()) const matches = inventory.items.filter(item => sameInstalledSkillSource(item, selected)) const whole = matches[0] @@ -55,6 +58,7 @@ export async function inspectUpgradeBaseline(store: Pick if (await realpath(target.rootDir) !== target.rootDir || await realpath(target.installDir) !== target.installDir) { return { status: 'blocked', reason: 'target-alias' } } + if (allowLocalChanges) await assertChangeTreeBudget(target.installDir) await assertOwnedRegularTree(target.installDir) const read = await readInstalledSkillMetadata(target.installDir) if (read.status !== 'valid') return { status: 'blocked', reason: 'metadata-invalid' } @@ -64,8 +68,19 @@ export async function inspectUpgradeBaseline(store: Pick || metadata.agent !== 'dsh' || metadata.installedAt !== target.installedAt) { return { status: 'blocked', reason: 'metadata-invalid' } } - const snapshot = await snapshotInstalledSkillDirectory(item, target) - if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { + const original = createHash('sha256') + for (const path of Object.keys(metadata.files).sort()) original.update(`${path}:${metadata.files[path]}\n`, 'utf8') + if (`sha256:${original.digest('hex')}` !== target.fingerprint) return { status: 'blocked', reason: 'metadata-invalid' } + let snapshot + if (allowLocalChanges) { + snapshot = await snapshotSkillDirectory(target.installDir) + if (target.installationState === 'disabled') { + if ('SKILL.md' in snapshot.files || 'SKILL.md.disabled' in metadata.files) return { status: 'blocked', reason: 'entry-conflict' } + if (snapshot.files['SKILL.md.disabled']) snapshot.files['SKILL.md'] = snapshot.files['SKILL.md.disabled']! + delete snapshot.files['SKILL.md.disabled'] + } else if ('SKILL.md.disabled' in snapshot.files && !('SKILL.md.disabled' in metadata.files)) return { status: 'blocked', reason: 'entry-conflict' } + } else snapshot = await snapshotInstalledSkillDirectory(item, target) + if (!allowLocalChanges && (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files))) { return { status: 'blocked', reason: 'local-changes' } } targets.push({ scope: approvedRoots.find(root => root.root === target.rootDir)!.scope, diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts index 0ea65d5b3..677ba7a07 100644 --- a/cli/src/platform/archive.ts +++ b/cli/src/platform/archive.ts @@ -3,8 +3,8 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { zipSync, unzipSync } from 'fflate' import { MAX_PACKAGE_BYTES } from './download.js' -const MAX_ZIP_ENTRIES = 500 -const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024 +export const MAX_ZIP_ENTRIES = 500 +export const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024 const EOCD_SIGNATURE = 0x06054b50 const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50 const ZIP64_MARKER_16 = 0xffff diff --git a/cli/src/platform/atomic-rename.ts b/cli/src/platform/atomic-rename.ts new file mode 100644 index 000000000..572ab4de6 --- /dev/null +++ b/cli/src/platform/atomic-rename.ts @@ -0,0 +1,16 @@ +import { rename } from 'node:fs/promises' + +/** Retry only Windows sharing failures; never remove the destination or replay a transaction. */ +export async function renameAtomicFile(from: string, to: string, + renameFile: typeof rename = rename, platform: NodeJS.Platform = process.platform): Promise { + const delays = [20, 40, 80, 160, 320] + for (let attempt = 0; ; attempt++) { + try { await renameFile(from, to); return } + catch (error) { + const code = (error as NodeJS.ErrnoException).code + const delay = delays[attempt] + if (platform !== 'win32' || !['EPERM', 'EBUSY'].includes(code ?? '') || delay === undefined) throw error + await new Promise(resolve => setTimeout(resolve, delay)) + } + } +} diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index 0f803a3ed..35f14ec65 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -137,7 +137,9 @@ export async function installSkill(options: InstallOptions): Promise target.canonicalSkillDir)) if (expectedItem !== undefined) { diff --git a/cli/src/services/inventory-cas.ts b/cli/src/services/inventory-cas.ts index a71da8e23..1dc03189e 100644 --- a/cli/src/services/inventory-cas.ts +++ b/cli/src/services/inventory-cas.ts @@ -17,7 +17,7 @@ export async function captureInstallInventory( const canonicalRoot = await canonicalizeExistingPath(root) return { agent: target.agent, rootDir: canonicalRoot, installDir: resolve(canonicalRoot, relative(root, resolve(target.installDir))), - installationState: target.installationState ?? 'enabled', version: target.version, fingerprint: target.fingerprint ?? null, installedAt: target.installedAt, installedBy: [...new Set(targetInstalledBy(item, target))].sort() } + version: target.version, fingerprint: target.fingerprint ?? null, installedAt: target.installedAt, installedBy: [...new Set(targetInstalledBy(item, target))].sort() } })) const selected = targets.filter(target => [...canonicalDirs].some(path => pathsOverlap(path, target.installDir))) if (!selected.length) continue diff --git a/cli/src/services/skill-target-lock.ts b/cli/src/services/skill-target-lock.ts index ed1510375..ef14cf585 100644 --- a/cli/src/services/skill-target-lock.ts +++ b/cli/src/services/skill-target-lock.ts @@ -21,7 +21,8 @@ export async function orderSkillTargetLocks(targe } /** Serializes every local lifecycle mutation for one Skill target directory. */ -export async function acquireSkillTargetLock(rootDir: string, slug: string): Promise<() => Promise> { +export async function acquireSkillTargetLock(rootDir: string, slug: string, + acquire: typeof acquireTargetLock = acquireTargetLock): Promise<() => Promise> { const lockPath = await skillTargetLockPath(rootDir, slug) // proper-lockfile's stale deletion is not serialized. Gate only the acquisition attempt so two // recoverers cannot remove and replace the same target lock concurrently. @@ -36,7 +37,7 @@ export async function acquireSkillTargetLock(rootDir: string, slug: string): Pro let releaseTarget: () => Promise try { - releaseTarget = await acquireTargetLock(lockPath) + releaseTarget = await acquire(lockPath) } catch (operationError) { try { await releaseAcquisitionGate() @@ -61,14 +62,27 @@ export async function acquireSkillTargetLock(rootDir: string, slug: string): Pro return releaseTarget } -function acquireTargetLock(lockPath: string): Promise<() => Promise> { - return lock(lockPath, { - lockfilePath: lockPath, - realpath: false, - stale: 10_000, - update: 3_000, - retries: 0 - }) +/** The acquisition gate remains held while retrying a Windows mkdir sharing failure. */ +export async function acquireTargetLock(lockPath: string, acquire: typeof lock = lock, + platform: NodeJS.Platform = process.platform): Promise<() => Promise> { + const delays = [20, 40, 80, 160, 320] + for (let attempt = 0; ; attempt++) { + try { + return await acquire(lockPath, { + lockfilePath: lockPath, + realpath: false, + stale: 10_000, + update: 3_000, + retries: 0 + }) + } catch (error) { + const failure = error as NodeJS.ErrnoException + const delay = delays[attempt] + if (platform !== 'win32' || !['EPERM', 'EBUSY'].includes(failure.code ?? '') + || failure.syscall !== 'mkdir' || failure.path !== lockPath || delay === undefined) throw error + await new Promise(resolve => setTimeout(resolve, delay)) + } + } } /** Serialize stale-lock recovery attempts without replacing a live contender. */ diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index 790198873..0d0b83be3 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -1,8 +1,9 @@ +import { renameAtomicFile } from '../platform/atomic-rename' import { createHash } from 'node:crypto' import { z } from 'zod' import { isDeepStrictEqual } from 'node:util' -import { lstat, open, readFile, rename, rm } from 'node:fs/promises' -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { lstat, open, readFile, rm } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, join } from 'node:path' import { lock } from 'proper-lockfile' import { acquireAcquisitionGate } from '../services/skill-target-lock' import { canonicalizeExistingPath, pathsOverlap, joinPath, userStateDir, ensureDir } from '../platform/paths' @@ -109,6 +110,28 @@ export function parseInventory(value: unknown): Inventory { ...(legacy.suites === undefined ? {} : { suites: legacy.suites }) } as Inventory } +/** Persisted ownership excludes the independently observed entry state. */ +export function inventoryWithoutState(inventory: Inventory): Inventory { + return { ...inventory, items: inventory.items.map(item => ({ ...item, targets: item.targets.map(target => { + const stored = { ...target } + delete stored.installationState + return stored + }) })) } +} + +/** Ambiguous, missing and unsafe entries have no usable state. */ +export async function readInstallationState(directory: string): Promise<'enabled' | 'disabled' | undefined> { + const inspect = async (name: string) => { + try { const stat = await lstat(join(directory, name)); return stat.isFile() && !stat.isSymbolicLink() ? 'file' : 'unsafe' } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; return 'unsafe' } + } + const enabled = await inspect('SKILL.md') + const disabled = await inspect('SKILL.md.disabled') + if (enabled === 'file' && disabled === 'missing') return 'enabled' + if (disabled === 'file' && enabled === 'missing') return 'disabled' + return undefined +} + /** Validate ownership aliases before writing; content of unselected skills is not inspected. */ async function validateInventoryClaims(inventory: Inventory): Promise { const sources = new Set() @@ -135,12 +158,20 @@ export class InventoryStore { this.path = joinPath(userStateDir(home), 'inventory.json') } - async read(): Promise { + /** Raw legacy evidence, used only while reconciling an existing v3 transaction. */ + async readPersisted(): Promise { try { return parseInventory(JSON.parse(await readFile(this.path, 'utf-8'))) } - catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { items: [] } - throw error + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { items: [] }; throw error } + } + + async read(): Promise { + const inventory = await this.readPersisted() + for (const item of inventory.items) for (const target of item.targets) { + const state = await readInstallationState(target.installDir) + if (state) target.installationState = state + else delete target.installationState } + return inventory } async write(inventory: Inventory): Promise { await this.writeAtomic(inventory) } @@ -157,7 +188,7 @@ export class InventoryStore { const value: unknown = JSON.parse(await readFile(this.path, 'utf8')) parseInventory(value) return typeof value === 'object' && value !== null && 'schemaVersion' in value ? value.schemaVersion as 2 | 3 : 1 - } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 3; throw error } + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 2; throw error } } private async migrateUnderLock(): Promise { @@ -169,9 +200,16 @@ export class InventoryStore { const value: unknown = JSON.parse(raw.toString('utf8')) const inventory = parseInventory(value) await validateInventoryClaims(inventory) - if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 3) return + if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 2) return + if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 3) { + const { snapshotInstalledSkillDirectory } = await import('../services/installed-skill-snapshot') + for (const item of inventory.items) for (const target of item.targets) { + if (await readInstallationState(target.installDir) !== target.installationState) throw new Error('Inventory v3 entry state requires recovery') + if ((await snapshotInstalledSkillDirectory(item, target)).fingerprint !== target.fingerprint) throw new Error('Inventory v3 content requires verification') + } + } const digest = createHash('sha256').update(raw).digest('hex') - const backup = joinPath(dirname(this.path), `inventory.v${typeof value === 'object' && value !== null && 'schemaVersion' in value ? 2 : 1}.${digest}.backup.json`) + const backup = joinPath(dirname(this.path), `inventory.v${typeof value === 'object' && value !== null && 'schemaVersion' in value ? value.schemaVersion : 1}.${digest}.backup.json`) try { const file = await open(backup, 'wx', 0o600) try { await file.writeFile(raw); await file.sync() } finally { await file.close() } @@ -183,7 +221,7 @@ export class InventoryStore { try { await this.writeUnderLock(inventory) } catch (error) { try { - if (await this.diskVersion() === 3 && isDeepStrictEqual(await this.read(), inventory)) return + if (await this.diskVersion() === 2 && isDeepStrictEqual(inventoryWithoutState(await this.read()), inventoryWithoutState(inventory))) return } catch { /* Preserve the original migration failure. */ } throw error } @@ -221,7 +259,6 @@ export class InventoryStore { const inventory = await this.read() const before = structuredClone(inventory) const result = mutate(inventory) - for (const item of inventory.items) for (const target of item.targets) target.installationState ??= 'enabled' if (beforeCommit) await beforeCommit(structuredClone(before), structuredClone(inventory)) try { await this.writeUnderLock(inventory) } catch (writeError) { @@ -229,8 +266,8 @@ export class InventoryStore { try { actual = await this.read() } catch (readError) { throw new InventoryCommitUnknownError(new AggregateError([writeError, readError])) } // Still holding the inventory lock: a failed transport response is not proof of failed rename. - if (isDeepStrictEqual(actual, inventory)) return result - if (!isDeepStrictEqual(actual, before)) throw new InventoryCommitUnknownError(writeError) + if (isDeepStrictEqual(inventoryWithoutState(actual), inventoryWithoutState(inventory))) return result + if (!isDeepStrictEqual(inventoryWithoutState(actual), inventoryWithoutState(before))) throw new InventoryCommitUnknownError(writeError) throw writeError } return result @@ -242,14 +279,14 @@ export class InventoryStore { protected async writeUnderLock(inventory: Inventory): Promise { inventoryMemorySchema.parse(inventory) await validateInventoryClaims(inventory) - const payload = JSON.stringify(inventoryV3Schema.parse({ schemaVersion: 3, records: inventory.items.map(item => ({ ...item, targets: item.targets.map(target => ({ ...target, installationState: target.installationState ?? 'enabled' })) })), suites: inventory.suites }), null, 2) + const payload = JSON.stringify(inventoryDiskSchema.parse({ schemaVersion: 2, records: inventoryWithoutState(inventory).items, suites: inventory.suites }), null, 2) parseInventory(JSON.parse(payload)) const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp` try { const file = await open(tmpPath, 'wx', 0o600) try { await file.writeFile(payload); await file.sync() } finally { await file.close() } parseInventory(JSON.parse(await readFile(tmpPath, 'utf-8'))) - await rename(tmpPath, this.path) + await this.replaceInventoryFile(tmpPath) if (process.platform !== 'win32') { const directory = await open(dirname(this.path), 'r') try { await directory.sync() } finally { await directory.close() } @@ -259,6 +296,10 @@ export class InventoryStore { } } + protected async replaceInventoryFile(temporaryPath: string): Promise { + await renameAtomicFile(temporaryPath, this.path) + } + private async acquireLock(): Promise<() => Promise> { // proper-lockfile stale stat/remove is not atomic: serialize acquisition so a delayed // recoverer cannot remove another process's newly acquired inventory lock. diff --git a/cli/test/fixtures/toggle-crash-child.ts b/cli/test/fixtures/toggle-crash-child.ts index 38576fdb7..1c1ef3419 100644 --- a/cli/test/fixtures/toggle-crash-child.ts +++ b/cli/test/fixtures/toggle-crash-child.ts @@ -1,10 +1,11 @@ +import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { InventoryStore } from '../../src/stores/inventory-store' import { OperationStore } from '../../src/integrations/operation-store' import { inspectToggle, executeNativeToggle } from '../../src/integrations/native-toggle' -const [home, operationId, boundary] = process.argv.slice(2) -if (!home || !operationId || !['intent', 'linked', 'moved', 'restore-linked', 'committed'].includes(boundary!)) throw new Error('Invalid fixture arguments') +const [home, operationId, boundary, readyFile] = process.argv.slice(2) +if (!home || !operationId || !readyFile || !['intent', 'linked', 'moved', 'restore-linked', 'committed'].includes(boundary!)) throw new Error('Invalid fixture arguments') const inventory = new InventoryStore(home) const operations = await OperationStore.open(join(home, 'operations')) const baseline = await inspectToggle(inventory, { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' }, @@ -12,10 +13,14 @@ const baseline = await inspectToggle(inventory, { registry: 'https://registry.ex if (baseline.status !== 'ready') throw new Error('Fixture baseline unavailable') const append = operations.recordToggle.bind(operations) operations.recordToggle = async (...args) => { + if (boundary === 'restore-linked' && args[3].stage === 'committed') throw new Error('Injected commit rejection') const saved = await append(...args) - if (args[3].stage === boundary) process.kill(process.pid, 'SIGKILL') + if (args[3].stage === boundary) { + await writeFile(readyFile!, boundary, { flag: 'wx' }) + // The parent force-terminates this owned process using the native platform API. + await new Promise(() => { setInterval(() => {}, 1000) }) + } return saved } -if (boundary === 'restore-linked') inventory.mutateAtomic = async () => { throw new Error('Injected CAS rejection') } await executeNativeToggle(operations, inventory, { operationId, ownerId: 'owner', baseline, authorize: async () => {} }) throw new Error('Crash boundary was not reached') diff --git a/cli/test/integration/doctor-command.test.ts b/cli/test/integration/doctor-command.test.ts index 4a4bf9fe6..d8c5b4a5f 100644 --- a/cli/test/integration/doctor-command.test.ts +++ b/cli/test/integration/doctor-command.test.ts @@ -195,7 +195,7 @@ describe('doctor command', () => { const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) as { schemaVersion: number; records: Array<{ targets: Array<{ version: string }> }> } - expect(inventory.schemaVersion).toBe(3) + expect(inventory.schemaVersion).toBe(2) expect(inventory.records).toHaveLength(1) expect(inventory.records[0]!.targets.map(target => target.version).sort()).toEqual(['1.0.0', '2.0.0']) }) diff --git a/cli/test/unit/integrations/install-crash-recovery.test.ts b/cli/test/unit/integrations/install-crash-recovery.test.ts index 13a5c9db6..cf7eee088 100644 --- a/cli/test/unit/integrations/install-crash-recovery.test.ts +++ b/cli/test/unit/integrations/install-crash-recovery.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { recoverInstallation } from '../../../src/integrations/install-recovery' import { OperationStore } from '../../../src/integrations/operation-store' -import { InventoryStore, type Inventory } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore, type Inventory } from '../../../src/stores/inventory-store' interface Fixture { operationId: string; ownerId: string; trustedTargetPaths: string[]; before: Inventory; oldContent: string; newContent: string } @@ -52,7 +52,7 @@ test.skipIf(process.platform === 'win32')('real killed installers recover all pr expect(denied.outcome).toBe('unknown') expect((await operations.get(fixture.operationId, fixture.ownerId))!.operation.status) .toMatchObject({ phase: 'recovery-required', recovery: 'incomplete' }) - expect(await inventory.read()).toEqual(fixture.before) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState(fixture.before)) expect(await Promise.all(['user', 'project'].map(scope => readdir(join(root, scope))))).toEqual(directories) } const result = await recoverInstallation({ ...fixture, operations, inventory }) @@ -69,10 +69,10 @@ test.skipIf(process.platform === 'win32')('real killed installers recover all pr expect(await readdir(join(root, scope))).toEqual(first && boundary !== 'after-commit' ? [] : ['writer']) if (!first || boundary === 'after-commit') expect(await readFile(join(root, scope, 'writer/SKILL.md'), 'utf8')).toBe(boundary === 'after-commit' ? fixture.newContent : fixture.oldContent) } - if (boundary !== 'after-commit') expect(await inventory.read()).toEqual(fixture.before) + if (boundary !== 'after-commit') expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState(fixture.before)) else { const committed = await inventory.read() - expect(committed).toEqual(saved.transaction!.inventoryCommit!.after) + expect(inventoryWithoutState(committed)).toEqual(inventoryWithoutState(saved.transaction!.inventoryCommit!.after)) expect(committed.items[0]!.targets[0]!.version).toBe('2.0.0') expect(committed.items[0]!.targets).toHaveLength(2) } diff --git a/cli/test/unit/integrations/installed-projection.test.ts b/cli/test/unit/integrations/installed-projection.test.ts index a16159380..fafa99b27 100644 --- a/cli/test/unit/integrations/installed-projection.test.ts +++ b/cli/test/unit/integrations/installed-projection.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'bun:test' -import { realpath, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { realpath, mkdtemp, mkdir, rm, rename, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { readInstalledProjection } from '../../../src/integrations/installed-projection' +import { readInstalledProjection, readAllInstalledProjection } from '../../../src/integrations/installed-projection' import type { InventoryItem } from '../../../src/stores/inventory-store' const registry = 'https://registry.example' @@ -57,3 +57,57 @@ test('inventory failure is partial and escaping targets are reported without rea expect(result.items[0]!.state).toBe('mismatch') expect(result.items[0]!.targetPath).toBe(join(trusted, 'writer')) }) + + +test('entry projection rejects missing, ambiguous and unsafe entries without inventing enabled state', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-projection-entry-'))) + try { + const path = join(root, 'writer') + const target = { version: '1.2.0', fingerprint: 'original', agent: 'dsh', rootDir: root, + installDir: path, installedAt: '2026-10-14T00:00:00Z' } + const item: InventoryItem = { registry, namespace: 'demo', slug: 'writer', targets: [target] } + await mkdir(join(path, '.skillhub'), { recursive: true }) + await writeFile(join(path, '.skillhub/metadata.json'), JSON.stringify({ ...item, ...target })) + const inventory = { read: async () => ({ items: [item] }) } + const read = () => readInstalledProjection(inventory, registry, [{ scope: 'project', root }]) + for (const mode of ['missing', 'enabled', 'both', 'disabled', 'symlink'] as const) { + if (mode === 'enabled') await writeFile(join(path, 'SKILL.md'), 'content') + if (mode === 'both') await writeFile(join(path, 'SKILL.md.disabled'), 'content') + if (mode === 'disabled') await rm(join(path, 'SKILL.md')) + if (mode === 'symlink') { await rename(join(path, 'SKILL.md.disabled'), join(path, 'other.md')); await symlink(join(path, 'other.md'), join(path, 'SKILL.md')) } + const result = await read() + const valid = mode === 'enabled' || mode === 'disabled' + expect(result.complete).toBe(valid) + expect(result.items[0]!.state).toBe(valid ? 'present' : 'unreadable') + expect(result.items[0]!.installationState).toBe(valid ? mode : undefined) + expect(result.issues).toEqual(valid ? [] : ['target-unreadable']) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) + + +test('all-source projection keeps trusted source identity and default source filtering', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-all-sources-'))) + try { + const items: InventoryItem[] = [] + for (const [scope, source] of [['user', registry], ['project', 'https://other.example']] as const) { + const rootDir = join(root, scope) + const target = { version: '1.0.0', agent: 'dsh', rootDir, installDir: join(rootDir, 'writer'), installedAt: 'today' } + const item = { registry: source, namespace: 'demo', slug: 'writer', targets: [target] } + items.push(item) + await mkdir(join(target.installDir, '.skillhub'), { recursive: true }) + await writeFile(join(target.installDir, '.skillhub/metadata.json'), JSON.stringify({ ...item, ...target })) + await writeFile(join(target.installDir, 'SKILL.md'), 'content') + } + const inventory = { read: async () => ({ items }) } + const targets = [{ scope: 'user' as const, root: join(root, 'user') }, { scope: 'project' as const, root: join(root, 'project') }] + const all = await readAllInstalledProjection(inventory, targets) + expect(all.complete).toBe(true) + expect(all.items.map(item => [item.registry, item.scope, item.state])).toEqual([ + [registry, 'user', 'present'], ['https://other.example', 'project', 'present'], + ]) + expect((await readInstalledProjection(inventory, registry, targets)).items).toHaveLength(1) + expect(await readAllInstalledProjection({ read: async () => { throw new Error('unreadable') } }, targets)) + .toEqual({ items: [], complete: false, issues: ['inventory-unreadable'] }) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/installed-update-snapshot.test.ts b/cli/test/unit/integrations/installed-update-snapshot.test.ts new file mode 100644 index 000000000..2512a1a59 --- /dev/null +++ b/cli/test/unit/integrations/installed-update-snapshot.test.ts @@ -0,0 +1,80 @@ +import { expect, spyOn, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import { basename, join } from 'node:path' +import { tmpdir } from 'node:os' +import { InventoryStore } from '../../../src/stores/inventory-store' +import { openInstalledProjectionSnapshot } from '../../../src/integrations/installed-projection' + +const registry = 'https://registry.example' +async function fixture(count: number, run: (inventory: InventoryStore, targets: { scope: 'user' | 'project'; root: string }[]) => Promise) { + const root = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'skillhub-update-scan-'))) + try { + const inventory = new InventoryStore(root) + const targets = [{ scope: 'user' as const, root: join(root, 'user') }, { scope: 'project' as const, root: join(root, 'project') }] + const items = [] + for (let index = 0; index < count / 2; index++) { + const slug = `skill-${index}` + const entries = targets.map(target => ({ rootDir: target.root, installDir: join(target.root, slug), agent: 'dsh', version: '1.0.0', installedAt: '2026-10-28', fingerprint: 'original' })) + for (const entry of entries) { + await fs.mkdir(join(entry.installDir, '.skillhub'), { recursive: true }) + await fs.writeFile(join(entry.installDir, 'SKILL.md'), 'local content') + await fs.writeFile(join(entry.installDir, '.skillhub/metadata.json'), JSON.stringify({ registry, namespace: 'demo', slug, ...entry })) + } + items.push({ registry, namespace: 'demo', slug, targets: entries }) + } + await inventory.write({ items }) + await run(inventory, targets) + } finally { await fs.rm(root, { recursive: true, force: true }) } +} + +// The timeout includes fixture creation and cleanup; performance is asserted by I/O counts, not wall time. +for (const size of [10, 100, 500]) test(`snapshot ${size} real filesystem targets has linear reads`, async () => { + await fixture(size, async (inventory, targets) => { + const read = spyOn(inventory, 'read') + const files = spyOn(fs, 'readFile') + const stats = spyOn(fs, 'lstat') + const started = performance.now() + try { + const snapshot = await openInstalledProjectionSnapshot(inventory, registry, targets) + expect(snapshot.projection.items).toHaveLength(size) + expect(snapshot.projection.complete).toBe(true) + for (const item of snapshot.projection.items) { await snapshot.read(item); await snapshot.read(item) } + const metadataReads = files.mock.calls.filter(([path]) => basename(String(path)) === 'metadata.json').length + const inventoryStats = stats.mock.calls.filter(([path]) => path === inventory.path).length + expect(read).toHaveBeenCalledTimes(1) + expect(metadataReads).toBe(size * 3) + expect(inventoryStats).toBe(size * 4 + 3) + console.info(JSON.stringify({ targets: size, inventoryReads: read.mock.calls.length, metadataReads, inventoryStats, elapsedMs: Math.round(performance.now() - started) })) + } finally { read.mockRestore(); files.mockRestore(); stats.mockRestore() } + }) +}, 30_000) + +test('snapshot rejects inventory ABA, atomic replacement and target metadata changes', async () => { + await fixture(2, async (inventory, targets) => { + let snapshot = await openInstalledProjectionSnapshot(inventory, registry, targets) + const original = await fs.readFile(inventory.path, 'utf8') + const before = await fs.stat(inventory.path) + await fs.writeFile(inventory.path, original.replace('demo', 'evil')) + await fs.writeFile(inventory.path, original) + await fs.utimes(inventory.path, before.atime, before.mtime) + await expect(snapshot.read(snapshot.projection.items[0]!)).rejects.toThrow('Inventory changed') + snapshot = await openInstalledProjectionSnapshot(inventory, registry, targets) + await fs.writeFile(inventory.path + '.replacement', original) + await fs.rename(inventory.path + '.replacement', inventory.path) + await expect(snapshot.read(snapshot.projection.items[0]!)).rejects.toThrow('Inventory changed') + snapshot = await openInstalledProjectionSnapshot(inventory, registry, targets) + await fs.writeFile(join(snapshot.projection.items[0]!.targetPath, '.skillhub/metadata.json'), '{}') + await expect(snapshot.read(snapshot.projection.items[0]!)).rejects.toThrow('Installed target changed') + }) +}) + +test('absent inventory is an empty snapshot; creation invalidates it', async () => { + await fixture(2, async (inventory, targets) => { + const existing = await openInstalledProjectionSnapshot(inventory, registry, targets) + await fs.unlink(inventory.path) + const empty = await openInstalledProjectionSnapshot(inventory, registry, targets) + expect(empty.projection).toEqual({ items: [], complete: true, issues: [] }) + await inventory.write({ items: [] }) + await expect(empty.read(existing.projection.items[0]!)).rejects.toThrow('Inventory changed') + }) +}) diff --git a/cli/test/unit/integrations/native-changes.test.ts b/cli/test/unit/integrations/native-changes.test.ts new file mode 100644 index 000000000..26315db30 --- /dev/null +++ b/cli/test/unit/integrations/native-changes.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from 'bun:test' +import { createHash, randomUUID } from 'node:crypto' +import { mkdtemp, readFile, realpath, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { zipSync } from 'fflate' +import { installSkill } from '../../../src/services/install-service' +import { InventoryStore } from '../../../src/stores/inventory-store' +import { openNativeRuntime } from '../../../src/integrations/native-runtime' +import { boundedDiff } from '../../../src/integrations/native-changes' +import type { NativeInstallAcceptance } from '../../../src/integrations/native-contract' + +async function fixture() { + const home = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-changes-'))) + const versions = ['1.0.0', '2.0.0'].map((version, i) => { + const files: Record = { 'SKILL.md': Buffer.from(`# Version ${version}`), 'resource.txt': Buffer.from(`resource${i}`), 'deleted.txt': Buffer.from('restore me') } + const hash = createHash('sha256') + for (const path of Object.keys(files).sort()) hash.update(`${path}:${createHash('sha256').update(files[path]!).digest('hex')}\n`) + return { version, versionId: i + 1, fingerprint: `sha256:${hash.digest('hex')}`, files } + }) + let badFingerprint = false + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { + const url = new URL(request.url), release = versions.find(v => v.version === url.searchParams.get('version')) ?? versions[0]! + if (url.pathname.endsWith('/resolve')) return Response.json({ data: { namespace: 'demo', slug: 'writer', version: release.version, versionId: release.versionId, + fingerprint: badFingerprint ? 'sha256:wrong' : release.fingerprint, downloadUrl: `/download?version=${release.version}` } }) + return new Response(new Uint8Array(zipSync(release.files)).buffer) + } }) + const selected = { registry: server.url.origin, namespace: 'demo', slug: 'writer', version: '1.0.0' } + const inventory = new InventoryStore(home), root = join(home, 'skills'), path = join(root, 'writer') + await installSkill({ ...selected, force: false, inventoryStore: inventory, targets: [{ agent: 'dsh', rootDir: root, scope: 'user', source: 'explicit' }] }) + const state = join(home, 'operations'), runtime = await openNativeRuntime({ stateDirectory: state, inventoryHome: home }), target = { root, scope: 'user' as const } + const ready = async () => { const s = await runtime.inspectChanges(selected, target); if (s.status !== 'ready') throw Error(s.reason); return s } + const accept = async () => runtime.acceptFileReset({ hostInstanceId: runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { reset: randomUUID() } }, async (): Promise => ({ ...selected, + targetLabel: 'User', targetPaths: [path], context: { sourceIdentity: selected.registry, sourceLabel: 'Fixture', sourceRevision: 'r1', hostLabel: 'Local', initiatedFrom: 'plaza' } })) + return { home, root, path, state, versions, selected, target, inventory, runtime, ready, accept, bad: () => { badFingerprint = true }, close: async () => { await server.stop(true); await rm(home, { recursive: true, force: true }) } } +} +test('local changes list and bounded body use exact release and include added/deleted/modified files', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'user edits'); await writeFile(join(f.path, 'extra.txt'), 'new'); await unlink(join(f.path, 'deleted.txt')) + const snapshot = await f.ready() + expect(snapshot.changes).toEqual([{ path: 'deleted.txt', kind: 'deleted' }, { path: 'extra.txt', kind: 'added' }, { path: 'resource.txt', kind: 'modified' }]) + expect((await f.runtime.readChangeDiff(snapshot, 'resource.txt')).lines).toEqual([{ kind: 'deleted', text: 'resource0' }, { kind: 'added', text: 'user edits' }]) + f.bad(); await expect(f.runtime.readChangeDiff(snapshot, 'resource.txt')).rejects.toThrow('identity') + } finally { await f.close() } +}) +test('version comparison is read-only and rejects downgrade', async () => { + const f = await fixture() + try { + const before = await readFile(f.inventory.path) + const diff = await f.runtime.prepareVersionChanges(f.selected, f.target, f.versions[1]!) + expect(diff.changes.map(v => v.path)).toEqual(['resource.txt', 'SKILL.md']) + expect((await f.runtime.readChangeDiff(diff, 'SKILL.md')).state).toBe('text') + await expect(f.runtime.prepareVersionChanges(f.selected, f.target, f.versions[0]!)).rejects.toThrow('newer') + expect(await readFile(f.inventory.path)).toEqual(before) + } finally { await f.close() } +}) +test.each([false, true])('reset preserves inventory metadata/version and disabled state=%s', async disabled => { + const f = await fixture() + try { + if (disabled) await rename(join(f.path, 'SKILL.md'), join(f.path, 'SKILL.md.disabled')) + await writeFile(join(f.path, 'resource.txt'), 'user edits'); await writeFile(join(f.path, 'extra.txt'), 'new'); await unlink(join(f.path, 'deleted.txt')) + const metadata = await readFile(join(f.path, '.skillhub/metadata.json')), inventory = await readFile(f.inventory.path) + const snapshot = await f.ready(), op = (await f.accept())! + const result = await f.runtime.executeFileReset({ operationId: op.operationId, ownerId: 'owner', snapshot, paths: ['resource.txt'], authorize: async () => {} }) + expect(result.outcome).toBe('committed') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('resource0') + expect(await readFile(join(f.path, 'extra.txt'), 'utf8')).toBe('new') + const next = await f.ready(), op2 = (await f.accept())! + expect((await f.runtime.executeFileReset({ operationId: op2.operationId, ownerId: 'owner', snapshot: next, paths: next.changes.map(v => v.path), authorize: async () => {} })).outcome).toBe('committed') + expect((await f.ready()).changes).toEqual([]) + expect(await readFile(join(f.path, disabled ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + expect(await readFile(join(f.path, '.skillhub/metadata.json'))).toEqual(metadata) + expect(await readFile(f.inventory.path)).toEqual(inventory) + expect((await f.runtime.getOperation(op.operationId, 'owner'))?.files).toBe('files-reset') + } finally { await f.close() } +}) +test('changed snapshot or unlisted path never writes a file', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'first'); const snapshot = await f.ready(); const op = (await f.accept())! + await writeFile(join(f.path, 'resource.txt'), 'second') + expect((await f.runtime.executeFileReset({ operationId: op.operationId, ownerId: 'owner', snapshot, paths: ['resource.txt'], authorize: async () => {} })).outcome).toBe('inventory-before') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('second') + const op2 = (await f.accept())! + await expect(f.runtime.executeFileReset({ operationId: op2.operationId, ownerId: 'owner', snapshot: await f.ready(), paths: ['../outside'], authorize: async () => {} })).rejects.toThrow('parameters') + } finally { await f.close() } +}) +test('backup is byte-exact and receipt authorizes dirty upgrade without changing inventory format', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'team customization') + const snapshot = await f.ready(), receipt = await f.runtime.backupChanges(snapshot, async () => {}) + expect(await readFile(join(f.home, 'backups', receipt.id, 'files/resource.txt'), 'utf8')).toBe('team customization') + const version = f.versions[1]!, op = (await f.runtime.acceptUpgrade({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { upgrade: true } }, async () => ({ namespace: f.selected.namespace, slug: f.selected.slug, version: version.version, targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Fixture', sourceRevision: 'r1', hostLabel: 'Local', initiatedFrom: 'plaza' } })))! + expect((await f.runtime.executeUpgrade({ operationId: op.operationId, ownerId: 'owner', ...f.selected, ...version, baseline: snapshot.baseline, + localChangesAuthorization: { snapshot, backupReceipt: receipt }, authorize: async () => {}, validateStaged: async () => {} })).outcome).toBe('committed') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('resource1') + expect(JSON.parse(await readFile(f.inventory.path, 'utf8')).schemaVersion).toBe(2) + expect(await readFile(join(f.home, 'backups', receipt.id, 'files/resource.txt'), 'utf8')).toBe('team customization') + } finally { await f.close() } +}) +test('backup failure or post-backup changes cannot authorize overwriting newer local files', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'before') + const snapshot = await f.ready() + await expect(f.runtime.backupChanges(snapshot, async () => { throw Error('Denied') })).rejects.toThrow('Denied') + const receipt = await f.runtime.backupChanges(snapshot, async () => {}) + await writeFile(join(f.path, 'resource.txt'), 'later') + const version = f.versions[1]!, op = (await f.runtime.acceptUpgrade({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { upgrade: true } }, async () => ({ namespace: f.selected.namespace, slug: f.selected.slug, version: version.version, targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Fixture', sourceRevision: 'r1', hostLabel: 'Local', initiatedFrom: 'plaza' } })))! + await expect(f.runtime.executeUpgrade({ operationId: op.operationId, ownerId: 'owner', ...f.selected, ...version, baseline: snapshot.baseline, + localChangesAuthorization: { snapshot, backupReceipt: receipt }, authorize: async () => {}, validateStaged: async () => {} })).rejects.toThrow('stale') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('later') + } finally { await f.close() } +}) +test('reset failure after old directory move is recoverable and blocks other lifecycle writes', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'local') + const snapshot = await f.ready(), op = (await f.accept())! + // Edit after replacement authorization: the captured backup differs, so publish must stop. + const result = await f.runtime.executeFileReset({ operationId: op.operationId, ownerId: 'owner', snapshot, paths: ['resource.txt'], authorize: async (_paths, phase) => { + if (phase === 'replace') await writeFile(join(f.path, 'resource.txt'), 'race') + } }) + expect(result.outcome).toBe('unknown') + expect((await f.runtime.listPending()).some(item => item.operationId === op.operationId)).toBe(true) + // Recovery refuses to overwrite a later edit rather than silently choosing either snapshot. + await expect(f.runtime.recover(op.operationId, 'owner', [f.path], async () => {})).rejects.toThrow('backup changed') + } finally { await f.close() } +}) +test('unsafe symlink is never compared or reset', async () => { + if (process.platform === 'win32') return + const f = await fixture() + try { await symlink(join(f.path, 'SKILL.md'), join(f.path, 'link')); expect((await f.runtime.inspectChanges(f.selected, f.target)).status).toBe('blocked') } + finally { await f.close() } +}) +test('bounded diff handles binary invalid UTF-8 and large text without quadratic work', () => { + expect(boundedDiff('a', Buffer.from([0, 1]), Buffer.from('a')).state).toBe('binary') + expect(boundedDiff('a', Buffer.from([0xff]), Buffer.from('a')).state).toBe('binary') + expect(boundedDiff('a', Buffer.alloc(300000, 65), Buffer.from('a')).state).toBe('too-large') + const value = boundedDiff('a', Buffer.from('a\n'.repeat(4000)), Buffer.from('b\n'.repeat(4000))) + expect(value.lines).toHaveLength(2000); expect(value.truncated).toBe(true) +}) + +test('reset acceptance is idempotent after response loss and blocks new legacy acceptance', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'edit') + const request = { hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new' as const, requestKey: randomUUID(), parameters: { reset: true } } + const acceptance = { namespace: f.selected.namespace, slug: f.selected.slug, version: f.selected.version, targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Fixture', sourceRevision: 'r1', hostLabel: 'Local', initiatedFrom: 'plaza' as const } } + const first = (await f.runtime.acceptFileReset(request, async () => acceptance))! + expect((await f.runtime.acceptFileReset(request, async () => { throw Error('must not reevaluate') }))?.operationId).toBe(first.operationId) + await expect(f.runtime.acceptUpgrade({ ...request, requestKey: randomUUID() }, async () => acceptance)).rejects.toThrow('recovery required') + expect((await f.runtime.executeFileReset({ operationId: first.operationId, ownerId: 'owner', snapshot: await f.ready(), paths: ['resource.txt'], authorize: async () => {} })).outcome).toBe('committed') + } finally { await f.close() } +}) + +test.each([false, true])('dirty upgrade preserves disabled=%s and verifies retained dirty transaction backups', async disabled => { + const f = await fixture() + try { + if (disabled) await rename(join(f.path, 'SKILL.md'), join(f.path, 'SKILL.md.disabled')) + await writeFile(join(f.path, 'resource.txt'), 'customized') + const snapshot = await f.ready(), backupReceipt = await f.runtime.backupChanges(snapshot, async () => {}) + const version = f.versions[1]! + const operation = (await f.runtime.acceptUpgrade({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { upgrade: true } }, async () => ({ namespace: f.selected.namespace, slug: f.selected.slug, version: version.version, targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Fixture', sourceRevision: 'r1', hostLabel: 'Local', initiatedFrom: 'plaza' } })))! + const result = await f.runtime.executeUpgrade({ operationId: operation.operationId, ownerId: 'owner', ...f.selected, ...version, baseline: snapshot.baseline, + localChangesAuthorization: { snapshot, backupReceipt }, authorize: async (_paths, phase) => { if (phase === 'cleanup') throw Error('cleanup denied') }, validateStaged: async () => {} }) + expect(['committed', 'unknown']).toContain(result.outcome) + expect(await readFile(join(f.path, disabled ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Version 2.0.0') + expect((await f.runtime.recover(operation.operationId, 'owner', [f.path], async () => {})).outcome).toBe('committed') + expect(await readFile(join(f.home, 'backups', backupReceipt.id, 'files/resource.txt'), 'utf8')).toBe('customized') + } finally { await f.close() } +}) + +test('backup file and manifest raw cannot be forged together to authorize a dirty overwrite', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'original local customization') + const snapshot = await f.ready(), backupReceipt = await f.runtime.backupChanges(snapshot, async () => {}) + const backup = join(f.home, 'backups', backupReceipt.id) + await writeFile(join(backup, 'files/resource.txt'), 'forged') + const manifest = JSON.parse(await readFile(join(backup, 'manifest.json'), 'utf8')) + manifest.raw.files['resource.txt'] = createHash('sha256').update('forged').digest('hex') + await writeFile(join(backup, 'manifest.json'), JSON.stringify(manifest)) + const version = f.versions[1]!, operation = (await f.runtime.acceptUpgrade({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { upgrade: true } }, async () => ({ namespace: f.selected.namespace, slug: f.selected.slug, version: version.version, targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Fixture', sourceRevision: 'r1', hostLabel: 'Local', initiatedFrom: 'plaza' } })))! + await expect(f.runtime.executeUpgrade({ operationId: operation.operationId, ownerId: 'owner', ...f.selected, ...version, baseline: snapshot.baseline, + localChangesAuthorization: { snapshot, backupReceipt }, authorize: async () => {}, validateStaged: async () => {} })).rejects.toThrow('Backup changed') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('original local customization') + } finally { await f.close() } +}) + +test('committed reset with unfinished cleanup remains pending across restart and recovers only verified files', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'custom') + const operation = (await f.accept())!, snapshot = await f.ready() + expect((await f.runtime.executeFileReset({ operationId: operation.operationId, ownerId: 'owner', snapshot, paths: ['resource.txt'], authorize: async (_paths, phase) => { + if (phase === 'cleanup') throw Error('stop after durable commit') + } })).outcome).toBe('unknown') + const restarted = await openNativeRuntime({ stateDirectory: f.state, inventoryHome: f.home }) + expect((await restarted.listPending()).some(record => record.operationId === operation.operationId)).toBe(true) + expect((await restarted.recover(operation.operationId, 'owner', [f.path], async () => {})).outcome).toBe('committed') + expect(await restarted.listPending()).toEqual([]) + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('resource0') + } finally { await f.close() } +}) + +test('recovery policy yield cannot erase a newly changed reset backup', async () => { + const f = await fixture() + try { + await writeFile(join(f.path, 'resource.txt'), 'custom') + const operation = (await f.accept())!, snapshot = await f.ready() + await f.runtime.executeFileReset({ operationId: operation.operationId, ownerId: 'owner', snapshot, paths: ['resource.txt'], authorize: async (_paths, phase) => { if (phase === 'cleanup') throw Error('stop') } }) + const backup = `${f.path}.skillhub-backup-${operation.operationId}` + await expect(f.runtime.recover(operation.operationId, 'owner', [f.path], async () => { + await writeFile(join(backup, 'resource.txt'), 'new content during authorization') + })).rejects.toThrow('changed after authorization') + expect(await readFile(join(backup, 'resource.txt'), 'utf8')).toBe('new content during authorization') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('resource0') + } finally { await f.close() } +}) + +test('backup quota includes its manifest and never deletes older backups', async () => { + const f = await fixture() + try { + const { mkdir, open, readdir } = await import('node:fs/promises') + const { treeBytes } = await import('../../../src/integrations/native-changes') + await writeFile(join(f.path, 'resource.txt'), 'custom') + const bytes = await treeBytes(f.path), directory = join(f.home, 'backups', 'retained') + await mkdir(directory, { recursive: true }) + const sparse = await open(join(directory, 'large.bin'), 'w') + try { await sparse.truncate(1024 * 1024 * 1024 - bytes - 64) } finally { await sparse.close() } + await expect(f.runtime.backupChanges(await f.ready(), async () => {})).rejects.toThrow('quota') + expect(await readdir(join(f.home, 'backups'))).toEqual(['retained']) + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('custom') + } finally { await f.close() } +}) + +test('reset never adopts or removes an occupied staging directory', async () => { + const f = await fixture() + try { + const { mkdir } = await import('node:fs/promises') + await writeFile(join(f.path, 'resource.txt'), 'custom') + const operation = (await f.accept())!, snapshot = await f.ready() + const staging = `${f.path}.skillhub-reset-stage-${operation.operationId}` + await mkdir(staging); await writeFile(join(staging, 'keep.txt'), 'competitor') + expect((await f.runtime.executeFileReset({ operationId: operation.operationId, ownerId: 'owner', snapshot, paths: ['resource.txt'], authorize: async () => {} })).outcome).toBe('inventory-before') + expect(await readFile(join(staging, 'keep.txt'), 'utf8')).toBe('competitor') + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('custom') + } finally { await f.close() } +}) + +test('automatic change inspection blocks oversized local files before hashing their bodies', async () => { + const f = await fixture() + try { + const { open } = await import('node:fs/promises') + const { assertChangeTreeBudget } = await import('../../../src/integrations/change-tree-budget') + const huge = await open(join(f.path, 'large.bin'), 'w') + try { await huge.truncate(10 * 1024 * 1024 + 1) } finally { await huge.close() } + await expect(assertChangeTreeBudget(f.path)).rejects.toThrow('package limit') + expect(await f.runtime.inspectChanges(f.selected, f.target)).toEqual({ status: 'blocked', reason: 'target-unavailable' }) + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('resource0') + } finally { await f.close() } +}) + +test('durable tree flush keeps existing nested binary and text file bytes intact', async () => { + const f = await fixture() + try { + const { mkdir } = await import('node:fs/promises') + const { syncTree } = await import('../../../src/integrations/native-changes') + const directory = join(f.home, 'flush-copy') + await mkdir(join(directory, 'nested'), { recursive: true }) + await writeFile(join(directory, 'text.txt'), 'flush does not replace or truncate this file\n') + await writeFile(join(directory, 'nested', 'binary.dat'), Buffer.from([0, 255, 1, 2, 3])) + await syncTree(directory) + expect(await readFile(join(directory, 'text.txt'), 'utf8')).toBe('flush does not replace or truncate this file\n') + expect(await readFile(join(directory, 'nested', 'binary.dat'))).toEqual(Buffer.from([0, 255, 1, 2, 3])) + } finally { await f.close() } +}) diff --git a/cli/test/unit/integrations/native-removal.test.ts b/cli/test/unit/integrations/native-removal.test.ts index aca0e8f74..f788cef19 100644 --- a/cli/test/unit/integrations/native-removal.test.ts +++ b/cli/test/unit/integrations/native-removal.test.ts @@ -7,7 +7,7 @@ import { executeNativeRemoval } from '../../../src/integrations/native-removal' import { recoverRemoval } from '../../../src/integrations/removal-recovery' import { inspectRemovalBaseline } from '../../../src/integrations/removal-baseline' import { OperationStore, operationRequestDigest } from '../../../src/integrations/operation-store' -import { InventoryStore } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore } from '../../../src/stores/inventory-store' import { snapshotSkillDirectory } from '../../../src/services/skill-fingerprint' test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-denied', 'cleanup-denied', 'commit-ack-lost', 'commit-log-failed', 'inventory-response-lost'] as const)( @@ -75,21 +75,21 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni } }) if (scenario === 'stale-root-file') { await expect(execute()).rejects.toThrow('Removal root changed') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) const recover = () => recoverRemoval({ operations, inventory, operationId: record.operationId, ownerId: 'session', trustedTargetPaths: [path], authorizeMutation: async () => {} }) expect((await recover()).outcome).toBe('unknown') expect(await readFile(target.root, 'utf8')).toBe('Concurrent root replacement') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) await rm(target.root) await mkdir(target.root) expect((await recover()).outcome).toBe('restored') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) return } if (scenario === 'commit-ack-lost' || scenario === 'commit-log-failed' || scenario === 'inventory-response-lost') { await expect(execute()).rejects.toThrow('Commit acknowledgment lost') - expect(await inventory.read()).toEqual({ items: [{ ...item, targets: [retained] }] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [{ ...item, targets: [retained] }] })) await expect(readFile(join(path, 'SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readFile(join(quarantine, 'SKILL.md'), 'utf8')).toBe('# Original') const saved = (await operations.get(record.operationId, 'session'))! @@ -97,7 +97,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni expect(saved.removal?.inventoryCommit?.committed).toBe(scenario === 'commit-ack-lost') } else if (scenario === 'local-change' || scenario === 'commit-denied') { await expect(execute()).rejects.toThrow(scenario === 'local-change' ? 'baseline changed' : 'Commit denied') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) if (scenario === 'local-change') { expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('# User edit') expect((await operations.get(record.operationId, 'session'))?.operation.status.phase).toBe('failed') @@ -108,7 +108,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni } } else { expect((await execute()).outcome).toBe('committed') - expect(await inventory.read()).toEqual({ items: [{ ...item, targets: [retained] }] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [{ ...item, targets: [retained] }] })) await expect(readFile(join(path, 'SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) const saved = (await operations.get(record.operationId, 'session'))! expect(saved.operation.status.phase).toBe('completed') @@ -146,7 +146,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni operationId: record.operationId, ownerId: 'session', trustedTargetPaths: [path], authorizeMutation: async () => {}, }) expect(outcome.outcome).toBe('unknown') - expect(await inventory.read()).toEqual(conflictedInventory) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState(conflictedInventory)) expect(await readFile(join(quarantine, 'SKILL.md'), 'utf8')).toBe('# Original') if (conflict === 'occupied-path') { expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('# New occupant after commit') @@ -178,7 +178,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni expect((await recover()).outcome).toBe(recovered.outcome) if (scenario === 'commit-denied') { expect(await readFile(join(path, 'SKILL.md'), 'utf8')).toBe('# Original') - expect(await inventory.read()).toEqual({ items: [item] }) + expect(inventoryWithoutState(await inventory.read())).toEqual(inventoryWithoutState({ items: [item] })) } expect(await operations.listForRecovery()).toEqual([]) } finally { await rm(home, { recursive: true, force: true }) } diff --git a/cli/test/unit/integrations/native-toggle.test.ts b/cli/test/unit/integrations/native-toggle.test.ts index e1e6b2e6d..52b9a7e5f 100644 --- a/cli/test/unit/integrations/native-toggle.test.ts +++ b/cli/test/unit/integrations/native-toggle.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test' import { randomUUID } from 'node:crypto' -import { cp, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { cp, mkdtemp, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import { tmpdir } from 'node:os' import { join } from 'node:path' import { InventoryStore } from '../../../src/stores/inventory-store' @@ -41,6 +42,9 @@ test('offline toggles rename only entry, preserve bytes, reject stale plans, and })) const result = await f.runtime.executeToggle({ operationId: operation!.operationId, ownerId: 'owner', baseline, authorize: async () => {} }) expect(result.outcome).toBe('committed') + const disk = JSON.parse(await readFile(f.inventory.path, 'utf8')) + expect(disk.schemaVersion).toBe(2) + expect(disk.records[0].targets[0]).not.toHaveProperty('installationState') expect((await f.runtime.getOperation(operation!.operationId, 'owner'))?.files).toBe(desired) expect(await readFile(join(f.path, desired === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') expect(await readFile(join(f.path, '.skillhub/metadata.json'))).toEqual(metadata) @@ -81,16 +85,12 @@ test.each(['cas-before', 'cas-after', 'intent', 'committed-log'] as const)('togg namespace: f.selected.namespace, slug: f.selected.slug, version: f.selected.version, kind: 'disable', targetLabel: 'User', targetPaths: [f.path], context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } })) - const mutate = f.inventory.mutateAtomic.bind(f.inventory) - if (mode.startsWith('cas')) f.inventory.mutateAtomic = async (...args) => { - if (mode === 'cas-after') await mutate(...args) - throw new Error('Injected CAS failure') - } const append = operations.recordToggle.bind(operations) let once = true operations.recordToggle = async (...args) => { - if (once && (mode === 'intent' && args[3].stage === 'intent' || mode === 'committed-log' && args[3].stage === 'committed')) { + if (once && (mode === 'intent' && args[3].stage === 'intent' || mode !== 'intent' && args[3].stage === 'committed')) { once = false + if (mode === 'cas-after' || mode === 'committed-log') await append(...args) throw new Error('Injected journal failure') } return append(...args) @@ -123,7 +123,7 @@ test.each([false, true])('restart recovers a renamed entry without overwriting a } await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow('Crash window') - expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('disabled') expect(await readFile(join(f.path, 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') if (conflict) await writeFile(join(f.path, 'SKILL.md'), 'New occupant') const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) @@ -135,7 +135,7 @@ test.each([false, true])('restart recovers a renamed entry without overwriting a } finally { await rm(f.home, { recursive: true, force: true }) } }) -test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, renamed and committed boundaries', async () => { +test('killed toggle writers recover intent, renamed and committed boundaries', async () => { const fixtures = await Promise.all(['intent', 'linked', 'moved', 'restore-linked', 'committed'].map(async boundary => { const f = await fixture() const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { boundary } }, async () => ({ @@ -145,12 +145,47 @@ test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, return { ...f, boundary, operationId: accepted!.operationId } })) try { - await Promise.all(fixtures.map(async f => { - const child = Bun.spawn([process.execPath, new URL('../../fixtures/toggle-crash-child.ts', import.meta.url).pathname, f.home, f.operationId, f.boundary], + const crashes = await Promise.allSettled(fixtures.map(async f => { + const readyFile = join(f.home, 'crash-ready') + const child = Bun.spawn([process.execPath, fileURLToPath(new URL('../../fixtures/toggle-crash-child.ts', import.meta.url)), f.home, f.operationId, f.boundary, readyFile], { stdout: 'pipe', stderr: 'pipe' }) - expect(await child.exited).not.toBe(0) - expect(await new Response(child.stderr).text()).toBe('') + const waitForExit = async (exited: Promise) => { + let timer: ReturnType | undefined + try { + return await Promise.race([exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Owned crash process did not exit within 10 seconds')), 10000) + })]) + } finally { clearTimeout(timer) } + } + const terminate = async () => { + if (child.exitCode !== null) return + if (process.platform === 'win32') { + const killer = Bun.spawn(['taskkill.exe', '/PID', String(child.pid), '/T', '/F'], { stdout: 'ignore', stderr: 'pipe' }) + try { expect(await waitForExit(killer.exited)).toBe(0) } finally { if (killer.exitCode === null) killer.kill() } + } else child.kill('SIGKILL') + } + try { + const deadline = Date.now() + 20000 + let ready = false + while (Date.now() < deadline && child.exitCode === null) { + try { ready = (await readFile(readyFile, 'utf8')) === f.boundary } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (ready) break + await Bun.sleep(25) + } + expect(ready).toBe(true) + await terminate() + expect(await waitForExit(child.exited)).not.toBe(0) + expect(await new Response(child.stderr).text()).toBe('') + } finally { + // Cleanup uses only this child handle even if taskkill failed; it is not a passing crash assertion. + if (child.exitCode === null) child.kill() + await waitForExit(child.exited) + } })) + const failures = crashes.filter((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Crash fixtures failed after all owned children settled') // Reverse-link recovery holds the inventory lease; its stale timeout is 30 seconds. await Bun.sleep(31_500) for (const f of fixtures) { @@ -162,7 +197,7 @@ test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, expect(await readFile(join(f.path, state === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') } } finally { await Promise.all(fixtures.map(f => rm(f.home, { recursive: true, force: true }))) } -}, 60_000) +}, 90_000) test.each(['forward', 'rollback'] as const)('exclusive entry creation preserves a competitor appearing after preflight: %s', async direction => { const f = await fixture() @@ -181,11 +216,14 @@ test.each(['forward', 'rollback'] as const)('exclusive entry creation preserves if (direction === 'rollback' && args[3].stage === 'restoring') await writeFile(args[3].from, 'Competing entry', { flag: 'wx' }) return saved } - if (direction === 'rollback') f.inventory.mutateAtomic = async () => { throw new Error('CAS denied') } + if (direction === 'rollback') { + const write = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { if (args[3].stage === 'committed') throw new Error('Commit denied'); return write(...args) } + } await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} })).rejects.toThrow() expect(await readFile(join(f.path, direction === 'forward' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('Competing entry') expect(await readFile(join(f.path, direction === 'forward' ? 'SKILL.md' : 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') - expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBeUndefined() expect((await f.runtime.getOperation(accepted!.operationId, 'owner'))?.phase).toBe('recovery-required') } finally { await rm(f.home, { recursive: true, force: true }) } }) @@ -210,7 +248,71 @@ test('independent toggle transactions preserve both targets in shared inventory' })) return { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} } })) - expect((await Promise.all(requests.map(request => f.runtime.executeToggle(request)))).map(result => result.outcome)).toEqual(['committed', 'committed']) + const results = await Promise.allSettled(requests.map(request => f.runtime.executeToggle(request))) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Independent toggle transactions failed') + expect(results.map(result => result.status === 'fulfilled' ? result.value.outcome : undefined)).toEqual(['committed', 'committed']) expect((await f.inventory.read()).items[0]!.targets.map(target => target.installationState)).toEqual(['disabled', 'disabled']) } finally { await rm(f.home, { recursive: true, force: true }) } }) + +test.each(['enabled', 'disabled'] as const)('existing v3 recovery respects persisted %s commit evidence', async storedState => { + const f = await fixture() + try { + const baseline = await f.runtime.inspectToggle(f.selected, f.target, 'disabled') + if (baseline.status !== 'ready') throw new Error('Missing baseline') + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { storedState } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'disable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const operations = await OperationStore.open(f.stateDirectory) + const append = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { + const saved = await append(...args) + if (args[3].stage === 'moved') throw new Error('Lost legacy response') + return saved + } + await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, + authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow() + const inventory = await f.inventory.read() + inventory.items[0]!.targets[0]!.installationState = storedState + await writeFile(f.inventory.path, JSON.stringify({ schemaVersion: 3, records: inventory.items })) + const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) + expect((await restarted.recover(accepted!.operationId, 'owner', [f.path], async () => {})).outcome).toBe(storedState === 'disabled' ? 'committed' : 'restored') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe(storedState) + await f.inventory.ensureWritable() + expect(await f.inventory.diskVersion()).toBe(2) + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test('recovery never overwrites a legitimate replacement after an interrupted enable', async () => { + const f = await fixture() + try { + await rename(join(f.path, 'SKILL.md'), join(f.path, 'SKILL.md.disabled')) + const baseline = await f.runtime.inspectToggle(f.selected, f.target, 'enabled') + if (baseline.status !== 'ready') throw new Error('Missing disabled baseline') + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { enable: true } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'enable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const operations = await OperationStore.open(f.stateDirectory) + const append = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { + const saved = await append(...args) + if (args[3].stage === 'moved') throw new Error('Interrupted enable') + return saved + } + await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, + authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow() + await writeFile(join(f.path, 'SKILL.md'), '# Legitimate replacement\n') + const snapshot = await snapshotSkillDirectory(f.path) + const target = { ...baseline.inventoryItem.targets[0]!, version: '2.0.0', installedAt: '2026-10-29', fingerprint: snapshot.fingerprint, installationState: 'enabled' as const } + await writeFile(join(f.path, '.skillhub/metadata.json'), JSON.stringify({ ...f.selected, version: target.version, ...snapshot, agent: 'dsh', installedAt: target.installedAt })) + await f.inventory.writeAtomic({ items: [{ ...baseline.inventoryItem, targets: [target] }] }) + const paths = [f.inventory.path, join(f.path, 'SKILL.md'), join(f.path, '.skillhub/metadata.json'), join(f.path, 'resource.txt')] + const before = await Promise.all(paths.map(path => readFile(path))) + const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) + expect((await restarted.recover(accepted!.operationId, 'owner', [f.path], async () => {})).outcome).toBe('unknown') + expect(await Promise.all(paths.map(path => readFile(path)))).toEqual(before) + } finally { await rm(f.home, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/operation-store.test.ts b/cli/test/unit/integrations/operation-store.test.ts index c1573e0b9..a6adcd89e 100644 --- a/cli/test/unit/integrations/operation-store.test.ts +++ b/cli/test/unit/integrations/operation-store.test.ts @@ -1,6 +1,7 @@ +import { renameAtomicFile } from '../../../src/platform/atomic-rename' import { expect, test } from 'bun:test' import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { OperationStore, operationRequestDigest } from '../../../src/integrations/operation-store' @@ -114,3 +115,103 @@ test('pending journals block parent and child targets including aliases with mis await store.assertTargetsAvailable([sibling], current) expect((await store.get(pending.operationId, pending.ownerId))!.operation.status.phase).toBe('unknown') })) + +test('ordinary journals remain v2 while activation journals are isolated and replayable', () => fixture(async (store, root) => { + const ordinary = (await store.accept(request(store), async () => accepted))! + const key = request(store) + const activation = (await store.accept(key, async () => ({ ...accepted, kind: 'disable' })))! + expect(JSON.parse(await readFile(join(root, `${ordinary.operationId}.json`), 'utf8')).schemaVersion).toBe(2) + expect(JSON.parse(await readFile(join(root, 'activation', `${activation.operationId}.json`), 'utf8')).schemaVersion).toBe(3) + expect((await readdir(root)).filter(name => name.endsWith('.json')).sort()).toEqual([`${ordinary.operationId}.json`, 'host.json'].sort()) + const reopened = await OperationStore.open(root) + expect((await reopened.accept({ ...key, mode: 'retry' }, async () => { throw new Error('No replay mutation') }))?.operationId).toBe(activation.operationId) + expect((await reopened.listForRecovery()).map(record => record.operationId)).toContain(activation.operationId) + expect((await reopened.accept(request(reopened), async () => accepted))?.schemaVersion).toBe(2) +})) + +test('existing root v3 journal is moved byte-for-byte outside legacy scan', () => fixture(async (store, root) => { + const ordinary = (await store.accept(request(store), async () => accepted))! + const bytes = JSON.stringify({ ...ordinary, schemaVersion: 3 }, null, 3) + await writeFile(join(root, `${ordinary.operationId}.json`), bytes) + const reopened = await OperationStore.open(root) + expect(await readFile(join(root, 'activation', `${ordinary.operationId}.json`), 'utf8')).toBe(bytes) + expect(await readdir(root)).not.toContain(`${ordinary.operationId}.json`) + expect((await reopened.get(ordinary.operationId, ordinary.ownerId))?.schemaVersion).toBe(3) +})) + +test.each(['identical', 'changed'] as const)('interrupted journal isolation handles %s duplicate without hiding evidence', mode => fixture(async (store, root) => { + const ordinary = (await store.accept(request(store), async () => accepted))! + const path = join(root, `${ordinary.operationId}.json`) + await mkdir(join(root, 'activation')) + await writeFile(join(root, 'activation', `${ordinary.operationId}.json`), await readFile(path)) + if (mode === 'changed') { + await writeFile(path, JSON.stringify({ ...ordinary, revision: 1 })) + await expect(OperationStore.open(root)).rejects.toMatchObject({ code: 'CORRUPT_RECORD' }) + expect(JSON.parse(await readFile(path, 'utf8')).revision).toBe(1) + expect(JSON.parse(await readFile(join(root, 'activation', `${ordinary.operationId}.json`), 'utf8')).revision).toBe(0) + } else { + const reopened = await OperationStore.open(root) + expect(await readdir(root)).not.toContain(`${ordinary.operationId}.json`) + await reopened.update(ordinary.operationId, ordinary.ownerId, 0, { ...ordinary.operation.status, phase: 'staging' }) + expect((await reopened.get(ordinary.operationId, ordinary.ownerId))?.revision).toBe(1) + expect(await readdir(root)).not.toContain(`${ordinary.operationId}.json`) + } +})) + + +test.each(['EPERM', 'EBUSY'])('journal replacement retries %s under the store lock without replaying validation or revision', async code => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-journal-retry-')) + let armed = false; let attempts = 0; let validations = 0 + const reachedRename = Promise.withResolvers() + let firstAttempt: { source: string; target: string; payload: string } | undefined + let previous: string | undefined + let competing: Promise[]> | undefined + try { + const store = await OperationStore.open(root, (from, to) => renameAtomicFile(from, to, async (source, target) => { + if (armed) { + const observation = { source: String(source), target: String(target), payload: (await readFile(source)).toString('hex') } + if (!firstAttempt) firstAttempt = observation + else expect(observation).toEqual(firstAttempt) + expect((await readFile(target)).toString('hex')).toEqual(previous!) + if (++attempts < 3) { reachedRename.resolve(); throw Object.assign(new Error('Sharing failure'), { code }) } + } + await rename(source, target) + }, 'win32')) + const key = request(store) + const initial = (await store.accept(key, async () => { validations++; return accepted }))! + const other = await OperationStore.open(root) + previous = (await readFile(join(root, initial.operationId + '.json'))).toString('hex') + armed = true + const updating = store.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' }) + await reachedRename.promise + competing = Promise.allSettled([other.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' })]) + const staged = await updating + expect(staged.revision).toBe(1) + expect((await competing)[0]!.status).toBe('rejected') + expect(attempts).toBe(3) + expect(validations).toBe(1) + expect((await store.get(initial.operationId, key.ownerId))!.revision).toBe(1) + expect((await store.accept({ ...key, mode: 'retry' }, async () => { throw new Error('must not replay') }))!.operationId).toBe(initial.operationId) + } finally { await competing; await rm(root, { recursive: true, force: true }) } +}) + +test('exhausted journal replacement preserves original bytes and releases the store lock', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-journal-exhaust-')) + let armed = false; let attempts = 0 + const failure = Object.assign(new Error('Sharing failure'), { code: 'EPERM' }) + try { + const store = await OperationStore.open(root, (from, to) => renameAtomicFile(from, to, async (source, target) => { + if (armed) { attempts++; throw failure } + await rename(source, target) + }, 'win32')) + const key = request(store); const initial = (await store.accept(key, async () => accepted))! + const path = join(root, initial.operationId + '.json'); const before = await readFile(path) + armed = true + await expect(store.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' })).rejects.toBe(failure) + expect(attempts).toBe(6) + expect(await readFile(path)).toEqual(before) + expect((await readdir(root)).filter(name => name.endsWith('.tmp'))).toEqual([]) + armed = false + expect((await store.update(initial.operationId, key.ownerId, 0, { ...initial.operation.status, phase: 'staging' })).revision).toBe(1) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/removal-crash-recovery.test.ts b/cli/test/unit/integrations/removal-crash-recovery.test.ts index 8ec0bdd1c..ef4846db5 100644 --- a/cli/test/unit/integrations/removal-crash-recovery.test.ts +++ b/cli/test/unit/integrations/removal-crash-recovery.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { openNativeRuntime } from '../../../src/integrations/native-runtime' -import { InventoryStore, type Inventory } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore, type Inventory } from '../../../src/stores/inventory-store' interface Fixture { operationId: string; ownerId: string; path: string; retainedPath: string; quarantine: string | null; before: Inventory; content: string } @@ -41,9 +41,9 @@ test.skipIf(process.platform === 'win32')('killed removal and recovery processes const result = await runtime.recover(fixture.operationId, fixture.ownerId, [fixture.path], async () => {}) expect(result.outcome, `${boundary}/${mode}: ${result.reason}`).toBe(committed ? 'committed' : 'restored') const beforeItem = fixture.before.items[0]! - expect(await new InventoryStore(join(root, 'home')).read()).toEqual(committed + expect(inventoryWithoutState(await new InventoryStore(join(root, 'home')).read())).toEqual(inventoryWithoutState(committed ? { items: [{ ...beforeItem, targets: beforeItem.targets.filter(target => target.installDir !== fixture.path) }] } - : fixture.before) + : fixture.before)) expect(await readFile(join(fixture.retainedPath, 'SKILL.md'), 'utf8')).toBe(fixture.content) if (committed || mode === 'stale') await expect(readFile(join(fixture.path, 'SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) else expect(await readFile(join(fixture.path, 'SKILL.md'), 'utf8')).toBe(fixture.content) diff --git a/cli/test/unit/integrations/removal-inventory.test.ts b/cli/test/unit/integrations/removal-inventory.test.ts index 3c227a40b..e299a0505 100644 --- a/cli/test/unit/integrations/removal-inventory.test.ts +++ b/cli/test/unit/integrations/removal-inventory.test.ts @@ -11,7 +11,7 @@ test('removal commits exactly one target, journals before writing and preserves try { const store = new InventoryStore(home) const root = join(home, 'skills') - const target = { installationState: 'enabled' as const, version: '1.0.0', fingerprint: 'fingerprint', agent: 'dsh', rootDir: root, installDir: join(root, 'writer'), installedAt: '2026-10-14' } + const target = { version: '1.0.0', fingerprint: 'fingerprint', agent: 'dsh', rootDir: root, installDir: join(root, 'writer'), installedAt: '2026-10-14' } const retained = { ...target, agent: 'other', rootDir: join(home, 'other'), installDir: join(home, 'other/writer') } const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', targets: [target, retained] } const other = { ...item, slug: 'reader', targets: [{ ...target, installDir: join(root, 'reader') }] } diff --git a/cli/test/unit/integrations/transaction-journal.test.ts b/cli/test/unit/integrations/transaction-journal.test.ts index 799aa4c0c..5def5c732 100644 --- a/cli/test/unit/integrations/transaction-journal.test.ts +++ b/cli/test/unit/integrations/transaction-journal.test.ts @@ -8,7 +8,7 @@ import { OperationStore, operationRequestDigest } from '../../../src/integration import { createInstallTransactionObserver } from '../../../src/integrations/transaction-journal' import { installSkill } from '../../../src/services/install-service' import { SkillHubClient } from '../../../src/clients/skillhub-client' -import { InventoryStore } from '../../../src/stores/inventory-store' +import { inventoryWithoutState, InventoryStore } from '../../../src/stores/inventory-store' class LocalClient extends SkillHubClient { async download(): Promise { @@ -54,7 +54,7 @@ test('real installation persists move intent before rename and both inventory sn beforeInventoryCommit: async commit => { await observer.beforeInventoryCommit!(commit) const saved = (await store.get(operation.operationId, ownerId))! - expect(saved.transaction?.inventoryCommit).toEqual({ ...commit, committed: false }) + expect(saved.transaction?.inventoryCommit).toEqual({ before: inventoryWithoutState(commit.before), after: inventoryWithoutState(commit.after), committed: false }) expect((await new InventoryStore(home).read()).items).toEqual([]) } } @@ -135,7 +135,7 @@ test.each([false, true])('preparation acknowledgment failure leaves target uncha expect(await readdir(join(root, 'skills'))).toEqual(existing ? ['writer'] : []) const saved = (await store.get(operation.operationId, ownerId))! expect(saved.transaction!.moves).toEqual([]) - expect(saved.transaction!.preparation!.before).toEqual(before) + expect(inventoryWithoutState(saved.transaction!.preparation!.before)).toEqual(inventoryWithoutState(before)) if (existing) expect(await readFile(join(root, 'skills/writer/SKILL.md'), 'utf8')).toBe('# Journal fixture') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/cli/test/unit/platform/atomic-rename.test.ts b/cli/test/unit/platform/atomic-rename.test.ts new file mode 100644 index 000000000..ee0522b3e --- /dev/null +++ b/cli/test/unit/platform/atomic-rename.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { renameAtomicFile } from '../../../src/platform/atomic-rename' + +test.each(['EPERM', 'EBUSY'])('Windows atomic rename retries transient %s without removing the destination', async code => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-atomic-rename-')) + const from = join(root, 'temporary'); const to = join(root, 'inventory') + try { + await writeFile(from, 'next'); await writeFile(to, 'original') + let calls = 0 + await renameAtomicFile(from, to, async (source, destination) => { + expect(await readFile(to, 'utf8')).toBe('original') + expect(await readFile(from, 'utf8')).toBe('next') + if (++calls < 3) throw Object.assign(new Error('Sharing failure'), { code }) + await rename(source, destination) + }, 'win32') + expect(calls).toBe(3) + expect(await readFile(to, 'utf8')).toBe('next') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test.each([['linux', 'EPERM'], ['darwin', 'EBUSY'], ['win32', 'EACCES'], ['win32', 'ENOENT']] as const)( + '%s does not retry %s outside the Windows sharing-failure boundary', async (platform, code) => { + const error = Object.assign(new Error('Permanent failure'), { code }); let calls = 0 + await expect(renameAtomicFile('source', 'destination', async () => { calls++; throw error }, platform)).rejects.toBe(error) + expect(calls).toBe(1) + }) diff --git a/cli/test/unit/services/skill-target-lock.test.ts b/cli/test/unit/services/skill-target-lock.test.ts index 99964f6f3..5802a2b3a 100644 --- a/cli/test/unit/services/skill-target-lock.test.ts +++ b/cli/test/unit/services/skill-target-lock.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'bun:test' import { acquireSkillTargetLock, + acquireTargetLock, assertPrivateLockDir, ensurePrivateLockDir, skillTargetLockPath, @@ -236,3 +237,71 @@ describe('skill target lifecycle lock', () => { expect(await exists(lockPathBeforeRemoval)).toBe(false) }) }) + + +test.each(['EPERM', 'EBUSY'])('Windows target lock retries only a transient mkdir %s on the selected lock', async code => { + const path = join(tmpdir(), 'skillhub-injected-lock'); let calls = 0 + const release = async () => {} + expect(await acquireTargetLock(path, async (target, options) => { + expect(target).toBe(path) + expect(options).toEqual({ lockfilePath: path, realpath: false, stale: 10000, update: 3000, retries: 0 }) + if (++calls < 3) throw Object.assign(new Error('Sharing failure'), { code, syscall: 'mkdir', path }) + return release + }, 'win32')).toBe(release) + expect(calls).toBe(3) +}) + +test('Windows target lock exhaustion preserves the original mkdir error', async () => { + const path = join(tmpdir(), 'skillhub-injected-lock') + const error = Object.assign(new Error('Sharing failure'), { code: 'EPERM', syscall: 'mkdir', path }); let calls = 0 + await expect(acquireTargetLock(path, async () => { calls++; throw error }, 'win32')).rejects.toBe(error) + expect(calls).toBe(6) +}) + +test.each([ + ['linux', 'EPERM', 'mkdir', true], ['darwin', 'EBUSY', 'mkdir', true], + ['win32', 'ELOCKED', 'mkdir', true], ['win32', 'EACCES', 'mkdir', true], + ['win32', 'EPERM', 'rmdir', true], ['win32', 'EPERM', 'mkdir', false], +] as const)('target lock rejects %s %s %s without broadening retry ownership', async (platform, code, syscall, selectedPath) => { + const path = join(tmpdir(), 'skillhub-injected-lock') + const error = Object.assign(new Error('No retry'), { code, syscall, path: selectedPath ? path : path + '-other' }); let calls = 0 + await expect(acquireTargetLock(path, async () => { calls++; throw error }, platform)).rejects.toBe(error) + expect(calls).toBe(1) +}) + +test('Windows target lock retries never steal a live proper-lockfile owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-lock-live-owner-')) + const path = join(root, 'target.lock'); const release = await acquireTargetLock(path) + try { + const original = await lstat(path) + await expect(acquireTargetLock(path, undefined, 'win32')).rejects.toMatchObject({ code: 'ELOCKED' }) + expect((await lstat(path)).mtimeMs).toBe(original.mtimeMs) + } finally { await release(); await rm(root, { recursive: true, force: true }) } +}) + + +test('target acquisition gate excludes another contender throughout Windows retry backoff', async () => { + const root = await mkdtemp(join(tmpdir(), 'skillhub-lock-retry-gate-')) + let secondEntered = false; let attempts = 0 + let second: Promise Promise>[]> | undefined + let release: (() => Promise) | undefined + try { + release = await acquireSkillTargetLock(root, 'writer', path => acquireTargetLock(path, async () => { + if (++attempts === 1) { + second = Promise.allSettled([acquireSkillTargetLock(root, 'writer', async otherPath => { + secondEntered = true; return acquireTargetLock(otherPath) + })]) + throw Object.assign(new Error('Sharing failure'), { code: 'EPERM', syscall: 'mkdir', path }) + } + expect(secondEntered).toBe(false) + return acquireTargetLock(path) + }, 'win32')) + const result = await second! + expect(attempts).toBe(2) + expect(secondEntered).toBe(true) + expect(result[0]!.status).toBe('rejected') + } finally { + if (second) for (const result of await second) if (result.status === 'fulfilled') await result.value() + await release?.(); await rm(root, { recursive: true, force: true }) + } +}) diff --git a/cli/test/unit/stores/inventory-store.test.ts b/cli/test/unit/stores/inventory-store.test.ts index d5a710c4c..a049acbcc 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -1,5 +1,6 @@ +import { renameAtomicFile } from '../../../src/platform/atomic-rename' import { describe, expect, test } from 'bun:test' -import { mkdir, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, dirname } from 'node:path' @@ -15,7 +16,6 @@ function makeTarget(slug: string) { agent: 'claude', rootDir: `/projects/${slug}`, installDir: `/projects/${slug}/.skillhub/skills/${slug}`, - installationState: 'enabled' as const, installedAt: new Date().toISOString(), } } @@ -166,3 +166,31 @@ describe('InventoryStore', () => { ]) }) }) + + test('exhausted Windows rename retries preserve inventory, clean temporary files, and do not replay the mutation', async () => { + const home = await makeTempHome() + const original = new InventoryStore(home) + let attempts = 0 + const failure = Object.assign(new Error('Persistent sharing failure'), { code: 'EPERM' }) + class FailingRenameStore extends InventoryStore { + protected override async replaceInventoryFile(temporaryPath: string): Promise { + await renameAtomicFile(temporaryPath, this.path, async () => { attempts++; throw failure }, 'win32') + } + } + try { + await original.writeAtomic({ items: [] }) + const bytes = await readFile(original.path) + const failing = new FailingRenameStore(home) + let mutations = 0 + await expect(failing.mutateAtomic(inventory => { + mutations++ + inventory.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'writer', targets: [] }) + })).rejects.toBe(failure) + expect(attempts).toBe(6) + expect(mutations).toBe(1) + expect(await readFile(original.path)).toEqual(bytes) + expect((await readdir(dirname(original.path))).filter(name => name.endsWith('.tmp'))).toEqual([]) + await original.mutateAtomic(inventory => { inventory.items.push({ registry: 'https://registry.example', namespace: 'team', slug: 'other', targets: [] }) }) + expect((await original.read()).items.map(item => item.slug)).toEqual(['other']) + } finally { await rm(home, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/stores/inventory-v2.test.ts b/cli/test/unit/stores/inventory-v2.test.ts index c497e0f3e..3be485183 100644 --- a/cli/test/unit/stores/inventory-v2.test.ts +++ b/cli/test/unit/stores/inventory-v2.test.ts @@ -58,7 +58,7 @@ test('v1 read preserves bytes and migration expands ownership without inventing expect(backups).toHaveLength(1) expect(await readFile(join(dirname(f.store.path), backups[0]!), 'utf8')).toBe(bytes) const disk = JSON.parse(await readFile(f.store.path, 'utf8')) - expect(disk.schemaVersion).toBe(3); expect(disk).not.toHaveProperty('items'); expect(disk.records[0]).not.toHaveProperty('version') + expect(disk.schemaVersion).toBe(2); expect(disk).not.toHaveProperty('items'); expect(disk.records[0]).not.toHaveProperty('version') expect(await f.store.read()).toEqual(read) } finally { await f.close() } }) @@ -74,7 +74,7 @@ test.each(['before', 'after'] as const)('migration %s rename failure leaves one const store = new FailingStore(join(f.root, 'home')) if (mode === 'before') await expect(store.ensureWritable()).rejects.toThrow('injected') else await store.ensureWritable() - expect(await store.diskVersion()).toBe(mode === 'before' ? 1 : 3) + expect(await store.diskVersion()).toBe(mode === 'before' ? 1 : 2) expect(await store.read()).toEqual({ items: [] }) expect((await readdir(dirname(store.path))).filter(name => name.includes('.backup.json'))).toHaveLength(1) } finally { await f.close() } @@ -113,7 +113,10 @@ test('independent prepared upgrades commit without losing siblings', async () => try { for (let i = 0; i < 3; i++) await f.install(i, '1.0.0') const first = await f.prepare(0); const second = await f.prepare(1) - expect((await Promise.all([f.runtime.executeUpgrade(first), f.runtime.executeUpgrade(second)])).map(result => result.outcome)).toEqual(['committed', 'committed']) + const results = await Promise.allSettled([f.runtime.executeUpgrade(first), f.runtime.executeUpgrade(second)]) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Independent upgrades failed') + expect(results.map(result => result.status === 'fulfilled' ? result.value.outcome : undefined)).toEqual(['committed', 'committed']) const targets = (await f.store.read()).items[0]!.targets expect(f.roots.map(root => targets.find(target => target.rootDir === root.root)?.version)).toEqual(['3.0.0', '3.0.0', '1.0.0']) } finally { await f.close() } @@ -289,7 +292,7 @@ test.each(['suite-only', 'shared', 'suite-claim'] as const)('direct CLI mutation } finally { await f.close() } }) -test('v2 read is byte-preserving; v3 migration backs up exact v2 bytes and makes target states explicit', async () => { +test('v2 read and writable checks preserve exact compatible bytes', async () => { const f = await fixture() try { await f.install(0, '1.0.0') @@ -301,10 +304,29 @@ test('v2 read is byte-preserving; v3 migration backs up exact v2 bytes and makes expect((await f.store.read()).items[0]!.targets[0]!.installationState).toBe('enabled') expect(await readFile(f.store.path, 'utf8')).toBe(bytes) await f.store.ensureWritable() - const digest = createHash('sha256').update(bytes).digest('hex') - expect(await readFile(join(dirname(f.store.path), `inventory.v2.${digest}.backup.json`), 'utf8')).toBe(bytes) + expect(await readFile(f.store.path, 'utf8')).toBe(bytes) const disk = JSON.parse(await readFile(f.store.path, 'utf8')) - expect(disk.schemaVersion).toBe(3) - expect(disk.records).toEqual(v3.items) + expect(disk.schemaVersion).toBe(2) + expect(disk.records).toEqual(v2.records) + } finally { await f.close() } +}) + + test.each(['matching', 'conflict'] as const)('v3 conversion verifies files and preserves original bytes: %s', async mode => { + const f = await fixture() + try { + await f.install(0, '1.0.0') + const inventory = await f.store.read() + if (mode === 'conflict') inventory.items[0]!.targets[0]!.installationState = 'disabled' + const bytes = JSON.stringify({ schemaVersion: 3, records: inventory.items }) + await writeFile(f.store.path, bytes) + if (mode === 'conflict') { + await expect(f.store.ensureWritable()).rejects.toThrow('requires recovery') + expect(await readFile(f.store.path, 'utf8')).toBe(bytes) + } else { + await f.store.ensureWritable() + expect(await f.store.diskVersion()).toBe(2) + expect(await readFile(join(dirname(f.store.path), `inventory.v3.${createHash('sha256').update(bytes).digest('hex')}.backup.json`), 'utf8')).toBe(bytes) + expect(JSON.parse(await readFile(f.store.path, 'utf8')).records[0].targets[0]).not.toHaveProperty('installationState') + } } finally { await f.close() } }) diff --git a/compose.analytics-umami.yml b/compose.analytics-umami.yml new file mode 100644 index 000000000..cbd365f2d --- /dev/null +++ b/compose.analytics-umami.yml @@ -0,0 +1,39 @@ +# Independent Compose project. Only Umami joins the shared external network. +name: ${SKILLHUB_ANALYTICS_PROJECT:-skillhub-analytics} +services: + analytics-postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: umami + POSTGRES_USER: umami + POSTGRES_PASSWORD: ${UMAMI_DB_PASSWORD:?provide a separate database password} + volumes: + - analytics_postgres_data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, pg_isready -U umami -d umami] + interval: 5s + timeout: 3s + retries: 20 + umami: + image: ghcr.io/umami-software/umami:3.4.1 + restart: unless-stopped + environment: + # Use a URL-safe password (letters/digits recommended), or percent-encode + # its password component in UMAMI_DATABASE_URL separately. + DATABASE_URL: ${UMAMI_DATABASE_URL:-postgresql://umami:${UMAMI_DB_PASSWORD:?provide a separate database password}@analytics-postgres:5432/umami} + APP_SECRET: ${UMAMI_APP_SECRET:?provide a separate random app secret} + ports: + - "127.0.0.1:${UMAMI_ADMIN_PORT:-8300}:3000" + depends_on: + analytics-postgres: + condition: service_healthy + networks: + - default + - analytics +volumes: + analytics_postgres_data: +networks: + analytics: + external: true + name: ${SKILLHUB_ANALYTICS_NETWORK:-skillhub-analytics} diff --git a/compose.release.analytics.yml b/compose.release.analytics.yml new file mode 100644 index 000000000..fd46cfebf --- /dev/null +++ b/compose.release.analytics.yml @@ -0,0 +1,10 @@ +# Optional network attachment; keep the normal Web default network for server. +services: + web: + networks: + - default + - analytics +networks: + analytics: + external: true + name: ${SKILLHUB_ANALYTICS_NETWORK:-skillhub-analytics} diff --git a/compose.release.yml b/compose.release.yml index 963ef479c..c450a7657 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -90,7 +90,7 @@ services: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:-true} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} - DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-/device} + DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required} SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3} STORAGE_BASE_PATH: /var/lib/skillhub/storage @@ -209,6 +209,8 @@ services: SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false} SKILLHUB_WEB_AUTH_DIRECT_PROVIDER: ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER:-} + SKILLHUB_WEB_ANALYTICS_CONFIG: ${SKILLHUB_WEB_ANALYTICS_CONFIG:-} + SKILLHUB_WEB_ANALYTICS_UPSTREAM: ${SKILLHUB_WEB_ANALYTICS_UPSTREAM:-} depends_on: server: condition: service_healthy diff --git a/deploy/k8s/analytics-umami/umami.yaml b/deploy/k8s/analytics-umami/umami.yaml new file mode 100644 index 000000000..44c08eade --- /dev/null +++ b/deploy/k8s/analytics-umami/umami.yaml @@ -0,0 +1,93 @@ +# Independent analytics deployment unit; create namespace stats and secret umami-credentials first. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: umami-postgres-data + namespace: stats +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 2Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: umami-postgres + namespace: stats +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: {app: umami-postgres} + template: + metadata: + labels: {app: umami-postgres} + spec: + containers: + - name: postgres + image: postgres:16-alpine + env: + - {name: POSTGRES_DB, value: umami} + - {name: POSTGRES_USER, value: umami} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: {name: umami-credentials, key: db-password} + ports: [{containerPort: 5432}] + readinessProbe: + exec: + command: [pg_isready, -U, umami, -d, umami] + periodSeconds: 5 + volumeMounts: + - {name: data, mountPath: /var/lib/postgresql/data} + volumes: + - name: data + persistentVolumeClaim: {claimName: umami-postgres-data} +--- +apiVersion: v1 +kind: Service +metadata: + name: umami-postgres + namespace: stats +spec: + selector: {app: umami-postgres} + ports: [{port: 5432}] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: umami + namespace: stats +spec: + replicas: 1 + selector: + matchLabels: {app: umami} + template: + metadata: + labels: {app: umami} + spec: + containers: + - name: umami + image: ghcr.io/umami-software/umami:3.4.1 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: {name: umami-credentials, key: database-url} + - name: APP_SECRET + valueFrom: + secretKeyRef: {name: umami-credentials, key: app-secret} + ports: [{containerPort: 3000}] + readinessProbe: + httpGet: {path: /login, port: 3000} + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: umami + namespace: stats +spec: + selector: {app: umami} + ports: [{port: 3000}] diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 92b48f7df..5ac2e4889 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -95,6 +95,23 @@ Public API 的可见性规则: - 若 `latest_version_id = null`,即使 `visibility=PUBLIC`,skill 也不会对外公开,只有 owner 可访问 - `hidden=true` 时,普通访客不可访问;仅 owner 或该 namespace 的 `ADMIN` / `OWNER` 可访问 +### 技能详情的公共页面地址 + +`GET /api/v1/skills/{namespace}/{slug}` 的 `data.webUrl` 是可空的 URI 字符串, +指向该技能的 SkillHub 网页详情,而非作者自定义外站地址,也不是 CLI `resolve` 响应字段。 +它只取自部署配置 `skillhub.public.base-url`(环境变量 `SKILLHUB_PUBLIC_BASE_URL`), +不根据请求的 Host 或转发头推测地址,不改变技能原有访问权限。 + +- 有效配置须为带主机的 HTTP(S) 地址,不能包含用户凭据、查询参数或片段。 +- 配置缺省、空白或不符合上述校验时返回 `null`;客户端应隐藏该跳转入口,不拼造链接。 +- 保留公开地址中的子路径,去掉结尾斜杠,再追加 `/space/{namespace}/{slug}`。 + 两个路径段分别按 UTF-8 URL 编码,空格为 `%20`,段内斜杠为 `%2F`。 +- 例如公开地址 `https://skills.example.com/portal/` 和技能 `team/demo`, + 返回 `https://skills.example.com/portal/space/team/demo`。 + +实现与边界测试:[SkillWebUrls](../server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillWebUrls.java)、 +[SkillWebUrlsTest](../server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillWebUrlsTest.java)。 + `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` 的 `data` 字段除版本基础信息外,还必须包含: - `parsedMetadataJson`:`SKILL.md` frontmatter 的完整 JSON 序列化结果 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 04706d6d9..c15034ae1 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -1,6 +1,56 @@ # skillhub 部署架构与运维 对象存储可选部署、凭证和迁移说明见[对象存储手册](object-storage.md)。 +可选前端动作统计默认关闭。Docker/Compose 和 Helm 只填配置即可接入 Umami, +详见[前端动作统计接入指南](frontend-analytics-integration.md)。 + +### CLI 设备授权地址 + +`skillhub login --no-open --registry https://skills.example.com` 先按 `--token`、 +`SKILLHUB_TOKEN`、该 registry 已保存凭据的顺序解析 Token。仅在未解析到 Token 时 +进入设备码授权;已解析的 Token 若验证失败,不会自动回退到设备码流程。`--no-open` +只禁止自动打开浏览器,不会跳过 Token 解析。 + +设备码流程中,CLI 请求 `/api/v1/auth/device/code`,用户打开返回的 `/device` 页面并登录、确认授权, +CLI 再轮询兑换 Token。完成后用 `skillhub whoami --registry https://skills.example.com` +确认登录身份;不要分享设备码、用户码或 Token。 + +- `DEVICE_AUTH_VERIFICATION_URI` 未设置、为空或仅含空白时,服务端使用 + `SKILLHUB_PUBLIC_BASE_URL` 加 `/device`,保留子路径并移除末尾重复斜杠。 + 例如 `https://skills.example.com/portal/` 对应 `https://skills.example.com/portal/device`。 +- 若公开地址也未设置,保留兼容的 `/device` 相对地址,由 CLI 按 registry 的源解析。 + 正式部署、子路径部署或 Web/API 分离部署应设置正确的 Web 公开地址。 +- 显式指定有效的 HTTP(S) 地址或根相对路径时原样使用,支持自定义页面和查询参数。 + 非法地址会阻止服务启动,错误提示只包含配置项名称,不回显配置值。 + 宿主机进程修改配置后需重启;Compose 部署修改 `.env.release` 后需重建受影响容器, + `docker compose restart` 不会更新容器环境变量。 +- Compose 的空配置交由后端处理;Helm 的 `deviceAuthVerificationUri` 默认对应 + `/device`,也可显式覆盖。 + +对已运行的单机交付栈,仅修改设备授权地址时,先编辑原部署目录中的 `.env.release`: + +- 源码部署:在包含 `Makefile` 和 `scripts/validate-release-config.sh` 的仓库根目录, + 先执行 `make validate-release-config`,修正错误后再重建。 +- 一键脚本部署:进入脚本输出的 `Runtime dir`(即该实例的 `--home` 目录)。脚本仅下载 + Compose 清单和环境模板,不提供上述 Make 校验入口,不要在该目录执行 `make`。 + +以下重建命令适用于未自定义项目名、未使用 override 的标准栈: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --no-deps --force-recreate server +``` + +若原部署使用了 `-p`、`COMPOSE_PROJECT_NAME` 或额外的 `-f` 文件,重建时必须保留原项目名、 +环境文件及按原顺序排列的全部 `-f` 参数,不能直接照抄以上基础命令;否则可能操作另一实例 +或丢失 override 中的配置。此操作只重建已有后端容器,不重新下载部署文件。 + +首次部署仍使用完整栈的 `up -d`。若同时修改公开地址等其他服务也使用的配置, +应一并重建相应服务。验证设备授权地址时,使用未传 `--token`、未设置 `SKILLHUB_TOKEN`, +且该 registry 没有保存凭据的 CLI 环境,再执行上述登录命令并核对返回地址; +已有 Token 的登录成功不能证明设备授权地址已更新。不要仅凭配置文件内容判断已生效。 + +`/cli/auth` 是旧版浏览器 loopback 回调入口,需要客户端附带回调参数;它不是输入设备码的页面。 +不要将设备授权地址设成这个旧入口。此次配置保护不改变旧客户端回调流程。 ## 1 运行模型 diff --git a/docs/README.md b/docs/README.md index e0c505c0f..c437494ca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ Use the documentation site for user workflows, repository guides for engineering - [Docker deployment and upgrades](skillhub/en/guide/deployment.md) / [中文](skillhub/guide/deployment.md) - [Kubernetes](skillhub/en/guide/kubernetes.md) / [中文](skillhub/guide/kubernetes.md) +- [Optional frontend analytics](skillhub/en/guide/frontend-analytics.md) / [前端动作统计](skillhub/guide/frontend-analytics.md) - [Deployment reference](09-deployment.md), [object storage](object-storage.md), [SMTP and password reset](19-smtp-password-reset-email-setup.md) - [Security scanning](security-scanning.md), [observability](observability-developer-guide.md), [private SSO integration](12-private-sso-integration-playbook.md) diff --git a/docs/examples/analytics/README.md b/docs/examples/analytics/README.md new file mode 100644 index 000000000..f61f7401f --- /dev/null +++ b/docs/examples/analytics/README.md @@ -0,0 +1,6 @@ +# 可选统计接入 + +旧的部署方脚本注入示例已迁入产品模块,不再作为部署入口。 +请使用[配置接入指南](../../frontend-analytics-integration.md)。 +唯一源码位于 `web/src/features/analytics/`。PR-1 的历史 edge 配置仅作为测试 fixture 保留, +不应部署。无需复制 JS,也不需要额外 Nginx。 diff --git a/docs/frontend-analytics-integration.md b/docs/frontend-analytics-integration.md new file mode 100644 index 000000000..a109ebd7a --- /dev/null +++ b/docs/frontend-analytics-integration.md @@ -0,0 +1,412 @@ +# 前端动作统计接入指南 + + +## 1. 默认关闭,按需开启 + +不配置时,不加载采集模块或 SDK,也不发送统计请求。正常业务不等待统计平台。 +现在 Docker、Compose、Helm 用户只需填写配置。不需要改 JS、复制文件或手写 Nginx。 +现在支持 Umami 3.4.1 和 DataFinder(火山引擎增长分析)。其他平台需要开发并发布新的注册 adapter,不是任意远程脚本插件。 +一键脚本可新建独立 Umami,或接入已有平台;Helm 下 Umami 是独立部署单元。下方给出三条路径。 + +点击下载只是下载意图,不是下载或安装成功。提交发布也不是发布成功。 +部署方负责授权策略、平台安全、数据保留和合规。接口和事件边界见[英文契约][analytics-reference]。 + +## 2. 复用已有 Umami + +前提:SkillHub 已正常部署;Web 镜像包含本功能;有可达的 Umami 3.4.1。 +先在 Umami 创建网站,拿到公开的网站 UUID。管理员密码、API token 不要放入前端配置。 +确认 Web 容器能够访问平台的 HTTP(S) origin。HTTPS 证书需由镜像的系统 CA 信任。 +自签名平台需先按组织规范配置受信任 CA,不要关闭证书验证。 + +在受保护的 `.env.release` 中填写下面两项。把示例 UUID 换成自己网站的 UUID。 +JSON 保持一行。单引号用于 Compose 的 dotenv 文件,不是 JSON 的一部分。 + +```dotenv +SKILLHUB_WEB_ANALYTICS_CONFIG='{"enabled":true,"adapter":"umami","consent":"granted","adapterConfig":{"websiteId":"4398de8e-40df-4483-9eea-ed213643525f"}}' +SKILLHUB_WEB_ANALYTICS_UPSTREAM=https://stats.example.com +``` + +`granted` 表示部署方确认当前策略允许采集,不表示每位用户都点击了同意。 +需要用户单独授权时,初始使用 `denied`,再接入第 5 节的公开接口。本功能不提供同意弹窗。 + +所有命令从仓库根目录运行。正常发布配置仍需包含数据库、存储和认证等参数。 + +```sh +make validate-release-config +docker compose --env-file .env.release -f compose.release.yml config --quiet +docker compose --env-file .env.release -f compose.release.yml up -d --no-deps --force-recreate web +docker compose --env-file .env.release -f compose.release.yml logs --tail=100 web +``` + +不要公开分享完整的 Compose 渲染结果,其中可能包含业务凭证。 +Web 启动时自动生成同源代理,只开放 `/_skillhub-analytics/script.js` 和 `/_skillhub-analytics/api/send`。 +其他采集路径返回 404,不公开 Umami 管理界面。统计 DNS 在请求时解析,不是 Web 启动前提。 +统计配置错误只关闭统计。容器日志中的 `[analytics]` 提示字段,不回显原值。 +原有 API/auth 配置、100 MiB 上传上限、TLS、业务缓存及权限不变。不放宽 CSP。 + +直接运行 Docker 时,把同样两项用 `--env-file` 传给正常的 Web 容器即可。文件使用原始 +`KEY=value` 格式,JSON 外不要加 dotenv 单引号。保留原有 API、公开地址和端口配置。 +不要把 API 上游改成统计平台地址。 + +## 2.1 接入 DataFinder(火山引擎增长分析) + +适合已有公有云或私有部署的平台。先在平台创建应用。 +打开该应用的 Web SDK 接入页,找到数字 `app_id` 和采集域名(channel domain)。 +每个私有部署可能不同。填写采集域名,不是管理后台地址,也不带 `/list`。 +确认 Web 容器到该域名的网络、DNS 和 HTTPS 证书正常。 + +用下面两项替换第 2 节的 Umami 配置。`123456` 是虚构应用 ID,请换成自己的值。 + +```dotenv +SKILLHUB_WEB_ANALYTICS_CONFIG='{"enabled":true,"adapter":"datafinder","consent":"granted","adapterConfig":{"appId":123456,"product":"skillhub"}}' +SKILLHUB_WEB_ANALYTICS_UPSTREAM=https://collector.example.com +``` + +沿用第 2 节的校验和重建 Web 命令。不安装官方 JS SDK,也不加载远程脚本。 +浏览器只通过同源 `/_skillhub-analytics/list` 发 POST,子路径自动加前缀。 +其他采集路径(包括 Umami 的 script.js、api/send)保持 404。 +浏览器配置不包含采集域名。代理保留浏览器 User-Agent。 + +Helm 使用 values 文件,不用 `--set` 传 JSON。保留原有生产 values,再追加: + +```yaml +web: + analytics: + config: '{"enabled":true,"adapter":"datafinder","consent":"granted","adapterConfig":{"appId":123456,"product":"skillhub"}}' + upstream: https://collector.example.com +``` + +然后按第 4 节升级 Web。Chart 不创建 DataFinder 服务。 +`appId` 必须是 1 到 2147483647 的整数。`product` 可省略,默认 `skillhub`。 +product 只接受小写字母开头,后接小写字母、数字或下划线,最长 32 字符。 +除此之外的 adapterConfig 字段全部拒绝。不要填写 App Key(`X-MCS-AppKey`)。 +它用于服务端 HTTP 接入,不属于浏览器采集。 + +如何核对: + +1. 新开页面。在 Network 确认应用先挂载,再加载采集 chunk。不会自动发 pageview。 +2. 搜索或点击配置允许的动作。检查 POST `/list` 的 JSON 数组。每次一个 batch、一个 event。 + `events[0].event` 是原始动作 ID,例如 `skill.download.request`,点号不替换。 + `params` 是 JSON 字符串,例如 `{"event":"click"}`,不是对象。 +3. `header.app_id` 是应用 ID。`header.custom.product` 和 `header.custom.module` 在平台显示为公共属性。 + product 区分同一应用下的产品。module 是动作发生页的路由模板,例如 `/space/$namespace/$slug`。 + 不含真实 namespace、slug、查询或 hash;未匹配页面为 `other`。params 中的 event 是事件属性。 +4. 回复应有 `e:0` 和 `sc:1`。只有 HTTP 200 不代表接收成功。 + 再到该应用的实时事件或事件分析页,按动作名、product 和 module 核对。 +5. 调用 stop 后继续操作,业务应正常且没有新的 `/list`。默认关闭或 denied 的新页面不加载采集 chunk, + 不生成浏览器统计 ID;denied 只有显式 start 才运行。授权方式和停用步骤与第 5、7 节相同。 + +排查时不要分享完整配置或事件中的浏览器 ID: + +| 现象 | 检查 | +| --- | --- | +| `/list` 400 `UserAgent is not allowed` | 平台拒绝非浏览器 UA。用真实浏览器验收;检查额外代理是否清空或替换 User-Agent,不要在前端伪造。 | +| HTTP 200 但 `sc:0` | 没有事件被接收。空数组也是 sc:0;检查数组、appId、events 和 params 字符串,再核对平台应用。 | +| `/list` 502 或超时 | 检查采集域名、DNS、网络和证书。不要关闭 TLS 校验。业务不等待采集,没有自动重试。 | +| 配置被关闭 | 检查 appId、product 和多余字段。日志只提示字段,不回显原值。 | +| 平台看不到事件 | 确认应用、时间范围、动作规则和授权;检查 e/sc,不只看 HTTP 状态。 | + +隐私边界:DataFinder adapter 生成随机的匿名浏览器 ID(UUID v4)。 +优先用 crypto.randomUUID;纯 HTTP 内网部署没有它,改用 crypto.getRandomValues 生成同样格式的 ID。 +保存在 localStorage 的 `skillhub.analytics.datafinder.browser-id`,作为 user_unique_id 和 web_id。 +这支持同一浏览器的 UV 统计,不等于无身份数据;由部署方决定是否可接受。 +清除该键会使后续可持久化的采集生成新 ID。存储不可用时,当前页只用内存 ID。 +不绑定 SkillHub 登录账号,不读取账号 ID。页面只发送模板,不发送实际 URL 或参数值。 +仍有 IP、User-Agent 等传输元数据。沿用第 8 节的日志、权限、保留和授权审核要求。 +每次发送最长三秒,无 SDK、批量等待、事件持久化或重试。已发出的请求不能由 stop 撤回。 + +## 3. 一键脚本:新建独立 Umami + +无需克隆仓库。选择包含本功能的已审核 tag 或提交 SHA,下载同版本脚本和配套文件; +下面的 `SKILLHUB_REF` 须替换为实际版本,并在后续所有命令的 shell 中保留。 +`--ref`(或环境变量 `SKILLHUB_REF`)固定文件来源,`--version` 单独选择 SkillHub 镜像标签;两者需匹配,不能把文件版本当作镜像版本。 +`runtime.sh` 从 GitHub raw 获取 `compose.release.yml`、`.env.release.example`,在启用时获取 +`compose.release.analytics.yml`,在部署统计平台时获取 `compose.analytics-umami.yml`。 +这些文件保存在 `--home` 指定目录。使用 `--aliyun` 时镜像 OSS 根目录须同时发布两个新增 Compose 文件; +旧版 OSS 文件集仅可运行不含统计的 `up`,不能假定 GitHub 的 `--ref` 会固定 OSS 镜像文件版本。生产环境先锁定版本、备份数据,核对管理端访问控制。 + +```sh +export SKILLHUB_REF="<包含本功能的已审核tag或提交SHA>" +export SKILLHUB_VERSION="<与该版本匹配的已发布镜像标签>" +curl -fsSL "https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF/scripts/runtime.sh" -o runtime.sh +sh runtime.sh up --home "$HOME/skillhub-runtime" +sh runtime.sh analytics-up --home "$HOME/skillhub-runtime" +``` + +第二步首次创建 `analytics/.env`(权限 600、随机 DB 密码和 APP_SECRET)、外部共享网络, +启动独立 Compose 项目 `skillhub-analytics`:Postgres 使用独立数据卷,Umami 管理端默认只绑定 +`127.0.0.1:8300`。重复执行不重置密钥或数据。远程机器通过 SSH 端口转发管理。 +如需改端口,编辑 `analytics/.env` 中的 `UMAMI_ADMIN_PORT`,再运行 `analytics-up` 使端口生效。 +若同机部署多个实例,首次 `analytics-up` 前在各自 `.env.release` 中指定唯一的 +`SKILLHUB_ANALYTICS_PROJECT`、`SKILLHUB_ANALYTICS_NETWORK`。首次初始化会将这两个标识 +写入 `analytics/.env`;此后以独立统计配置中的标识为准,覆盖外层文件和 shell 中的同名值。 +不要通过修改标识迁移已有数据;它们决定实际 Compose 项目、网络和数据卷归属。已有平台不需要此网络。 + +打开 `http://127.0.0.1:8300`,用初始账号 `admin` / `umami` 登录,**立即修改密码**。 +重新登录,在网站管理中新建网站并复制网站 **UUID**。这一步由管理员完成,脚本不会代建网站。 +密码或 API token 不得填入 Web 配置。拿到 UUID 后运行: + +```sh +sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" --website-id "<网站UUID>" +``` + +`analytics-enable` 会立即以 `consent=granted` 开启采集;仅在部署方确认允许后执行。 +若需要逐用户同意,先按第 2、5 节配置 `denied` 并接入同意组件,不先运行此开启命令。 +脚本把公开 JSON 和内部 origin `http://umami:3000` 写入 `.env.release`,只重建 Web。 +Web 和 Umami 通过外部网络互通;SkillHub server/数据库只留在 SkillHub 默认网络。 +以后 `runtime.sh up` 自动带 Web 网络 overlay,容器重建仍可连通。 +两个项目各自执行 `up`/`analytics-up`、`down`/`analytics-down`、`pull`/`analytics-pull`。 +`analytics-down` 仅 stop,数据卷和密钥保留;重新运行 `analytics-up` 即恢复。 +`down` 不停 Umami;`clean` 停止 SkillHub 并删除其生成的运行文件,存在 `analytics/` 时保留该目录、 +凭证和独立项目标识,不停止统计平台、不删数据卷。执行前分别备份业务配置、 +`analytics/.env` 和统计数据库;`clean` 后重新 `up` 需恢复业务配置,再启用采集。 +旧版本生成的 `analytics/.env` 若缺少标识,会从原 `.env.release` 迁入;若原文件已经丢失, +脚本会在生成默认配置前拒绝运行管理命令,包括重复重试。此时恢复原文件,或核实原项目/网络后补入 +`analytics/.env`,不要猜测默认名称。可通过 `docker compose ls --all` 确认仍存在的项目, +再检查对应 Umami 容器实际加入的网络;不要直接改项目名来尝试连接原数据卷。 +不要用 `down -v`,除非已备份且明确要删除数据。 + +## 3.1 一键脚本:接入已有统计平台 + +已有 Umami 时,不运行 `analytics-up`。登录已有平台创建网站,取得 UUID,并确认 Web 容器 +可达平台 HTTP(S) origin、DNS 和 CA 信任。然后: + +```sh +sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" \ + --website-id "<网站UUID>" --upstream https://stats.example.com +``` + +使用已有 DataFinder 时,把平台应用的数字 app_id 和采集域名(非管理域名)填入: + +```sh +sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" \ + --adapter datafinder --app-id 123456 --upstream https://collector.example.com +``` + +上述 `analytics-enable` 同样立即使用 `consent=granted`,逐用户同意请用第 2、5 节。 +两种已有平台均不创建本地统计服务或共享网络。关闭采集运行 +`sh runtime.sh analytics-disable --home "$HOME/skillhub-runtime"`,仅重建 Web,平台仍运行。 +现有手工 Compose 部署也可按第 2 节直接填写 `.env.release` 并重建 Web。 + +## 4. Helm:独立统计部署单元 + +以下命令从检出的同一已审核版本仓库根目录运行;Chart、values schema 和 Kubernetes 清单应来自该版本。 +SkillHub Chart 不依赖 Umami。自建平台可先部署独立的 `deploy/k8s/analytics-umami/umami.yaml`。 +示例以 `stats` namespace、默认 StorageClass 的 PVC 为基础。先创建独立 namespace 和 Secret: + +```sh +kubectl create namespace stats +# 在权限受限的目录中生成 db-password、app-secret、database-url 三个文件; +# database-url 内容为 postgresql://umami:@umami-postgres.stats.svc.cluster.local:5432/umami +kubectl -n stats create secret generic umami-credentials \ + --from-file=db-password=./db-password \ + --from-file=app-secret=./app-secret \ + --from-file=database-url=./database-url +kubectl apply -f deploy/k8s/analytics-umami/umami.yaml +kubectl -n stats rollout status deployment/umami-postgres +kubectl -n stats rollout status deployment/umami +kubectl -n stats port-forward svc/umami 8300:3000 +``` + +使用 32 字节以上的独立随机密钥;数据库 URL 的口令若含特殊字符需 URL 编码。 +Secret 文件不入库,不把密钥直接放在命令行或 Helm values。管理入口仅 port-forward, +使用 `http://127.0.0.1:8300` 改默认口令、创建网站、取得 UUID。 +PVC 独立于 SkillHub Release;更新 Umami 镜像或密钥须单独滚动统计 Deployment, +Postgres 大版本升级先备份数据库并按其升级流程迁移。删除 Deployment 不删除 PVC; +删除 namespace/PVC 会删除数据,须先做可恢复备份。 + +以下针对已安装的 SkillHub Release。首次安装先按 [Chart README](https://github.com/iflytek/skillhub/blob/main/charts/skillhub/README.md) 完成业务安装, +已有平台可跳过上面的自建平台步骤。保留安装时的生产 values 和固定 Secret,在原 values 中增加以下内容: + +```yaml +web: + analytics: + config: '{"enabled":true,"adapter":"umami","consent":"granted","adapterConfig":{"websiteId":"4398de8e-40df-4483-9eea-ed213643525f"}}' + upstream: http://umami.stats.svc.cluster.local:3000 +``` + +```sh +helm lint --strict charts/skillhub -f values-production.yaml +helm upgrade skillhub ./charts/skillhub -n skillhub --reuse-values -f values-production.yaml +kubectl -n skillhub rollout status deployment/skillhub-web +kubectl -n skillhub logs deployment/skillhub-web --tail=100 +``` + +`helm upgrade` 是实际升级,`--reuse-values` 保留 Release 的已有值;显式传入的文件仍会覆盖旧值, +必须使用该安装的配置,不要传入 `charts/skillhub/tests/test-values.yaml` 等测试夹具。 +保留现有 Secret 引用和固定凭证,不能为开启统计重新生成业务密钥。 +deployment 名称使用 Chart 默认 release 名 `skillhub`;自定义名称时替换命令中的名称。 +值写入 Web 专用的 `-web-analytics` ConfigMap,只改统计配置时仅 Web 滚动更新, +后端 Pod 不会被替换。旧版 Chart 若自动生成应用凭据,Secret 与后端校验值可能不一致; +首次升级到本修复版本会纠正校验值,可能额外滚动一次后端,但不重置凭据。请预留滚动窗口, +之后单独调整统计配置仅滚动 Web。 +Chart 不自动部署 Umami。先在已有平台创建网站,并确认 Pod 出站网络、DNS 和证书可用。 +集群内 origin 使用完整服务名,例如 `http://umami.stats.svc.cluster.local:3000`, +Nginx 的运行时 DNS 不自动补 Pod 的搜索后缀。 + +## 5. 字段、规则和动态授权 + +`SKILLHUB_WEB_ANALYTICS_CONFIG` 是可选 JSON 对象,最大 64 KiB,嵌套最多 32 层。 +空值等同关闭。重复 JSON 键拒绝,避免部署和浏览器解释不同。 + +| 字段 | 含义 | +| --- | --- | +| enabled | 布尔值,默认 false。总开关,start() 不能绕过它。 | +| adapter | 静态注册名:umami 或 datafinder。 | +| consent | denied 或 granted,默认 denied。 | +| rules | 可选规则对象。省略用默认 12 条;显式提供会替换;空对象不采集。 | +| adapterConfig | 适配器公开参数。Umami 只接受 websiteId UUID;DataFinder 接受 appId 和可选 product,见第 2.1 节。 | + +未知配置字段或不合法的平台参数关闭统计。rules 沿用纯 `resolveRules` 的 active/skipped +语义:非法容器没有 active,非法条目跳过,其他合法规则继续工作。日志不输出非法 ID +或原配置。开发者可本地检查 `resolveRules(rules).skipped`,不要发送诊断给平台。 + +`SKILLHUB_WEB_ANALYTICS_UPSTREAM` 只供部署代理使用,不进入浏览器配置。 +只接受 HTTP(S) origin,无凭证、路径、查询、fragment、空白或末尾斜杠。 +不要放入平台管理密钥。浏览器前缀由 Web 自动生成,不能配置任意脚本 URL。 + +默认规则保持冻结: + +| ID | 类型 | +| --- | --- | +| search.query.submit | submit | +| skill.card.open | card | +| suite.card.open | card | +| skill.download.request | click | +| skill.file.download | click | +| skill.command.copy | click | +| skill.prompt.copy | click | +| skill.publish.request | click | +| skill.publish.confirm | click | +| skill.publish.cancel | click | +| skill.delete.confirm | click | +| skill.delete.cancel | click | + +例如只统计搜索和首页导航,可在 JSON 中增加: + +```json +{"rules":{"search.query.submit":"submit","navigation.home.open":"click"}} +``` + +这是 rules 字段示意,不是完整的开启配置。要保留默认值,需显式把默认条目也写进去。 +ID 匹配 `^[a-z]+(?:\.[a-z]+){2,}$`,只用静态动作类别,不拼用户或资源信息。 +`click` 用于普通按钮/链接,排除表单、选择器、文本框和非按钮 INPUT;`submit` 只用于 +FORM;`card` 只用于已有键盘处理的 DIV role=link。选择、文件、拖放、裁剪不支持这些规则, +不要猜成 click。`skill.detail.render` 不是自动 pageview。 + +当前页面挂载后提供接口,可接入已有同意组件;浏览器 Console 也可用于验收: + +```js +window.skillhubAnalytics.stop() +// 仅在重新获准后: +await window.skillhubAnalytics.start() +``` + +start() 是当前页的显式授权,支持初始 consent=denied。重复 start 不重复安装。 +stop() 幂等;等待中的模块/adapter 不能迟到后重启采集。stop 返回后不新派发请求, +但已经开始的 fetch 无法撤回。规则变更使用 stop 再初始化。 +修改 `.env.release` 后必须重新创建 Web(`up -d --force-recreate web`);`docker compose restart` +只重启原容器,不会重新注入环境变量。旧标签页不会自动收到新配置,需要用户刷新或关闭。 + +## 6. 根路径、子路径与验收 + +根路径无需改路由。子路径只需保持现有三项一致: + +```dotenv +SKILLHUB_WEB_BASE_PATH=/skillhub/ +SKILLHUB_WEB_API_BASE_URL=/skillhub +SKILLHUB_PUBLIC_BASE_URL=https://skills.example.com/skillhub +``` + +浏览器采集路径自动变为 `/skillhub/_skillhub-analytics/`,正常入口只重写一次。 +`/_skillhub-analytics/` 是保留前缀,不能作为 Web base path。 +保留当前 CSP;需要允许已批准的同源外部脚本和连接。nonce-only/strict-dynamic 部署仍需 +按本身的安全策略授权,无法授权就不开启。不要增加 unsafe-inline、unsafe-eval 或通配符。 + +按顺序验收。下面用本机默认 Web 端口,实际环境替换 origin 和子路径: + +```sh +curl -i http://localhost/runtime-config.js +curl -i http://localhost/_skillhub-analytics/script.js +curl -i http://localhost/_skillhub-analytics/api/websites +``` + +1. runtime-config 为 no-store;原 API/auth 键仍在;不得包含 upstream。 +2. 开启且 granted 时 script.js 应是 JS 200;未注册路径应为 404。 +3. 新开 SkillHub 页面,Network 保留日志。先看到应用挂载,再按需加载 controller、adapter + 和 script.js。不会自动发送 pageview。 +4. 搜索框输入本地测试文字,点击搜索,再按 Enter 搜索。每次应各有一条 api/send,页面正常跳转。 +5. 检查真正出站 JSON,只允许 `{type:'event',payload:{website,name,data:{event}}}`。 + 搜索 name 为 search.query.submit,event 为 submit。不能有查询文字、URL、标题、身份或额外 data。 +6. 在 Umami 网站事件页核对事件和数量。HTTP 200 不能单独证明入库。 +7. 调用 stop,再搜索。业务继续,不能新增 send。重新 start 后允许继续。 +8. 关闭配置或 denied 后重建 Web,打开新页面:不得加载 controller/adapter、script.js 或 send。 + denied 页面只有显式 start 才会加载。 +9. 在隔离测试环境停止 Umami,或用不可解析的内部域名启动 Web。Web 仍应就绪,搜索、 + 登录、卡片、下载继续遵守原业务行为。不要停止共享或生产平台来测试。 +10. 根/子路径都测试正常上传(大于 1 MiB 且业务有效)、登录与下载。统计不代表业务成功。 + +## 7. 排查、升级和关闭 + +| 现象 | 检查 | +| --- | --- | +| 没有模块/事件 | 总开关、授权、容器是否重建、是否新页面;默认关闭是正常情况。 | +| 配置报错 | 看 `[analytics]` 字段诊断;JSON 用双引号,enabled 是布尔值,UUID 正确;不要发出原值。 | +| script.js 502 | Web 到平台的 DNS、网络、端口或证书;origin 不带路径;业务应仍可用。 | +| SDK 失败后无事件 | 加载失败/超时后不重试;恢复平台后新开页面,或 stop 再 start。 | +| 模块/资源返回 HTML | 检查部署版本、base path 和入口路由,不能改成任意远程脚本。 | +| CSP 拦截 | 按原策略授权,不删除 CSP 或放宽通配符。 | +| send 返回 400 Website not found | websiteId 是合法 UUID,但 Umami 里没有这个网站。事件不会入库;配置校验只检查格式,通过不代表采集成功,需按第 6 节到平台核对入库。 | +| 有点击但没有统计 | 规则未配置、宿主不支持、超过 Umami 名称 50 字符,或 best-effort 丢失。 | +| 上传 413 | 检查整条外层代理链,正常 Web 仍是 100M,统计未降低它。 | + +runtime 升级时先备份 `.env.release`、`analytics/.env` 和数据库,下载目标版本的脚本并更新 +`SKILLHUB_REF` 和匹配的 `SKILLHUB_VERSION`;`pull` 只拉业务镜像,`analytics-pull` 只拉统计镜像,拉取不替换运行容器。 +分别运行 `up` 与 `analytics-up` 才应用变化,沿用原凭证和项目标识。不要以删除配置重建作为升级方法。 + +升级先比较[manifest](https://github.com/iflytek/skillhub/blob/main/docs/frontend-analytics-manifest.json),已有 ID 含义冻结,新 ID 不自动加入默认规则。 +消失的配置 ID 不再触发。SDK 固定 Umami 3.4.1;升级需重测真实 payload、stop、并发和 +根/子路径业务。回滚 Web 镜像要同时使用该版本支持的配置;先关闭统计最稳妥。 + +持久关闭:runtime 用户运行 `analytics-disable`(第 3.1 节);手工 Compose 用户将配置留空 +或 enabled 改 false,再按第 2 节重建 Web;Helm 用户将 `web.analytics.config` 留空或 +enabled 改 false,使用原生产 values 按第 4 节升级。新页面应零模块/SDK/请求。 +老标签页不自动更新;需让用户关闭/刷新,或在现有同意组件调用 stop。 +紧急全局阻断可在平台或独立采集路由禁用接收,再处理旧页;不能撤回已发出的请求。 + +自托管只停止统计服务,不要对整个业务栈执行 down: + +```sh +sh runtime.sh analytics-down --home "$HOME/skillhub-runtime" +``` + +Helm 自建平台可单独缩容 `kubectl -n stats scale deployment/umami --replicas=0`, +恢复时将副本数改回 1;SkillHub Release 保持运行,PVC 保留。 +Compose 删除统计数据需另行备份、确认后只处理对应项目的 `analytics_postgres_data` 卷, +不要使用整个项目的 `down -v`。 + +## 8. 隐私和自定义开发边界 + +helper 只保留静态 ID 和原生 event,不读内容、输入值、URL、Cookie、存储或身份。 +DataFinder 的匿名浏览器 ID 和页面模板边界见第 2.1 节。以下 SDK 说明仅适用于 Umami。 +SDK 在清洗前仍会读取页面标题、URL、referrer 和自己的 localStorage 开关。 +少字段不等于匿名:IP、User-Agent、Origin 仍到平台;Umami 还有 website、实际 hostname、 +会话 cache 请求头。Nginx 在统计上游前移除 Cookie、Authorization、Referer,并隐藏 Set-Cookie。 +浏览器到 Web 本身仍可能带这些 HTTP 元数据,需按原 Web 日志和权限策略管理。 +Umami 仍可能由传输元数据推导浏览器、系统、设备、地域和会话。数据库缺失 URL 的回退 +是空 path/query 和 localhost,不是用户当前路径。访问日志、保留和权限要单独审核。 + +尽力发送,无重试或持久队列。SDK 加载期最多内存暂存 20 条,默认等三秒;超额、失败、 +超时或 stop 会丢弃。就绪后的请求彼此独立。不要同时加载第二个 Umami tracker。 +stop 的派发保护依赖固定版本在 fetch 前同步序列化的顺序,升级必须重新验证。 + +自定义 adapter 属于开发流程,不是部署流程。除前端 adapter 外,还要改部署侧: +在 `web/src/features/analytics/adapters/` 增加参数校验与 send/stop factory 并注册到静态 registry; +在 Web 启动脚本增加该平台的配置校验和精确代理端点;补充前端与 `web-analytics-test.py` 测试。 +factory 检查取消信号后才创建 SDK。通用 controller 不认识厂商字段,业务组件只提供被动标签。 +复用唯一的 `skillhub-intents.mjs`,不复制 helper,也不开放远程 JS URL。 +新增标签时运行 `node web/scripts/analytics-manifest.mjs --write` 并审核语义和原生交互。 +测试命令为 `pnpm -C web test src/features/analytics` 和 `python3 scripts/tests/web-analytics-test.py`。 + + +[analytics-reference]: frontend-analytics.md diff --git a/docs/frontend-analytics-manifest.json b/docs/frontend-analytics-manifest.json new file mode 100644 index 000000000..6e280c546 --- /dev/null +++ b/docs/frontend-analytics-manifest.json @@ -0,0 +1,951 @@ +{ + "schema": 1, + "description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.", + "counts": {"routes":54,"uniqueIds":804,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":29,"forwardingSites":36}, + "routes": [ + {"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false}, + {"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false}, + {"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false}, + {"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":281},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false}, + {"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":290},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false}, + {"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false}, + {"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false}, + {"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":318},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false}, + {"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":329},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false}, + {"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false}, + {"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false}, + {"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false}, + {"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false}, + {"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false}, + {"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false}, + {"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":463},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":481},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":491},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":503},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":510},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":517},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":524},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":531},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":541},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":548},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":555},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":588},"implementation":null,"guarded":true,"roleProtected":false}, + {"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":597},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":607},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":629},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":636},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":650},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":657},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":667},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":677},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":684},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false}, + {"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":691},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false}, + {"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":706},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false}, + {"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":712},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":719},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":733},"implementation":null,"guarded":true,"roleProtected":false}, + {"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":742},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true}, + {"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":749},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true}, + {"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":756},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true}, + {"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":763},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true}, + {"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true} + ], + "definitions": [ + {"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]}, + {"file":"web/src/app/layout.tsx","line":90,"ids":["skill.publish.open"]}, + {"file":"web/src/app/layout.tsx","line":91,"ids":["navigation.skills.open"]}, + {"file":"web/src/app/layout.tsx","line":92,"ids":["navigation.github.open"]}, + {"file":"web/src/app/layout.tsx","line":93,"ids":["navigation.suites.open"]}, + {"file":"web/src/app/layout.tsx","line":94,"ids":["navigation.dashboard.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":26,"ids":["namespace.skills.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":27,"ids":["namespace.members.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":28,"ids":["namespace.reviews.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":29,"ids":["namespace.settings.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":41,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":42,"ids":["account.security.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":43,"ids":["notification.settings.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":49,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":50,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":51,"ids":["namespace.list.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":61,"ids":["governance.center.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":62,"ids":["report.queue.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":89,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":90,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.security.open"]} + ], + "sites": [ + {"file":"web/src/app/layout.tsx","line":126,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/app/layout.tsx","line":139,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/app/layout.tsx","line":159,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.menu.toggle"]}, + {"file":"web/src/app/layout.tsx","line":175,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/app/layout.tsx","line":193,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/app/layout.tsx","line":251,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/app/layout.tsx","line":252,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/app/layout.tsx","line":253,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]}, + {"file":"web/src/app/layout.tsx","line":260,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]}, + {"file":"web/src/app/layout.tsx","line":261,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]}, + {"file":"web/src/app/layout.tsx","line":262,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]}, + {"file":"web/src/app/layout.tsx","line":269,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.license.open"]}, + {"file":"web/src/app/layout.tsx","line":270,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.contributing.open"]}, + {"file":"web/src/app/layout.tsx","line":271,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.changelog.open"]}, + {"file":"web/src/app/layout.tsx","line":272,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.conduct.open"]}, + {"file":"web/src/app/layout.tsx","line":279,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.community.open"]}, + {"file":"web/src/app/layout.tsx","line":280,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]}, + {"file":"web/src/app/layout.tsx","line":281,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]}, + {"file":"web/src/features/auth/auth-shell.tsx","line":19,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/features/auth/auth-shell.tsx","line":28,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/features/auth/login-button.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.oauth.request"]}, + {"file":"web/src/features/auth/session-bootstrap-entry.tsx","line":54,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.sso.request"]}, + {"file":"web/src/features/auth/session-bootstrap-entry.tsx","line":94,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.sso.request"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.admission.edit"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":61,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.admission.cancel"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":63,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.admission.save"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.admission.cancel"]}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.create"]}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":72,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.credential.create.cancel","github.credential.replace.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":74,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.credential.create.submit","github.credential.replace.submit"],"conditional":true}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":82,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.create.cancel","github.credential.replace.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.replace"]}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":117,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.disable","github.credential.enable"],"conditional":true}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":65,"host":"input","prop":"data-analytics-id","event":"change","ids":["github.discovery.toggle"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":72,"host":"input","prop":"data-analytics-id","event":"change","ids":["github.discovery.budget.accelerated","github.discovery.budget.balanced","github.discovery.budget.economy"],"conditional":true}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":82,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.exclusion.add"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":83,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.exclusion.kind"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":96,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.exclusion.remove"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":103,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.reload"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":105,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.cancel"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":106,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.save"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.owner.create"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":172,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.create"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":176,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.owner.cancel","github.rule.create.cancel","github.rule.edit.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":191,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.owner.add"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":206,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.rule.create.submit","github.rule.edit.submit"],"conditional":true}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":219,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.kind.keyword"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":219,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.kind.topic"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":224,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.priority.core"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":224,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.priority.extension"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":232,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.selector.remove"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.selector.add"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":239,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.preview"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":240,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.create.cancel","github.rule.edit.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":295,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.edit"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":296,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.owner.disable","github.owner.enable","github.rule.disable","github.rule.enable"],"conditional":true}, + {"file":"web/src/features/github-sync/github-retry-rejected-skill-button.tsx","line":30,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rejected.retry"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":56,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.schedule.edit"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":63,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.schedule.cancel"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":65,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.schedule.save"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":74,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.schedule.cancel"]}, + {"file":"web/src/features/github-sync/github-source-ignore-editor.tsx","line":60,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.ignore.save"]}, + {"file":"web/src/features/github-sync/github-source-rescan-button.tsx","line":30,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.source.rescan"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":57,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.collection.close"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":75,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.edit"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":78,"host":"button","prop":"data-analytics-id","event":"click","ids":["github.progress.toggle"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":88,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.repository.add"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":98,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.open"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":98,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.toggle"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":102,"host":"button","prop":"data-analytics-id","event":"click","ids":["github.acquisition.toggle"]}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":49,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.binding.conflict.open","github.binding.pathmove.open"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":53,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.binding.conflict.cancel","github.binding.pathmove.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":60,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.binding.conflict.confirm","github.binding.pathmove.confirm"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":68,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.binding.conflict.cancel","github.binding.pathmove.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":50,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.settings.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":51,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.settings.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.collection.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":59,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.queue.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":67,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.runs.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.run.open"]}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":40,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.platform.pause.request","github.platform.resume.request"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":43,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.platform.pause.cancel","github.platform.resume.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.platform.pause.cancel","github.platform.resume.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.platform.pause.confirm","github.platform.resume.confirm"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":26,"host":"select","prop":"data-analytics-id","event":"change","forward":"analyticsId"}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":54,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.repository.close"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":60,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.repository.external"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.run.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":74,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.provenance.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":79,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":80,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.path.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":82,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.source.actions"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":83,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.queue.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":117,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.repository.search"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":119,"host":"SelectFilter","prop":"analyticsId","event":"change","ids":["github.repository.origin.filter"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":120,"host":"SelectFilter","prop":"analyticsId","event":"change","ids":["github.repository.state.filter"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":121,"host":"SelectFilter","prop":"analyticsId","event":"change","ids":["github.repository.source.status.filter"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":124,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.refresh"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":137,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.repository.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-run-detail.tsx","line":25,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.runs.open"]}, + {"file":"web/src/features/github-sync/github-sync-run-detail.tsx","line":68,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.path.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-run-detail.tsx","line":84,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.task.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-run-list.tsx","line":60,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.run.open"]}, + {"file":"web/src/features/github-sync/github-sync-run-list.tsx","line":66,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.run.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-runs-section.tsx","line":16,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.run.kind"]}, + {"file":"web/src/features/github-sync/github-sync-runs-section.tsx","line":19,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.run.status"]}, + {"file":"web/src/features/github-sync/github-sync-settings-drawer.tsx","line":14,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.settings.close"]}, + {"file":"web/src/features/github-sync/github-sync-source-detail.tsx","line":42,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.source.paginate"]}, + {"file":"web/src/features/governance/governance-inbox.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.promotion.open","governance.report.open","governance.review.open","governance.skill.open"],"conditional":true}, + {"file":"web/src/features/governance/governance-notifications.tsx","line":48,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.read.request"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":110,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.member.cancel"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":122,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.member.search"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":139,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.search"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.select"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":196,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.member.add"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":225,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["namespace.member.role.interact"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":230,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.member.role.admin","namespace.member.role.member"],"conditional":true}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":244,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.cancel"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":247,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.add"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":168,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.import.cancel","namespace.import.close"],"conditional":true}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":178,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.template.download"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":181,"host":"div","prop":"data-analytics-id","event":"click+keydown-enter-space","ids":["namespace.import.choose"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":197,"host":"input","prop":"data-analytics-id","event":"change","ids":["namespace.import.select"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":280,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.cancel"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":286,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.reset"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":289,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.confirm"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":301,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.close"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":145,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.create.cancel"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":156,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.create.request"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":186,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.create.request"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":238,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.cancel"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":241,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.request"]}, + {"file":"web/src/features/namespace/edit-namespace-dialog.tsx","line":70,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.settings.cancel"]}, + {"file":"web/src/features/namespace/edit-namespace-dialog.tsx","line":107,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.settings.cancel"]}, + {"file":"web/src/features/namespace/edit-namespace-dialog.tsx","line":110,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.settings.save"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":118,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.copy"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":126,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.create"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":129,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.create"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":140,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.retry"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":147,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.replace"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":148,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.revoke"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":153,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.invitation.replace.confirm","namespace.invitation.revoke.confirm"],"conditional":true}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":153,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.invitation.replace.cancel","namespace.invitation.revoke.cancel"],"conditional":true}, + {"file":"web/src/features/namespace/invitation-history.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.refresh"]}, + {"file":"web/src/features/namespace/invitation-history.tsx","line":89,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.invitation.paginate"]}, + {"file":"web/src/features/namespace/namespace-header.tsx","line":68,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.settings.edit"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":11,"host":"Link","prop":"data-analytics-id","event":"click","ids":["namespace.list.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":35,"host":"Link","prop":"data-analytics-id","event":"click","forward":"tab.analyticsId"}, + {"file":"web/src/features/namespace/namespace-skills.tsx","line":63,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/features/namespace/namespace-skills.tsx","line":72,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.skill.paginate"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":77,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.ownership.cancel"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":94,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["namespace.ownership.interact"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":99,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.ownership.select"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.ownership.cancel"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":135,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.ownership.confirm"]}, + {"file":"web/src/features/notification/notification-bell.tsx","line":41,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.menu.toggle"]}, + {"file":"web/src/features/notification/notification-dropdown.tsx","line":48,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.all.read"]}, + {"file":"web/src/features/notification/notification-dropdown.tsx","line":75,"host":"Link","prop":"data-analytics-id","event":"click","ids":["notification.fallback.open","notification.promotion.open","notification.report.open","notification.review.detail.open","notification.review.list.open","notification.target.provided.open"],"conditional":true}, + {"file":"web/src/features/notification/notification-dropdown.tsx","line":105,"host":"Link","prop":"data-analytics-id","event":"click","ids":["notification.list.open"]}, + {"file":"web/src/features/notification/notification-preference-form.tsx","line":75,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.preference.promotion.toggle","notification.preference.publish.toggle","notification.preference.report.toggle","notification.preference.review.toggle"],"conditional":true}, + {"file":"web/src/features/publish/upload-zone.tsx","line":71,"host":"div","prop":"data-analytics-id","event":"click+keydown+drop","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/publish/upload-zone.tsx","line":81,"host":"input","prop":"data-analytics-id","event":"change","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/publish/upload-zone.tsx","line":113,"host":"input","prop":"data-analytics-id","event":"change","forward":"folderAnalyticsId"}, + {"file":"web/src/features/publish/upload-zone.tsx","line":123,"host":"button","prop":"data-analytics-id","event":"click","forward":"pickerAnalyticsId"}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":49,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.sync.status"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":62,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.sync.sort.interact"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":64,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.sync.sort"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":65,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.sync.sort"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":93,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":103,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.sync.paginate"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":79,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.file.previous"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":81,"host":"select","prop":"data-analytics-id","event":"change","ids":["review.file.select"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":86,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.file.next"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":95,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.diff.changes"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":96,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.diff.full"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":108,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":146,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.commit.external"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":60,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.package.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":69,"host":"FileTree","prop":"analyticsId","event":"click","ids":["review.file.open"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":81,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":94,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["review.file.close"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["review.file.copy"]}, + {"file":"web/src/features/search/search-bar.tsx","line":55,"host":"form","prop":"data-analytics-id","event":"submit","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/search/search-bar.tsx","line":67,"host":"button","prop":"data-analytics-id","event":"click","ids":["search.query.clear"],"conditional":true}, + {"file":"web/src/features/security-audit/security-audit-section.tsx","line":114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.findings.toggle"]}, + {"file":"web/src/features/security-audit/security-audit-summary.tsx","line":56,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.scan.retry"]}, + {"file":"web/src/features/security-audit/security-audit-summary.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.scan.open"]}, + {"file":"web/src/features/security-audit/security-audit-summary.tsx","line":77,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["security.scan.close"]}, + {"file":"web/src/features/skill/avatar-review-settings.tsx","line":28,"host":"input","prop":"data-analytics-id","event":"change","ids":["admin.avatar.machine.toggle","admin.avatar.manual.toggle"],"conditional":true}, + {"file":"web/src/features/skill/avatar-review-settings.tsx","line":30,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.avatar.settings"]}, + {"file":"web/src/features/skill/compliance-snapshot-panel.tsx","line":51,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.compliance.toggle"]}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":90,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","forward":"closeAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":101,"host":"Button","prop":"data-analytics-id","event":"click","forward":"copyAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":115,"host":"Button","prop":"data-analytics-id","event":"click","forward":"downloadAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":125,"host":"Button","prop":"data-analytics-id","event":"click","forward":"closeAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":158,"host":"Button","prop":"data-analytics-id","event":"click","forward":"downloadAnalyticsId"}, + {"file":"web/src/features/skill/file-tree-node.tsx","line":59,"host":"div","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/file-tree-node.tsx","line":81,"host":"div","prop":"data-analytics-id","event":"click","ids":["skill.directory.toggle"],"conditional":true}, + {"file":"web/src/features/skill/file-tree-node.tsx","line":102,"host":"FileTreeNodeComponent","prop":"analyticsId","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/file-tree.tsx","line":41,"host":"FileTreeNodeComponent","prop":"analyticsId","event":"click","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/skill/github-skill-source.tsx","line":19,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.repository.external"]}, + {"file":"web/src/features/skill/github-skill-source.tsx","line":21,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.commit.external"]}, + {"file":"web/src/features/skill/github-skill-source.tsx","line":37,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.file.external"]}, + {"file":"web/src/features/skill/install-command.tsx","line":83,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":116,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","forward":"skillhubAnalyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":119,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","forward":"clawhubAnalyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":125,"host":"CommandBlock","prop":"analyticsId","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":129,"host":"CommandBlock","prop":"analyticsId","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/install-for-agent-button.tsx","line":56,"host":"button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/markdown-renderer.tsx","line":172,"host":"a","prop":"data-analytics-id","event":"click","ids":["skill.markdown.link"]}, + {"file":"web/src/features/skill/share-button.tsx","line":44,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.share.request"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":124,"host":"div","prop":"data-analytics-id","event":"drop","ids":["skill.avatar.file.drop"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":127,"host":"input","prop":"data-analytics-id","event":"change","ids":["skill.avatar.file.select"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":130,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.change","skill.avatar.choose"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.remove"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.reset"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":139,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.avatar.cancel"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":145,"host":"div","prop":"data-analytics-id","event":"pointerup+keydown-intent","ids":["skill.avatar.crop.adjust"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":168,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.cancel"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":169,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.retry","skill.avatar.upload"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-manager.tsx","line":28,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.refresh"]}, + {"file":"web/src/features/skill/skill-avatar-manager.tsx","line":49,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.appeal"]}, + {"file":"web/src/features/skill/skill-avatar-manager.tsx","line":50,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.retry"]}, + {"file":"web/src/features/skill/skill-avatar-takedown.tsx","line":21,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.takedown"]}, + {"file":"web/src/features/skill/skill-card.tsx","line":37,"host":"Card","prop":"data-analytics-id","event":"click+keydown-enter-space","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/skill/skill-label-panel.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.label.privileged.detach","skill.label.recommended.detach","suite.label.privileged.detach","suite.label.recommended.detach"],"conditional":true}, + {"file":"web/src/features/skill/skill-label-panel.tsx","line":212,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.label.privileged.attach","skill.label.recommended.attach","suite.label.privileged.attach","suite.label.recommended.attach"],"conditional":true}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.visibility.open"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":51,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.visibility.cancel"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":63,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.visibility.interact"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":64,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.visibility.namespace","skill.visibility.private","skill.visibility.public"],"conditional":true}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.visibility.cancel"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.visibility.save"]}, + {"file":"web/src/features/social/rating-input.tsx","line":39,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.rating.five.request","skill.rating.four.request","skill.rating.one.request","skill.rating.three.request","skill.rating.two.request"],"conditional":true}, + {"file":"web/src/features/social/skill-reviews.tsx","line":60,"host":"input","prop":"data-analytics-id","event":"change","ids":["skill.comment.rating.five","skill.comment.rating.four","skill.comment.rating.one","skill.comment.rating.three","skill.comment.rating.two"],"conditional":true}, + {"file":"web/src/features/social/skill-reviews.tsx","line":143,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.delete"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":147,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.cancel"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":150,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.save"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":212,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.hide","skill.comment.restore"],"conditional":true}, + {"file":"web/src/features/social/skill-reviews.tsx","line":269,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.edit"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":273,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.delete"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":318,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.previous"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":322,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.next"]}, + {"file":"web/src/features/social/star-button.tsx","line":39,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.star.add","skill.star.remove"],"conditional":true}, + {"file":"web/src/features/social/subscribe-button.tsx","line":34,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.subscription.add","skill.subscription.remove"],"conditional":true}, + {"file":"web/src/features/suite/resource-card.tsx","line":12,"host":"Card","prop":"data-analytics-id","event":"click+keydown-enter-space","forward":"analyticsId"}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":241,"host":"UploadZone","prop":"analyticsId","event":"click+keydown+change+drop","ids":["suite.bundle.select"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":241,"host":"UploadZone","prop":"folderAnalyticsId","event":"change","ids":["suite.bundle.folder.select"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":241,"host":"UploadZone","prop":"pickerAnalyticsId","event":"click","ids":["suite.bundle.folder.open"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":317,"host":"input","prop":"data-analytics-id","event":"change","ids":["suite.bundle.warning"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":358,"host":"input","prop":"data-analytics-id","event":"change","ids":["suite.bundle.removals"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":368,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.bundle.reset"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":371,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.bundle.confirm"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":112,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.retry"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.cancel"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.restart"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.open"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":137,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.restart"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.refresh"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":283,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":313,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["suite.operation.cancel.confirm"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":313,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["suite.operation.cancel.cancel"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.reopen"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.create"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.yank"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.hide.request","suite.restore.request"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":139,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.archive.request","suite.unarchive.request"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":149,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.delete.request"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":160,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["suite.archive.confirm","suite.delete.confirm","suite.hide.confirm","suite.restore.confirm","suite.unarchive.confirm"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":160,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["suite.archive.cancel","suite.delete.cancel","suite.hide.cancel","suite.restore.cancel","suite.unarchive.cancel"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":173,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["suite.version.yank.cancel"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":184,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.yank.cancel"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":185,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.yank.confirm"]}, + {"file":"web/src/features/suite/suite-member-table.tsx","line":27,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]}, + {"file":"web/src/features/suite/suite-member-table.tsx","line":71,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/features/suite/suite-member-table.tsx","line":97,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.version.open"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":95,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.version.expand"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":118,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.select"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":191,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":197,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.publish.direct.request","suite.review.submit.request"],"conditional":true}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":221,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/features/suite/suite-workspace-header.tsx","line":26,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.workspace.back"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":144,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["account.token.create.cancel","account.token.created.close"],"conditional":true}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":156,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["account.token.create"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":193,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["account.token.create.expiry.interact"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":197,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.never"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":198,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.week"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":199,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.month"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":200,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.quarter"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":201,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.custom"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":229,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.create.cancel"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":232,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.create"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":265,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.copy"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":268,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.created.close"]}, + {"file":"web/src/features/token/token-list.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.open"]}, + {"file":"web/src/features/token/token-list.tsx","line":196,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.open"]}, + {"file":"web/src/features/token/token-list.tsx","line":233,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.expiration.edit"]}, + {"file":"web/src/features/token/token-list.tsx","line":242,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.revoke"]}, + {"file":"web/src/features/token/token-list.tsx","line":260,"host":"Pagination","prop":"analyticsId","event":"click","ids":["account.token.paginate"]}, + {"file":"web/src/features/token/token-list.tsx","line":263,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["account.token.revoke.confirm"]}, + {"file":"web/src/features/token/token-list.tsx","line":263,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["account.token.revoke.cancel"]}, + {"file":"web/src/features/token/token-list.tsx","line":274,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["account.token.expiration.cancel"]}, + {"file":"web/src/features/token/token-list.tsx","line":291,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["account.token.edit.expiry.interact"]}, + {"file":"web/src/features/token/token-list.tsx","line":295,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.never"]}, + {"file":"web/src/features/token/token-list.tsx","line":296,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.week"]}, + {"file":"web/src/features/token/token-list.tsx","line":297,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.month"]}, + {"file":"web/src/features/token/token-list.tsx","line":298,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.quarter"]}, + {"file":"web/src/features/token/token-list.tsx","line":299,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.custom"]}, + {"file":"web/src/features/token/token-list.tsx","line":320,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.expiration.cancel"]}, + {"file":"web/src/features/token/token-list.tsx","line":323,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.expiration.save"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":107,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.rebuild.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":110,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.clear"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":123,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.audit.action.interact"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":128,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.audit.action.all","admin.audit.action.archive","admin.audit.action.cli.publish","admin.audit.action.compat.publish","admin.audit.action.hide","admin.audit.action.promotion.approve","admin.audit.action.promotion.reject","admin.audit.action.promotion.submit","admin.audit.action.report","admin.audit.action.report.dismiss","admin.audit.action.report.resolve","admin.audit.action.review.approve","admin.audit.action.review.reject","admin.audit.action.review.submit","admin.audit.action.search.rebuild","admin.audit.action.unarchive","admin.audit.action.unhide","admin.audit.action.version.yank"],"conditional":true}, + {"file":"web/src/pages/admin/audit-log.tsx","line":137,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.user.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":145,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.request.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":153,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.ip.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":161,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.type.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":169,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.resource.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":177,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.start.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":185,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.end.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":242,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.previous"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":250,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.next"]}, + {"file":"web/src/pages/admin/labels.tsx","line":249,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.create"]}, + {"file":"web/src/pages/admin/labels.tsx","line":317,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.up"]}, + {"file":"web/src/pages/admin/labels.tsx","line":320,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.down"]}, + {"file":"web/src/pages/admin/labels.tsx","line":329,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.edit"]}, + {"file":"web/src/pages/admin/labels.tsx","line":332,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.delete"]}, + {"file":"web/src/pages/admin/labels.tsx","line":353,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.label.create.cancel","admin.label.edit.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/labels.tsx","line":377,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.label.type.interact"]}, + {"file":"web/src/pages/admin/labels.tsx","line":381,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.type.recommended"]}, + {"file":"web/src/pages/admin/labels.tsx","line":382,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.type.privileged"]}, + {"file":"web/src/pages/admin/labels.tsx","line":392,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.label.visibility.interact"]}, + {"file":"web/src/pages/admin/labels.tsx","line":396,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.visibility.visible"]}, + {"file":"web/src/pages/admin/labels.tsx","line":397,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.visibility.hidden"]}, + {"file":"web/src/pages/admin/labels.tsx","line":418,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.translation.add"]}, + {"file":"web/src/pages/admin/labels.tsx","line":436,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.translation.remove"]}, + {"file":"web/src/pages/admin/labels.tsx","line":445,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.create.cancel","admin.label.edit.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/labels.tsx","line":448,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.create.submit","admin.label.edit.submit"],"conditional":true}, + {"file":"web/src/pages/admin/labels.tsx","line":460,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.label.delete.cancel"]}, + {"file":"web/src/pages/admin/labels.tsx","line":468,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.delete.cancel"]}, + {"file":"web/src/pages/admin/labels.tsx","line":471,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.delete.confirm"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":337,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["admin.namespace.search"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":344,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.search"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":345,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.clear"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":356,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.status.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":358,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":359,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":360,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":361,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":371,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.type.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":373,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.type.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":374,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.type.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":375,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.type.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":407,"host":"TableRow","prop":"data-analytics-id","event":"click","ids":["admin.namespace.open"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":431,"host":"Pagination","prop":"analyticsId","event":"click","ids":["admin.namespace.paginate"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":456,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.transfer"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":464,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.open"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":484,"host":"Pagination","prop":"analyticsId","event":"click","ids":["admin.namespace.member.paginate"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":493,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.freeze"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":501,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.unfreeze"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":509,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.archive"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":517,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.restore"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":533,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.namespace.member.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":541,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.namespace.member.search"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":549,"host":"button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.select"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":573,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.member.role.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":575,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.member"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":576,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.admin"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":582,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":583,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.add"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":589,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.namespace.transfer.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":604,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.ownership.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":607,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.ownership.select"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":624,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.transfer.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":625,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.transfer.confirm"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":637,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.namespace.member.remove.confirm"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":637,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.namespace.member.remove.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":658,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.namespace.archive.cancel","admin.namespace.freeze.cancel","admin.namespace.restore.cancel","admin.namespace.unfreeze.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/namespaces.tsx","line":676,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.archive.cancel","admin.namespace.freeze.cancel","admin.namespace.restore.cancel","admin.namespace.unfreeze.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/namespaces.tsx","line":677,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.archive.confirm","admin.namespace.freeze.confirm","admin.namespace.restore.confirm","admin.namespace.unfreeze.confirm"],"conditional":true}, + {"file":"web/src/pages/admin/namespaces.tsx","line":799,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.member.role.draft.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":801,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.draft.member"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":802,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.draft.admin"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":805,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.role"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":825,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.remove"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":131,"host":"input","prop":"data-analytics-id","event":"change","ids":["admin.auth.password.toggle","admin.auth.registration.toggle"],"conditional":true}, + {"file":"web/src/pages/admin/system-config.tsx","line":145,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.discard"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":149,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.save"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":158,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.auth.confirm"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":158,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.auth.cancel"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":174,"host":"form","prop":"data-analytics-id","event":"submit","ids":["admin.role.rule.create"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":189,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.select"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":218,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.edit"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":226,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.discard"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":227,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.save"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":230,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.disable"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":239,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.role.rule.disable.confirm"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":239,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.role.rule.disable.cancel"]}, + {"file":"web/src/pages/admin/users.tsx","line":163,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["admin.user.search"]}, + {"file":"web/src/pages/admin/users.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.search"]}, + {"file":"web/src/pages/admin/users.tsx","line":174,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.clear"]}, + {"file":"web/src/pages/admin/users.tsx","line":186,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.user.status.interact"]}, + {"file":"web/src/pages/admin/users.tsx","line":190,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":191,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":192,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":193,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":232,"host":"CopyButton","prop":"analyticsId","event":"click","ids":["admin.user.identifier.copy"]}, + {"file":"web/src/pages/admin/users.tsx","line":253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.role.edit"]}, + {"file":"web/src/pages/admin/users.tsx","line":261,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.approve"]}, + {"file":"web/src/pages/admin/users.tsx","line":270,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.disable"]}, + {"file":"web/src/pages/admin/users.tsx","line":278,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.enable"]}, + {"file":"web/src/pages/admin/users.tsx","line":286,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.password.reset"]}, + {"file":"web/src/pages/admin/users.tsx","line":306,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.previous"]}, + {"file":"web/src/pages/admin/users.tsx","line":314,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.next"]}, + {"file":"web/src/pages/admin/users.tsx","line":328,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.user.role.cancel"]}, + {"file":"web/src/pages/admin/users.tsx","line":339,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.user.role.interact"]}, + {"file":"web/src/pages/admin/users.tsx","line":344,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.role.select"]}, + {"file":"web/src/pages/admin/users.tsx","line":353,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.role.cancel"]}, + {"file":"web/src/pages/admin/users.tsx","line":356,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.role.confirm"]}, + {"file":"web/src/pages/admin/users.tsx","line":372,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.user.disable.cancel","admin.user.enable.cancel","admin.user.password.reset.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/users.tsx","line":384,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.disable.cancel","admin.user.enable.cancel","admin.user.password.reset.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/users.tsx","line":387,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.disable.confirm","admin.user.enable.confirm","admin.user.password.reset.confirm"],"conditional":true}, + {"file":"web/src/pages/cli-auth.tsx","line":218,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.cli.login"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":46,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.collection.open"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":47,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.settings.open"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":52,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.overview.tab"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":53,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.repositories.tab"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":54,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.runs.tab"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":69,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.source.close"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":102,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.all.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":103,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.review.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":104,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.promotion.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":105,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.report.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":109,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":113,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":117,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":121,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":139,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.notification.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":149,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.activity.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":161,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.index.rebuild"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":173,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["governance.index.rebuild.confirm"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":173,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["governance.index.rebuild.cancel"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":92,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.management.open"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":138,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.open"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":148,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.list.paginate"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":157,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":306,"host":"form","prop":"data-analytics-id","event":"submit","ids":["skill.owned.search"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":327,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.owned.namespace.interact"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":331,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":333,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":341,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":354,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.filter"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":390,"host":"Card","prop":"data-analytics-id","event":"click","ids":["skill.owned.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":408,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":451,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.restore.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":462,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":478,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":494,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":510,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":525,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":554,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.owned.paginate"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":563,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":571,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.restore.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":571,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.restore.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":584,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":584,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":599,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":599,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":612,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":612,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":625,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":625,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.create.open"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":57,"host":"form","prop":"data-analytics-id","event":"submit","ids":["suite.owned.search"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":63,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.query.clear"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":70,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.owned.interact"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":71,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.owned.filter"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":73,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.attention.filter"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":82,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.owned.refresh"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":96,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.management.open","suite.operation.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":115,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.management.open","suite.operation.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":125,"host":"Pagination","prop":"analyticsId","event":"click","ids":["suite.owned.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":80,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.namespace.create"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":86,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.create.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":87,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.create.visibility.public"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":113,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":113,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.visibility.public"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":116,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.disable","github.namespace.enable"],"conditional":true}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":143,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.source.skills"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":149,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.namespace.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":159,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.skills.close"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":170,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.source.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":184,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":185,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.invitation.close"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":194,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.ownership.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":200,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":203,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":261,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["namespace.member.role.draft.interact"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":265,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.member.role.draft.member"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":266,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.member.role.draft.admin"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":289,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.role.save"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":295,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.role.cancel"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":306,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.remove"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":331,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.member.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":335,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.member.remove.confirm"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":335,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.member.remove.cancel"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":57,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.namespace.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":97,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":117,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.namespace.status.interact"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":119,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":120,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":121,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":130,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.namespace.sort.interact"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":134,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.sort"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":135,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.sort"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":175,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.namespace.user.tab"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":176,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.namespace.sync.tab"]}, + {"file":"web/src/pages/dashboard/namespace-settings.tsx","line":45,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.archive.request","namespace.delete.request","namespace.freeze.request","namespace.restore.request","namespace.unfreeze.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/namespace-settings.tsx","line":52,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.archive.confirm","namespace.delete.confirm","namespace.freeze.confirm","namespace.restore.confirm","namespace.unfreeze.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/namespace-settings.tsx","line":52,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.archive.cancel","namespace.delete.cancel","namespace.freeze.cancel","namespace.restore.cancel","namespace.unfreeze.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":195,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.previous"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":204,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.next"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":281,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.approve"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":284,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.reject"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":342,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.profile.sort.interact"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":346,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.profile.sort"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":347,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.profile.sort"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":363,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.pending.tab"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":369,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.approved.tab"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":375,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.rejected.tab"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":416,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["review.profile.approve.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":428,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.approve.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":431,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.approve.confirm"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":439,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["review.profile.reject.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":455,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.reject.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":458,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.reject.confirm"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":82,"host":"Pagination","prop":"analyticsId","event":"click","ids":["promotion.list.paginate"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":151,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.review.approve"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":154,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.review.reject"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":250,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.approve"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.reject"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":259,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["promotion.revocation.confirm"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":259,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["promotion.revocation.cancel"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":296,"host":"Pagination","prop":"analyticsId","event":"click","ids":["promotion.revocation.paginate"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":351,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.history.sort"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":437,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.pending.tab"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":438,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.tab"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":439,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.history"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":440,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.approved.tab"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":441,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.rejected.tab"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":265,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.namespace.interact"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":269,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.clear"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":271,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.select"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":283,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.visibility.interact"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":287,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.public"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":288,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":289,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.private"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"analyticsId","event":"click+keydown+change+drop","ids":["skill.package.select"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"folderAnalyticsId","event":"change","ids":["skill.package.folder.select"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"pickerAnalyticsId","event":"click","ids":["skill.package.folder.open"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":312,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.remove"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":343,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.preview.retry"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":355,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.save","skill.publish.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/publish.tsx","line":365,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.publish.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/publish.tsx","line":365,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.publish.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/reports.tsx","line":131,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":151,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.dismiss.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":159,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.resolve.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":167,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.hide.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":175,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.archive.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":192,"host":"Pagination","prop":"analyticsId","event":"click","ids":["report.list.paginate"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":208,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.pending.tab"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":209,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.resolved.tab"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":210,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.dismissed.tab"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["report.archive.confirm","report.dismiss.confirm","report.hide.confirm","report.resolve.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["report.archive.cancel","report.dismiss.cancel","report.hide.cancel","report.resolve.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":211,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":233,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":276,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":293,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":317,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.content.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":318,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.changes.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":319,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.source.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":320,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.history.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":362,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.suite.retry"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":439,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.approve.request"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":453,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":464,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.request"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":479,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.cancel"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":508,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.approve.confirm"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":508,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.approve.cancel"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":517,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.reject.confirm"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":517,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.reject.cancel"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":79,"host":"form","prop":"data-analytics-id","event":"submit","ids":["review.progress.search"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":100,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.progress.type.interact"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":104,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":105,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":106,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":118,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.progress.status.interact"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":122,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":123,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":124,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":125,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":138,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":193,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.progress.paginate"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":228,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.workspace.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":232,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":262,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.resubmit.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":274,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.workspace.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":278,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.history.toggle"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":115,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.list.paginate"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":158,"host":"TableRow","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":207,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.list.sort.interact"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":211,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.list.sort"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":212,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.list.sort"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":221,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.pending.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":227,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.approved.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":233,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.rejected.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":274,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.skill.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":280,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.sync.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":286,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.tab"]}, + {"file":"web/src/pages/dashboard/stars.tsx","line":42,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/dashboard/stars.tsx","line":53,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.starred.paginate"]}, + {"file":"web/src/pages/dashboard/subscriptions.tsx","line":42,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/dashboard/subscriptions.tsx","line":53,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.subscribed.paginate"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":313,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.authoring.manual"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":322,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.authoring.import"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":355,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.namespace.interact"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":356,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.namespace.select"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":417,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.visibility.interact"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":419,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.visibility.public"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":420,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":421,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.visibility.private"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":519,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.candidate.coordinate.select","suite.candidate.market.select","suite.candidate.namespace.select"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":542,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["suite.candidate.search"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":558,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.candidate.visibility.interact"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":560,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.candidate.visibility.public"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":561,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.candidate.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":562,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.candidate.visibility.private"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":565,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.candidate.search"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":596,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.member.add","suite.member.version.replace"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":634,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.collapse"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":643,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.entry.select"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":648,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.member.up"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":649,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.member.down"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":650,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.member.remove"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":653,"host":"input","prop":"data-analytics-id","event":"change","ids":["suite.entry.select"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":677,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.member.remove"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":684,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.toggle"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":687,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.cancel"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":688,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.create.save","suite.draft.edit.save","suite.version.draft.save"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":696,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["suite.member.version.confirm"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":696,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["suite.member.version.cancel"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":92,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit","suite.version.create"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":107,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.market.preview"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":162,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.overview.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":163,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.members.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":164,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.versions.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":165,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.publishing.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":177,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.publish.direct.request","suite.review.submit.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":128,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":176,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]}, + {"file":"web/src/pages/dashboard.tsx","line":194,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":245,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]}, + {"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]}, + {"file":"web/src/pages/github-skills.tsx","line":63,"host":"Button","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/github-skills.tsx","line":82,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.catalog.search"]}, + {"file":"web/src/pages/github-skills.tsx","line":85,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.catalog.sort"]}, + {"file":"web/src/pages/github-skills.tsx","line":91,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.catalog.paginate"]}, + {"file":"web/src/pages/home.tsx","line":52,"host":"SearchBar","prop":"analyticsId","event":"submit","ids":["search.query.submit"]}, + {"file":"web/src/pages/home.tsx","line":56,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/home.tsx","line":62,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/pages/home.tsx","line":81,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.popular.open"]}, + {"file":"web/src/pages/home.tsx","line":94,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/home.tsx","line":113,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.latest.open"]}, + {"file":"web/src/pages/home.tsx","line":126,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/join.tsx","line":58,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.join.retry"]}, + {"file":"web/src/pages/join.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.join.confirm"]}, + {"file":"web/src/pages/landing.tsx","line":92,"host":"input","prop":"data-analytics-id","event":"keydown-enter","ids":["discovery.query.enter"]}, + {"file":"web/src/pages/landing.tsx","line":106,"host":"button","prop":"data-analytics-id","event":"click","ids":["discovery.suggestion.search"]}, + {"file":"web/src/pages/landing.tsx","line":132,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/landing.tsx","line":358,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/pages/landing.tsx","line":361,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/landing.tsx","line":424,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.popular.open"]}, + {"file":"web/src/pages/landing.tsx","line":437,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/landing.tsx","line":453,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.latest.open"]}, + {"file":"web/src/pages/landing.tsx","line":466,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/landing.tsx","line":483,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]}, + {"file":"web/src/pages/landing.tsx","line":486,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]}, + {"file":"web/src/pages/login.tsx","line":87,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.personal.select"]}, + {"file":"web/src/pages/login.tsx","line":90,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.enterprise.select"]}, + {"file":"web/src/pages/login.tsx","line":97,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.login.submit"]}, + {"file":"web/src/pages/login.tsx","line":124,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.password.reset.open"]}, + {"file":"web/src/pages/login.tsx","line":145,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.password.visibility"]}, + {"file":"web/src/pages/login.tsx","line":199,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.register.open"]}, + {"file":"web/src/pages/login.tsx","line":209,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]}, + {"file":"web/src/pages/login.tsx","line":210,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]}, + {"file":"web/src/pages/notifications.tsx","line":88,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.all.read"]}, + {"file":"web/src/pages/notifications.tsx","line":102,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.category.all","notification.category.promotion","notification.category.publish","notification.category.report","notification.category.review"],"conditional":true}, + {"file":"web/src/pages/notifications.tsx","line":133,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.fallback.open","notification.promotion.open","notification.report.open","notification.review.detail.open","notification.review.list.open","notification.target.provided.open"],"conditional":true}, + {"file":"web/src/pages/notifications.tsx","line":158,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.delete.request"]}, + {"file":"web/src/pages/notifications.tsx","line":177,"host":"Pagination","prop":"analyticsId","event":"click","ids":["notification.list.paginate"]}, + {"file":"web/src/pages/register.tsx","line":186,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.register.submit"]}, + {"file":"web/src/pages/register.tsx","line":263,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/pages/reset-password.tsx","line":109,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/pages/reset-password.tsx","line":114,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.password.reset.submit"]}, + {"file":"web/src/pages/reset-password.tsx","line":129,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.password.code.request"]}, + {"file":"web/src/pages/search.tsx","line":209,"host":"SearchBar","prop":"analyticsId","event":"submit","ids":["search.query.submit"]}, + {"file":"web/src/pages/search.tsx","line":223,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.sort.relevance"]}, + {"file":"web/src/pages/search.tsx","line":230,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.sort.downloads"]}, + {"file":"web/src/pages/search.tsx","line":237,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.sort.newest"]}, + {"file":"web/src/pages/search.tsx","line":263,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.starred.filter"]}, + {"file":"web/src/pages/search.tsx","line":271,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.label.filter"]}, + {"file":"web/src/pages/search.tsx","line":281,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.namespace.clear"]}, + {"file":"web/src/pages/search.tsx","line":300,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/search.tsx","line":309,"host":"Pagination","prop":"analyticsId","event":"click","ids":["search.results.paginate"]}, + {"file":"web/src/pages/settings/profile.tsx","line":186,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.security.open"]}, + {"file":"web/src/pages/settings/profile.tsx","line":191,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.profile.edit"]}, + {"file":"web/src/pages/settings/profile.tsx","line":215,"host":"form","prop":"data-analytics-id","event":"submit","ids":["account.profile.save"]}, + {"file":"web/src/pages/settings/profile.tsx","line":261,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.profile.cancel"]}, + {"file":"web/src/pages/settings/security.tsx","line":96,"host":"form","prop":"data-analytics-id","event":"submit","ids":["account.password.save"]}, + {"file":"web/src/pages/skill-detail.tsx","line":831,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":867,"host":"div","prop":"data-analytics-id","event":"structural","ids":["skill.detail.render"]}, + {"file":"web/src/pages/skill-detail.tsx","line":872,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.back"]}, + {"file":"web/src/pages/skill-detail.tsx","line":923,"host":"CopyButton","prop":"analyticsId","event":"click","ids":["skill.coordinate.copy"]}, + {"file":"web/src/pages/skill-detail.tsx","line":945,"host":"Link","prop":"data-analytics-id","event":"click","ids":["search.label.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":985,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.overview.tab"]}, + {"file":"web/src/pages/skill-detail.tsx","line":986,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.files.tab"]}, + {"file":"web/src/pages/skill-detail.tsx","line":987,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.versions.tab"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1015,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.overview.toggle"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1058,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1097,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1104,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.delete"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.publish"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1141,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.submit"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1178,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.browser.toggle"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1200,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1291,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.detail.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1318,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"analyticsId","event":"click","ids":["skill.command.copy"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"skillhubAnalyticsId","event":"click","ids":["skill.command.skillhub.select"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"clawhubAnalyticsId","event":"click","ids":["skill.command.clawhub.select"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1413,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1423,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.download.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1442,"host":"InstallForAgentButton","prop":"analyticsId","event":"click","ids":["skill.prompt.copy"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1514,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1518,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1523,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1555,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.promotion.version.interact"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1560,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.promotion.version"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1569,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1582,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1586,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1621,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.hide.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1625,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unhide.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1631,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.yank"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1641,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["report.create.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1661,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1664,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1671,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1671,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1690,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1703,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1704,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.submit","skill.revocation.request.submit"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1711,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1711,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1720,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1720,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.delete.continue"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.delete.continue.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1748,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.delete.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1764,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1767,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.delete.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.delete.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1792,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1792,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1816,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1836,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1839,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1846,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.rerelease.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1846,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1867,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.publish.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1867,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.publish.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1880,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.submit.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1880,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.submit.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1902,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.comparison.close"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1967,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.close"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["skill.file.close"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["skill.file.download"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["skill.file.copy"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":160,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.comparison.base.interact"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":165,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.comparison.base"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":187,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.comparison.head.interact"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":192,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.comparison.head"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":200,"host":"Input","prop":"data-analytics-id","event":"change","ids":["skill.comparison.filter"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":212,"host":"a","prop":"data-analytics-id","event":"click","ids":["skill.comparison.file"]}, + {"file":"web/src/pages/suite-detail.tsx","line":116,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.command.copy"]}, + {"file":"web/src/pages/suite-detail.tsx","line":122,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.command.copy"]}, + {"file":"web/src/pages/suite-detail.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit"]}, + {"file":"web/src/pages/suite-detail.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.publish.direct.request","suite.review.submit.request"],"conditional":true}, + {"file":"web/src/pages/suite-detail.tsx","line":155,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.overview.tab"]}, + {"file":"web/src/pages/suite-detail.tsx","line":156,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.members.tab"]}, + {"file":"web/src/pages/suite-detail.tsx","line":157,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.versions.tab"]}, + {"file":"web/src/pages/suite-detail.tsx","line":185,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.open"]}, + {"file":"web/src/pages/suites.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.create.open"]}, + {"file":"web/src/pages/suites.tsx","line":47,"host":"form","prop":"data-analytics-id","event":"submit","ids":["suite.query.submit"]}, + {"file":"web/src/pages/suites.tsx","line":61,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.label.clear"]}, + {"file":"web/src/pages/suites.tsx","line":75,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.label.filter"]}, + {"file":"web/src/pages/suites.tsx","line":97,"host":"ResourceCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["suite.card.open"]}, + {"file":"web/src/pages/suites.tsx","line":106,"host":"Pagination","prop":"analyticsId","event":"click","ids":["suite.list.paginate"]}, + {"file":"web/src/shared/components/confirm-dialog.tsx","line":68,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","forward":"cancelAnalyticsId"}, + {"file":"web/src/shared/components/confirm-dialog.tsx","line":74,"host":"Button","prop":"data-analytics-id","event":"click","forward":"cancelAnalyticsId"}, + {"file":"web/src/shared/components/confirm-dialog.tsx","line":77,"host":"Button","prop":"data-analytics-id","event":"click","forward":"confirmAnalyticsId"}, + {"file":"web/src/shared/components/copy-button.tsx","line":25,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":51,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.view.select"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":78,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.prompt.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":174,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":193,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":232,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":273,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":321,"host":"a","prop":"data-analytics-id","event":"click","ids":["guide.package.download"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":371,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.mode.select"]}, + {"file":"web/src/shared/components/language-switcher.tsx","line":60,"host":"Button","prop":"data-analytics-id","event":"click","ids":["navigation.language.open"]}, + {"file":"web/src/shared/components/language-switcher.tsx","line":80,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.language.select"]}, + {"file":"web/src/shared/components/pagination.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/pagination.tsx","line":74,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/pagination.tsx","line":91,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/quick-start.tsx","line":31,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/route-error.tsx","line":24,"host":"Button","prop":"data-analytics-id","event":"click","ids":["navigation.route.retry"]}, + {"file":"web/src/shared/components/theme-toggle.tsx","line":18,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.theme.toggle"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":112,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.account.toggle"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":145,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.owned.skills"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":148,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.owned.suites"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":151,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":155,"host":"Link","prop":"data-analytics-id","event":"click","ids":["account.security.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":160,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.queue.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":166,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.users.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":171,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.labels.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":176,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.settings.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":181,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.namespaces.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":186,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.audit.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":191,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.logout.request"]}, + {"file":"web/src/shared/ui/dialog.tsx","line":45,"host":"DialogPrimitive.Close","prop":"data-analytics-id","event":"click","forward":"closeAnalyticsId"} + ] +} diff --git a/docs/frontend-analytics.md b/docs/frontend-analytics.md new file mode 100644 index 000000000..ad16414d5 --- /dev/null +++ b/docs/frontend-analytics.md @@ -0,0 +1,212 @@ +# Passive frontend analytics contract + + +For installation, platform initialization, upgrades, shutdown and acceptance, use the +[Chinese configuration guide][analytics-guide]. It is the authoritative operator +procedure for runtime Compose and Kubernetes/Helm. This document owns the event, +module, configuration and privacy contracts; it does not duplicate deployment steps. +Docker/Compose and Helm ship optional analytics with Web. No configuration means no +controller/adapter chunk, SDK or collection request. The small configuration gate is +part of bootstrap; the controller and SDK are not. Business components remain passive. + +## Contract and scope + +`data-analytics-id` names an action category, not a unique event, resource or user. +It describes intent, not its result. IDs match `^[a-z]+(?:\.[a-z]+){2,}$`. +Lowercase dotted names are independent of language, visible text and layout. +Published meanings are frozen. A meaning change requires a new ID. Renames/removals +need release notes and a reviewed [manifest](https://github.com/iflytek/skillhub/blob/main/docs/frontend-analytics-manifest.json) diff. +The manifest is an AST source inventory, not a runtime closed enum or a count of +users, pageviews, successful operations or exhaustive business coverage. + +The inventory includes landing/search, skills, suites, publishing, review, +governance, namespaces, GitHub integration, notifications, preferences, account, +authentication, administration and navigation. Literal branches distinguish finite +actions. Never interpolate resource, slug, version, user, provider, role or backend +values into IDs. Fixed navigation metadata is source-owned. Forwarding sites do +not necessarily represent DOM elements; portals and repeated resources share IDs. + +Run from the repository root with dependencies already installed: + +```sh +node web/scripts/analytics-manifest.mjs +# Only after reviewing intentional source changes: +node web/scripts/analytics-manifest.mjs --write +pnpm -C web test src/app/analytics-manifest.test.ts src/features/analytics +``` + +### Original eight compatibility meanings + +| ID | Meaning | +| --- | --- | +| search.query.submit | Native search form submission, not typing or search success. | +| skill.card.open | Existing card click or direct Enter/Space activation. | +| skill.detail.render | Structural loaded detail root, not an automatic pageview. | +| skill.download.request | Download attempt; guards may reject it. | +| skill.command.copy | Command-copy attempt, not clipboard or installation success. | +| skill.prompt.copy | Prompt-copy attempt, not execution or installation success. | +| skill.publish.request | Publish/update/resubmit attempt, not avatar-only save. | +| skill.publish.confirm | Explicit publish warning continuation, not every dialog. | + +Installation-method tabs distinguish SkillHub and ClawHub without changing the +original command-copy meaning. Visibility choices and business modes use static +categories, not data fetched from the selected object. + +## Native observation policy and limits + +Attributes alone do not create events. Keep one nearest semantic target per event. +Never change defaults, propagation, focus, history, permissions or app handlers. + +- FORM uses submit, not submit-button click plus submit. Enter converges here; + rejected native validation may produce no submit. Typing/clear is not submission. +- Ordinary buttons/links use click, including browser-generated keyboard click. + Ignore disabled, aria-disabled and data-disabled controls. App guards still apply. +- DIV role=link cards with existing Enter/Space handlers also use keydown. Do not + synthesize activation. Ignore repeat, IME and modifier keydown. Native buttons + must not count keydown and their generated click twice. +- Tabs generate click from navigation keys; do not count Arrow/Home/End again. + Do not invent keyboard support for existing mouse-only rows/directories. +- Native selects, checkboxes and radio changes have different boundaries. An + option marker can describe static selection intent, never its value/text. Do + not infer committed selection from opening a trigger or highlighting an item. +- Radix selection may use pointerup or Enter/Space in a portal. Trigger typeahead + can change selection without opening an item. Trigger interaction is not proof + of committed selection; never capture typed keys. Current finite rules do not + implement select/file/drop/crop; do not misconfigure them as click. +- File selection and dropping only offer files, not upload success. Do not read + filename/path/metadata. Programmatic picker clicks are not extra user actions; + cancellation is not observed. Crop interaction is not proof of changed pixels. +- Explicit cancel/close retains workflow meaning. Escape, overlay, route teardown, + browser Back, automatic close and native picker cancel are not reliably observed + by these markers. Closing is not proof a pending request was aborted. + +Use document delegation for portals; React ancestry is not DOM ancestry. Wrappers +forward caller-scoped IDs, not guessed business IDs. Reject nested unmarked +interactive controls rather than attributing them to an ancestor card/form. +Synthetic temporary download anchors do not prove new successful user actions. +SPA classification, consent transitions and view deduplication are separate +policies; never derive them from full URLs/query strings. + +## Single product source and interfaces + +The sole helper is `web/src/features/analytics/skillhub-intents.mjs`: + +- `observeSkillhubIntents({ root, rules, onIntent }) -> stop`. +- Frozen `DEFAULT_RULES` has the original twelve rules listed in the guide. +- `resolveRules(rules) -> { active, skipped: [{ id, reason }] }` is pure. Invalid + containers produce id:null and no active rules. Unknown IDs are not rejected + merely for being absent from the manifest. Invalid grammar/types are skipped. +- Omitted rules use defaults. Explicit rules replace defaults; {} collects nothing. +- click excludes FORM/SELECT/TEXTAREA/non-button INPUT; submit is FORM-only; card + is DIV role=link only. Host validity is checked at observation, not resolution. +- Intent is exactly `{ id, event }`; event is click/submit/keydown. Same-version + evolution is additive optional allowlisted static fields; consumers ignore + unknown keys. Breaking changes need a new module contract. +- Importing does nothing; without callback no listeners are installed. Same root + returns the same stop and retains the FIRST rules/callback. Stop then initialize + to change configuration. Old disposers cannot remove a new installation. Stop + is idempotent; nested observers share event deduplication. +- Callback throws/rejections are swallowed without affecting business events. + The helper itself has no network, cookies, storage, identity or history hooks. + +The controller owns authorization, rule resolution, generations and lifecycle. +Static registry metadata validates public adapterConfig; factories lazily import +adapters. Factory cancellation prevents late imports from creating an SDK after +stop. Adapter exposes `send(intent) -> Promise` (never rejects) and stop(). +The loader runs separately after the first React DOM commit, never blocking mount. +Disabled, absent, invalid or denied configuration does not import the controller. +Public `window.skillhubAnalytics.start()` explicitly grants current-page consent; +stop() immediately disables observation. Enabled=false cannot be overridden by +start. Deployment changes require recreating Web (`docker compose restart` does not +re-read the env file); old tabs do not update. + +## Configuration and proxy + +`SKILLHUB_WEB_ANALYTICS_CONFIG` is JSON with enabled (boolean/default false), +adapter (static registry name), consent (denied/granted/default denied), rules +(optional finite rule map), adapterConfig (adapter-owned public object). Umami +accepts only websiteId UUID. DataFinder accepts only appId (integer 1..2147483647) +and optional product (`^[a-z][a-z0-9_]{0,31}$`, default skillhub). No App Key or other +adapter fields are accepted. Invalid configuration disables only analytics with +field-only diagnostics, never raw values. Rule resolution keeps its skip behavior. +The startup parser limits JSON to 64 KiB/32 levels and rejects duplicate keys. + +`SKILLHUB_WEB_ANALYTICS_UPSTREAM` is a deploy-only HTTP(S) origin: no credentials, +path, query, fragment, whitespace or injection characters. It is never put in +browser configuration. Existing BusyBox tools validate deployment control fields; +base64 transports JSON safely as data into the existing no-store runtime-config.js. +No jq, Node runtime, extra package, CSP relaxation or HTML injection is required. + +The reserved same-origin prefix is `/_skillhub-analytics/`. BASE_PATH is applied in +the browser; existing Nginx rewrite strips it once. Enabled configurations generate +exact script.js and api/send routes for Umami, or one POST-only list route for +DataFinder, with method restrictions; all other paths are +404, never a platform administration endpoint. Consent-denied enabled configurations retain those routes +for explicit dynamic start, but do not load/request them automatically. +Variable proxy_pass uses the existing resolver at request time. HTTPS verifies +certificates using the system trust bundle and correct SNI. Collector proxying +strips Cookie/Authorization/Referer and hides Set-Cookie. API/auth routes, 100M +uploads, TLS, business caching and permissions are not changed. + +## Privacy and integration modes + +Allowlist static id/event and adapter-owned fields described below, not text/HTML, input/option values, clipboard, +URLs/query/fragment, cookies, account identities, codes/tokens, private paths, +filenames, review reasons or crop data. Test IDs can contain resource identifiers; +do not scrape them. The manifest is not a payload. + +Umami 3.4.1 uses manual-only script attributes and a final before-send sanitizer. +Only `{type:'event',payload:{website,name,data:{event}}}` survives; website must +match configuration, name must match product grammar and Umami's 50-character +limit. Pageview/identify/performance/context/extra data are dropped. Payload values +are read once. A non-enumerable toJSON guard rechecks stop synchronously at fetch +dispatch; upgrading the SDK must reverify its ordering with dispatch-boundary tests. +Stop cannot recall a fetch already started. + +At most 20 valid intents wait in memory for up to three seconds while the SDK +loads. Excess, failure, timeout or stop drops them. Once ready, requests are +independent and time-bounded, with no retry/durable queue/post-load backlog. +Do not load a second Umami tracker. Missing/blocked/offline collectors never reject +into the application. SDK loading is trusted code with page privileges: review and +pin its version, access and retention; it is not an arbitrary remote-JS plugin. + +This is NOT anonymous transport. IP/User-Agent/Origin still reach the platform. +The SDK sends website, actual hostname and session cache headers, reads +URL/title/referrer before sanitization, and reads its own localStorage switch. +Nginx strips sensitive headers before the collector, not before the browser's +request reaches Web. Web logs require their own privacy policy. Umami can derive +browser/OS/device/geo/session from transport metadata. With pinned 3.4.1 absent URL +stores empty path/query and fallback hostname localhost; title/referrer/screen/ +language/distinct identity remain absent. Removing JSON fields does not disable +transport-derived processing. Operators must review consent, logs, access and law. + +DataFinder sends the minimal `/list` JSON array directly through same-origin fetch +with keepalive, credentials omitted and no referrer. No official SDK is shipped, +proxied or loaded. One intent is one batch, without batching delay or event storage; +fetch and response parsing share a three-second deadline, no retries. Acceptance +requires HTTP success, e=0 and sc=1, not HTTP 200 alone. Failures are best-effort drops. +Events retain their dotted ID; params is the JSON string `{ "event": "click" }` +(or submit/keydown). header.custom contains only product and module. The optional +generic context getter is passed from bootstrap through loader/controller/factory; +it is read at send time. Bootstrap uses the already-loaded app router's matched +route metadata (routesById[routeId].fullPath), never concrete match paths, params, +query or hash. Unmatched routes use other. Analytics does not eagerly import the router. + +DataFinder's user_unique_id and web_id share an anonymous random UUID v4 (crypto.randomUUID, +or crypto.getRandomValues where randomUUID is unavailable, e.g. plain-HTTP origins) stored +under localStorage key `skillhub.analytics.datafinder.browser-id`. Invalid stored +values are replaced. Unavailable storage falls back to one in-memory ID for the +page, including stop/start. This enables per-browser UV and is not linked to the +SkillHub account. Deployers must decide whether this persistent identifier is +acceptable. Disabled/denied collection does not run this code or access the key. +Only the identifier persists, never events. Clearing the key rotates a stored ID. +IP/User-Agent/Origin remain transport metadata; the proxy preserves browser UA. +No App Key is accepted in browser settings or sent by this adapter. + +Supported-platform deployment, initialization, verification, upgrade, shutdown and +custom adapter development are documented in the guide. Custom adapters require +reviewed code, registry validation, cancellation, final payload cleaning and exact +proxy routes; business components must remain vendor-neutral and passive. + + +[analytics-guide]: frontend-analytics-integration.md diff --git a/docs/security-scanning.md b/docs/security-scanning.md index 536ce465a..f96d4ffa7 100644 --- a/docs/security-scanning.md +++ b/docs/security-scanning.md @@ -81,6 +81,15 @@ Scanner-side optional environment variables: - `SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS` (default `930`) - `SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES` (default `110100480`, or 105 MiB) +For `SKILLHUB_SCANNER_MAX_UPLOAD_SIZE_BYTES`, prefer an integer byte value such as +`110100480` (105 MiB). The scanner also accepts scientific notation when the value +is an exact finite integer, for example `1.1010048e+08`; this accommodates Helm +serialization. Fractional values, non-finite values and invalid text are rejected +at scanner startup rather than silently rounded. Zero and negative integer values +are clamped to `1` byte, not interpreted as unlimited. Positive scientific-notation +values must also fit the platform's supported integer range. This scanner-side limit does +not override separate proxy or server upload limits. + If the LLM variables are absent, the scanner should still run with non-LLM analyzers. The default timeout ordering is server read timeout (900 seconds), scanner hard timeout (930 seconds), then pending-message reclaim (960 seconds). A hard timeout exits the scanner process diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index 59bbd3cd4..169c1fae4 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -70,6 +70,7 @@ export default defineConfig({ items: [ { text: 'Docker 部署', link: '/guide/deployment' }, { text: 'Kubernetes 部署', link: '/guide/kubernetes' }, + { text: '前端动作统计', link: '/guide/frontend-analytics' }, { text: '技能头像与审核', link: '/guide/skill-avatars' }, ], }, @@ -144,6 +145,7 @@ export default defineConfig({ items: [ { text: 'Deploy with Docker', link: '/en/guide/deployment' }, { text: 'Deploy on Kubernetes', link: '/en/guide/kubernetes' }, + { text: 'Frontend analytics', link: '/en/guide/frontend-analytics' }, { text: 'Skill avatars and moderation', link: '/en/guide/skill-avatars' }, ], }, diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 8f875755a..2163835a5 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -272,6 +272,21 @@ A `USER_ADMIN` can manage user status and assign platform roles other than `SUPE A: Use [Suites](./guide/skill-suites.md) and `skillhub suite install` for pinned members managed together, `skillhub sync pull` for team workspaces, or separate `skillhub install` commands for unrelated skills. Suite Bundle is an authoring import format, not a local installation command. +## Q: Where do I submit a saved Skill Suite draft for review, and why is the button missing or disabled? + +A: Open the management page for that Suite version and use the publishing checklist on its Overview tab. Public (`PUBLIC`) and namespace-visible (`NAMESPACE_ONLY`) Suites require Suite-level review; private (`PRIVATE`) Suites show Publish directly. This is separate from the scanning and review of each member Skill. + +- **No button:** confirm that you are managing a version you can operate on, rather than viewing its marketplace preview. The action appears only when the server allows your account to submit or publish that version; both permissions and version state affect available actions. +- **Disabled button:** complete the summary and overview, select an available entry Skill, and resolve blocked member versions. Every checklist item must be ready, with no submission already pending. + +See [creating and managing Skill Suites](./guide/skill-suites.md). If an older installation differs from the guide, report the Server/Web versions, Suite state and a redacted screenshot. Skill or Agent publishing pages are not substitutes for the Suite publishing page. + +## Q: Does a Suite overview become a callable skill after installation? + +A: No. The overview explains the intended tasks, member responsibilities and boundaries to reviewers and users. Installation delivers separate member skill directories; it does not combine the overview into a new Suite `SKILL.md`, and there is no universal `suite run` command. + +Put execution order, member-call instructions and result validation in the **entry and member `SKILL.md` files**. In the installation target project, confirm that the Agent has discovered the entry Skill, then ask it to use that entry for the task. The Suite coordinate manages installation; actual use starts with the entry Skill. Installing member files does not configure their required tools or services. Follow the [Skill Suite usage guide](./guide/skill-suites.md). + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: diff --git a/docs/skillhub/en/guide/deployment.md b/docs/skillhub/en/guide/deployment.md index 70b64a8cb..bc2d92328 100644 --- a/docs/skillhub/en/guide/deployment.md +++ b/docs/skillhub/en/guide/deployment.md @@ -48,6 +48,29 @@ for publication, download preparation, review, promotion, archive, visibility an scope, every setting, a real receive/query example and loss/duplicate/shutdown limits. Download preparation is not completion or installation; this lossy stream is not audit, billing or reliable notification. +## CLI device authorization URL + +`skillhub login --no-open --registry https://skills.example.com` first resolves `--token`, then `SKILLHUB_TOKEN`, then saved credentials for that registry. Device authorization starts only when no token is resolved; an invalid token does not automatically fall back. `--no-open` only disables automatic browser launch. Verify the resulting identity with `skillhub whoami --registry https://skills.example.com`. + +- When `DEVICE_AUTH_VERIFICATION_URI` is unset, empty, or whitespace, the server appends `/device` to `SKILLHUB_PUBLIC_BASE_URL`, preserving sub-paths and trimming trailing slashes. For example, `https://skills.example.com/portal/` becomes `https://skills.example.com/portal/device`. +- Without a public base URL, the compatibility fallback is `/device`, resolved by the CLI against the registry origin. Set the actual public Web URL for production, sub-path, or separate Web/API deployments. +- An explicit valid HTTP(S) URL or root-relative path overrides the default. Invalid values prevent startup without echoing the configured value. `/cli/auth` is the legacy loopback callback, not the device-code entry page. + +Edit `.env.release` in the existing deployment directory before recreating the backend: + +- Source checkout: run `make validate-release-config` from the repository root containing `Makefile` and `scripts/validate-release-config.sh`, and resolve errors before recreation. +- One-click script installation: use the `Runtime dir` printed by the script (the instance's `--home` directory). The script downloads the Compose file and environment template, not the Make validation tooling; do not run `make` in that directory. + +The following command applies to a standard stack without a custom project name or override files: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --no-deps --force-recreate server +``` + +If the original deployment uses `-p`, `COMPOSE_PROJECT_NAME`, or additional `-f` files, retain its project name, environment file and every `-f` argument in the original order. Do not copy the base command unchanged: it could target another instance or omit override settings. This only recreates the existing backend; it does not download deployment files again. + +Use the full `up -d` for an initial deployment. A plain `restart` does not reload container environment variables. If other shared settings changed, recreate their affected services too. To verify the device URL, retry the login command without `--token`, `SKILLHUB_TOKEN`, or saved credentials for that registry, and check the returned public URL. Successful login with an existing token does not verify the device URL. Do not share codes or tokens. See the [operations reference](https://github.com/iflytek/skillhub/blob/main/docs/09-deployment.md) for the full configuration contract. + ## Upgrade, stop, and troubleshoot Before upgrading, record the current image version and configuration, and back up both the database and skill object storage. Set `SKILLHUB_VERSION` to the target release and repeat validation, `pull`, `up -d`, and workflow verification. Rolling back an image does not roll back the database; check migration compatibility first. diff --git a/docs/skillhub/en/guide/frontend-analytics-guide.md b/docs/skillhub/en/guide/frontend-analytics-guide.md new file mode 100644 index 000000000..093e6aafa --- /dev/null +++ b/docs/skillhub/en/guide/frontend-analytics-guide.md @@ -0,0 +1,12 @@ +# Detailed setup (Chinese) + +The detailed setup instructions below are currently available in Chinese. Validate the example in an isolated environment first. + +[← Back to overview](./frontend-analytics.md) + + + +[analytics-guide]: ./frontend-analytics-guide.md +[analytics-reference]: ./frontend-analytics-reference.md +[analytics-subpaths]: ./frontend-analytics-reference.md#supported-sub-path-mapping +[analytics-privacy]: ./frontend-analytics-reference.md#privacy-and-integration-modes diff --git a/docs/skillhub/en/guide/frontend-analytics-reference.md b/docs/skillhub/en/guide/frontend-analytics-reference.md new file mode 100644 index 000000000..c2736f8ed --- /dev/null +++ b/docs/skillhub/en/guide/frontend-analytics-reference.md @@ -0,0 +1,12 @@ +# Interface and privacy contract + +This contract is in English. Detailed setup instructions are currently available in Chinese. + +[← Back to overview](./frontend-analytics.md) + + + +[analytics-guide]: ./frontend-analytics-guide.md +[analytics-reference]: ./frontend-analytics-reference.md +[analytics-subpaths]: ./frontend-analytics-reference.md#supported-sub-path-mapping +[analytics-privacy]: ./frontend-analytics-reference.md#privacy-and-integration-modes diff --git a/docs/skillhub/en/guide/frontend-analytics.md b/docs/skillhub/en/guide/frontend-analytics.md new file mode 100644 index 000000000..4419db819 --- /dev/null +++ b/docs/skillhub/en/guide/frontend-analytics.md @@ -0,0 +1,46 @@ +# Frontend analytics: deployment and setup + +SkillHub supports configuration-only integration with Umami and DataFinder. Collection is off by default. Enabled collection observes configured actions without making normal business operations wait for the statistics platform. No business JS edits or hand-written Nginx injection are required. + +Use a **Web image containing this feature**. Pin deployment-file source and image tags separately and match their versions: updating `runtime.sh` alone cannot add an adapter to an older image. See the [detailed operator guide](./frontend-analytics-guide.md) (Chinese) and the [interface and privacy contract](./frontend-analytics-reference.md) (English). + +## Choose a deployment path + +| Situation | Approach | Instructions | +| --- | --- | --- | +| Script-installed SkillHub, no statistics platform | Start independent Umami and PostgreSQL, obtain a website UUID, then enable collection | [Operator guide, section 3](./frontend-analytics-guide.md) | +| Existing Umami or DataFinder | Supply the platform's public identifier and collection origin, then recreate Web; no local statistics services | [Operator guide, sections 2 and 3.1](./frontend-analytics-guide.md) | +| Kubernetes / Helm | Deploy the statistics platform independently and connect SkillHub using Service DNS | [Operator guide, section 4](./frontend-analytics-guide.md) | + +## Deploy the platform before obtaining its UUID + +1. Follow the operator guide to fetch the selected version of `runtime.sh`, choose a persistent runtime directory, and retain existing business configuration. +2. Run `analytics-up` to fetch the supporting files and start independent Umami and PostgreSQL. +3. Open the printed administration URL. Its default binding is `127.0.0.1:8300`; use SSH forwarding for a remote host. +4. Sign in, immediately change the initial password, create a website, and copy its **UUID**. +5. Run `analytics-enable --website-id ` to write the configuration and recreate Web. +6. Refresh the page, trigger configured actions, and verify records in Umami. + +Starting Umami does not require a website UUID. SkillHub can also run normally with collection disabled while the platform is being prepared. + +Existing-platform users skip platform installation. Umami needs its website UUID; DataFinder needs its numeric app ID and collection origin. Never place administration passwords, API tokens or App Keys in browser configuration. + +## Independent lifecycle + +The browser sends events to a same-origin SkillHub Web endpoint, which forwards them to the platform. For local Umami, only Web and Umami join the shared private network; the SkillHub backend and business database remain outside it. + +- `.env.release` holds business and Web collection configuration. +- `analytics/.env` holds independent credentials, project and network identity. Once initialized, those identifiers are authoritative; renaming them is not a data migration. +- `analytics-disable` disables collection and recreates Web while the platform stays running. +- `analytics-down` stops statistics services without deleting credentials or data. +- `down` and `clean` affect SkillHub. When the independent analytics directory exists, `clean` retains it and its data. Restore business configuration before redeploying SkillHub. + +The operator guide covers legacy identity migration, multiple instances, upgrades and recovery. + +## Verify behavior + +Disabled or unauthorized new pages must load no collection module or SDK and send no events. Enabled pages should send only configured actions, without search text, real resource paths or login credentials. Platform failures must leave normal business behavior intact. + +HTTP 200 alone is not evidence of storage. Verify records in the platform; DataFinder additionally requires `e:0` and `sc:1`. Actions describe intent rather than successful download, installation or publication. + +`consent=granted` records the deployment's authorization; it does not mean every user clicked a consent dialog. Integrate an existing consent component when per-user approval is required. diff --git a/docs/skillhub/en/guide/index.md b/docs/skillhub/en/guide/index.md index 6b4dcdb35..753d1bc61 100644 --- a/docs/skillhub/en/guide/index.md +++ b/docs/skillhub/en/guide/index.md @@ -37,6 +37,7 @@ Read these guides when you need to run your own registry. Choose a deployment me - [Deploy with Docker](./deployment.md): installation, configuration, upgrades, and backups. - [Deploy on Kubernetes](./kubernetes.md): cluster and Helm deployment. +- [Frontend analytics](./frontend-analytics.md): optional manual integration, consent, and failure acceptance. - [Skill avatars and moderation](./skill-avatars.md): administrator configuration for image moderation providers. ## Develop and integrate diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index beab73783..61e508b29 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -275,6 +275,21 @@ A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN` A: 需要固定成员版本并统一升级时,使用 [技能套装](./guide/skill-suites.md) 的 `skillhub suite install`。同步团队命名空间工作区时,使用 [CLI](./guide/cli.md) 的 `skillhub sync pull`。若只是独立安装几个无关技能,可分别运行 `skillhub install`;Suite Bundle 是发布者的导入格式,不是本地安装命令。 +## Q: 技能套装保存草稿后,在哪里提交审核?按钮不显示或不可用怎么办? + +A: 进入该版本的套装管理页,在「概览」中的发布检查清单操作。公开(`PUBLIC`)和命名空间可见(`NAMESPACE_ONLY`)套装走套装级审核;私有(`PRIVATE`)套装显示「直接发布」。这与成员 Skill 自身的扫描、审核是两层流程。 + +- **没有按钮**:先核对是否进入有管理权限的套装版本,而不是市场预览页;页面只在服务端允许当前账号提交审核或直接发布时显示该操作,版本状态和权限都会影响可用动作。 +- **按钮不可用**:补齐简介、概述,指定可用的入口技能,并处理成员版本的阻塞原因。只有全部检查项就绪,且没有提交中的请求时才能提交。 + +参见 [创建与管理技能套装](./guide/skill-suites.md)。若旧版本页面与指南不一致,请附服务端/Web 版本、套装状态和脱敏截图反馈,不要把 Skill 或 Agent 的发布入口当作套装入口。 + +## Q: 安装技能套装后,概述会变成一个可调用的技能吗? + +A: 不会。概述说明套装适用任务、成员分工和使用边界,供审核者与使用者阅读;安装交付的是各成员的独立技能目录,不会将概述合成为一个新的套装 `SKILL.md`,也不提供通用的 `suite run` 命令。 + +需要 Agent 遵循的执行顺序、成员调用规则和结果校验,应写进**入口技能及成员的 `SKILL.md`**。在安装目标项目中确认 Agent 已发现入口技能,再请求它使用该入口完成任务;套装坐标用于安装管理,运行时使用实际入口技能。安装成员文件不等于配置好它们依赖的工具或服务。详细步骤见 [安装后开始使用](./guide/skill-suites.md#安装后开始使用)。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: diff --git a/docs/skillhub/guide/frontend-analytics-guide.md b/docs/skillhub/guide/frontend-analytics-guide.md new file mode 100644 index 000000000..53658847d --- /dev/null +++ b/docs/skillhub/guide/frontend-analytics-guide.md @@ -0,0 +1,12 @@ +# 详细接入操作(中文) + +以下是只改配置的接入步骤,请先在隔离环境验证。 + +[← 返回接入概览](./frontend-analytics.md) + + + +[analytics-guide]: ./frontend-analytics-guide.md +[analytics-reference]: ./frontend-analytics-reference.md +[analytics-subpaths]: ./frontend-analytics-reference.md#supported-sub-path-mapping +[analytics-privacy]: ./frontend-analytics-reference.md#privacy-and-integration-modes diff --git a/docs/skillhub/guide/frontend-analytics-reference.md b/docs/skillhub/guide/frontend-analytics-reference.md new file mode 100644 index 000000000..d8b2de725 --- /dev/null +++ b/docs/skillhub/guide/frontend-analytics-reference.md @@ -0,0 +1,12 @@ +# 接口与隐私契约(英文) + +本页契约正文为英文,详细操作步骤见中文指南。 + +[← 返回接入概览](./frontend-analytics.md) + + + +[analytics-guide]: ./frontend-analytics-guide.md +[analytics-reference]: ./frontend-analytics-reference.md +[analytics-subpaths]: ./frontend-analytics-reference.md#supported-sub-path-mapping +[analytics-privacy]: ./frontend-analytics-reference.md#privacy-and-integration-modes diff --git a/docs/skillhub/guide/frontend-analytics.md b/docs/skillhub/guide/frontend-analytics.md new file mode 100644 index 000000000..e93bd1e10 --- /dev/null +++ b/docs/skillhub/guide/frontend-analytics.md @@ -0,0 +1,47 @@ +# 前端动作统计:部署与接入 + +SkillHub 支持通过配置接入 Umami 或 DataFinder。默认关闭;开启后按已配置的动作规则采集,正常业务不等待统计平台。不需要修改业务 JS 或手写 Nginx。 + +请先使用**包含本功能的 Web 镜像**。脚本文件版本与镜像标签需要分别选择并匹配;只更新 `runtime.sh` 不会让旧镜像获得新的采集模块。完整命令、配置示例和排障见[详细接入指南](./frontend-analytics-guide.md),字段与隐私边界见[接口契约](./frontend-analytics-reference.md)。 + +## 选择部署路径 + +| 你的情况 | 接入方式 | 完整步骤 | +| --- | --- | --- | +| 一键脚本部署 SkillHub,还没有统计平台 | 脚本启动独立 Umami 和数据库,取得网站 UUID 后开启采集 | [详细指南第 3 节](./frontend-analytics-guide.md) | +| 已有 Umami 或 DataFinder | 填写平台公开标识和采集地址,重建 Web;不新建统计服务 | [详细指南第 2、3.1 节](./frontend-analytics-guide.md) | +| 使用 Kubernetes/Helm | 统计平台独立部署,SkillHub 通过 Service DNS 连接 | [详细指南第 4 节](./frontend-analytics-guide.md) | + +## 没有统计平台时,先部署,再拿 UUID + +1. 按详细指南获取所选版本的 `runtime.sh`,固定运行目录,保留原有业务配置。 +2. 执行 `analytics-up`,脚本获取配套文件并启动 Umami 和它自己的 PostgreSQL。 +3. 打开脚本输出的管理地址;默认绑定本机 `127.0.0.1:8300`,远程机器通过 SSH 转发访问。 +4. 使用初始账号登录后立即修改密码,在网站管理中创建网站,取得网站 **UUID**。 +5. 执行 `analytics-enable --website-id <网站UUID>`,脚本写入配置并重新创建 Web。 +6. 刷新页面,触发已配置的动作,并到 Umami 核对实际记录。 + +**启动统计平台不需要网站 UUID。** UUID 在平台启动后创建网站时取得。SkillHub 也可以先按默认关闭状态正常运行。 + +已有平台可以跳过部署步骤:Umami 需要网站 UUID,DataFinder 需要数字应用 ID 和采集域名。管理密码、API token、App Key 不得写入浏览器配置。 + +## 两个独立项目,按需连接 + +浏览器向 SkillHub Web 的同源采集路径发请求,再由 Web 转发到统计平台。独立部署的 Umami 和 Web 通过共享私网连接,SkillHub 后端与业务数据库不加入该统计网络。 + +- `.env.release` 保存 SkillHub 业务及 Web 采集配置。 +- `analytics/.env` 保存独立统计凭据、项目名和网络名。初始化后,管理命令使用这里的标识,不能靠改名迁移原数据。 +- `analytics-disable` 关闭采集并重建 Web,统计平台继续运行。 +- `analytics-down` 停止统计服务,数据与密钥保留。 +- `down`/`clean` 处理 SkillHub;存在独立统计目录时,`clean` 保留该目录和数据。重新部署业务前需要恢复业务配置。 + +旧配置迁入、多个实例、升级与恢复的完整说明见[详细指南](./frontend-analytics-guide.md)。 + +## 如何确认接入成功 + +- 配置关闭或未获授权时,新页面不加载采集模块、SDK,也不发送统计请求。 +- 开启后,Network 中应只出现配置允许的事件,不包含搜索词、真实资源路径或登录凭证。 +- 平台故障时,搜索、上传、下载等原业务仍应正常。 +- HTTP 200 不等于记录已经入库;还要到统计平台核对。DataFinder 同时要求响应 `e:0`、`sc:1`。 + +动作表示用户意图,例如“点击下载”,不代表下载或安装成功。`consent=granted` 是部署方授权,不代替逐用户同意弹窗;需要逐用户同意时,按指南接入现有授权组件。 diff --git a/docs/skillhub/guide/index.md b/docs/skillhub/guide/index.md index b6ef57dd7..57247860c 100644 --- a/docs/skillhub/guide/index.md +++ b/docs/skillhub/guide/index.md @@ -37,6 +37,7 @@ - [Docker 部署](./deployment.md):安装、配置、升级和备份。 - [Kubernetes 部署](./kubernetes.md):集群和 Helm 部署。 +- [前端动作统计](./frontend-analytics.md):可选手动接入、授权与故障验收。 - [技能头像与审核](./skill-avatars.md):管理员配置图片审核服务。 ## 开发与集成 diff --git a/openspec/changes/add-configurable-frontend-analytics/design.md b/openspec/changes/add-configurable-frontend-analytics/design.md new file mode 100644 index 000000000..d4aff35d1 --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/design.md @@ -0,0 +1,86 @@ +## Context and decisions + +本变更在 PR #1010 上保留原贡献历史并合入最新 main,保留 footer 翻译及 Trans 登录协议。使用已有运行时配置比 Nginx HTML 注入更易自动交付;完整标签管理平台增加额外服务与权限体系,本期不采用。 + +## 分阶段交付与接口对齐 + +PR-1(#1010)仅完成被动标签、厂商中立 helper 与示例 adapter、edge 示例问题修正和中文接入指南。它不修改产品入口或部署开关,手工接入方式继续明确标为可选示例。PR-2 独立交付配置化加载、产品模块、runtime-config、同源代理和 Compose/Helm。两者可分别合并,Issue #989 仅在 PR-2 完成并验证后关闭。 + +PR-2 必须采用 PR-1 实际交付的接口,不能另起一套不兼容协议: + +| PR-1 接口 | PR-2 采用方式 | +| --- | --- | +| `data-analytics-id` 匹配 `^[a-z]+(?:\.[a-z]+){2,}$`,由 manifest 索引 | 保持已发布 ID 含义冻结;manifest 不是运行时封闭枚举,不插值资源/身份信息 | +| `observeSkillhubIntents({ root, rules, onIntent }) -> stop` | controller 按授权安装委托观察;不改变页面原生事件和业务处理 | +| 冻结的 `DEFAULT_RULES`(12 条) | rules 省略用默认值,显式提供为替换,空对象不采集;增加默认规则需显式合并 | +| `resolveRules(rules) -> { active, skipped: [{ id, reason }] }` | 使用相同纯校验;非法容器的 id 为 null;诊断只留本地,不发送原配置 | +| 有限类型 `click`/`submit`/`card` | 保持宿主限制和原生事件边界;未知类型/ID 格式/不支持宿主跳过,不猜测选择、文件、拖放或裁剪事件 | +| intent 仅 `{ id, event }` | event 为原生 click/submit/keydown,不表示业务成功;同版本只允许增加可选的静态白名单字段,消费者忽略未知字段;破坏性变更另版本 | +| adapter `send(intent) -> Promise`、`stop()` | send 消化异常、永不 reject;平台参数只归 adapter;停止后不得派发新请求,已开始 fetch 不可撤回 | +| 同 root 重复初始化保留第一次 config/callback 并返回相同 stop | 改配置先 stop 再初始化;旧 stop 不得撤掉新安装,stop 可重复调用;嵌套观察共享事件去重 | + +配置映射:enabled=false 不安装观察、不加载 SDK;consent=denied 同样禁止初始化。enabled 与 consent 的联合判断由通用 controller 管理,不放进业务组件。rules 原样通过 resolveRules,保留 active/skipped 语义。adapter 仅选择静态注册 factory;adapterConfig 只交给所选适配层校验,不把 websiteId 等厂商字段提升到通用配置。PR-1 示例的 website/prefix 对应 PR-2 Umami 的 adapterConfig.websiteId/部署生成的同源前缀;PR-1 的 hasConsent hook 对应 PR-2 consent 和动态 start/stop。PR-2 新增默认关闭的模块加载边界,不能声称 PR-1 示例已具备产品配置化加载。 + +PR-2 将 helper 迁入 `web/src/features/analytics` 的单一产品源码,迁移而非复制,保留 PR-1 测试的全部断言,包括 grammar、有限规则、生命周期、隐私和 dispatch 边界。示例 adapter 仍不是任意远程 JS 插件机制。 + +## PR-2 产品设计(下列能力尚未在 PR-1 实现) + +### 1. Stable configuration contract + +- `SKILLHUB_WEB_ANALYTICS_CONFIG`:可选 JSON 对象,空值等同关闭。字段为 enabled(boolean,默认 false)、adapter(注册适配器名)、consent(`denied`/`granted`,默认 denied)、rules(可选动作→有限规则对象,省略沿用默认规则,显式空对象不采集)、adapterConfig(平台自有公开参数对象)。 +- `SKILLHUB_WEB_ANALYTICS_UPSTREAM`:仅部署可见的代理 origin,不能含凭证、路径、查询、fragment、空白或 Nginx 注入字符;不进入浏览器 runtime-config。 +- 内置 Umami 适配器在 adapterConfig 中读取 websiteId(公开 UUID),不让通用 controller 认识该字段。平台密钥不允许写入浏览器配置。 +- `consent=granted` 表示部署方显式确认其部署策略允许本次采集,不等同每个用户单独点击同意。需动态用户授权的部署可使用公开 start/stop 接口接入既有同意组件;本期不新增同意 UI。 +- 不合法配置只关闭采集,并给出不包含原值的字段级诊断;不可破坏 API/auth 等原 runtime-config 或阻止 Web 启动。 + +### 2. Module boundary and single source + +业务 TSX 仅有被动标签。采集源码在 web/src/features/analytics 下单一维护,包含通用类型、controller、规则 helper、静态注册表和独立 adapters/umami。迁移既有示例测试到正式源码,保留既有断言;不维护 docs 和产品两份 helper。 + +bootstrap 完成原应用启动后,以不等待 SDK 的独立异步路径读取 analytics 配置并按需导入 controller。enabled=false 或 consent=denied 时不得导入采集模块、加载 SDK 或请求统计平台。适配器接口只要求 send(intent)、stop();factory 注册与配置校验由适配层完成,不开放任意远程 JS URL。 + +controller 负责一次初始化、规则校验、代际隔离和停止。异步初始化期间被关闭后,迟到的模块或适配器不能重新启动。stop 后不得新派发请求,已开始的 fetch 无法撤回。保留既有队列上限、超时及 best-effort 不重试行为。异常、拒绝或统计服务不可用不得影响业务。 + +### 3. Same-origin optional proxy + +新增保留前缀 `/_skillhub-analytics/`,同步现有 base-path 的 shell、TS、配置校验及 Helm 保留规则。浏览器侧自动使用 BASE_PATH;既有 Nginx 子路径 rewrite 只处理一次。 + +Nginx 通过独立 include 片段选择已注册适配器的精确端点:Umami 仅 script.js 和 api/send;其余路径拒绝。使用变量 proxy_pass 和已有 resolver,使统计域名解析不成为启动依赖。HTTPS 上游必须验证证书并带正确 SNI。采集路由移除 Cookie、Authorization、Referer,不扩大原 API/auth 路由、不修改原 100M 上传限制、TLS、业务缓存或权限。禁止为接入放宽 CSP。公开 runtime 配置只含适配器必须的同源前缀和公开参数。 + +### 4. Deployment and lifecycle + +现有平台接入:填写 JSON 和内部 upstream,更新 Web。可选自托管组合:统计服务与其持久化数据库使用独立 Compose 项目,仅 Web 和 Umami 加入共享私网,不新增 Nginx;创建网站/取得 UUID 等一次性平台步骤必须诚实说明。入口和 Helm 均默认关闭。 + +独立统计目录中的 `analytics/.env` 同时保存凭据、项目名和网络名,初始化后成为这些标识的权威来源;外层业务配置和继承的 shell 环境不能将统计命令或 Web 接线重定向到其他实例。`clean` 删除业务运行文件但保留独立统计目录。旧配置缺少标识时,只从仍存在的原业务配置迁入;若原配置已丢失,则所有命令在生成默认文件之前明确失败,要求恢复已确认的标识,不猜测默认项目。 + +针对已安装 Release 的 Helm 统计验收仅覆盖统计字段,复用现有 Release 值和 Secret;不得隐式叠加固定测试凭据。调用者提供的 values 仍必须与安装配置一致。该脚本会执行真实升级并改变事件规则,只用于有明确边界的隔离验收环境,不能描述为只读检查。 同一次 Helm 渲染中的应用 Secret 只解析或生成一次,资源本体及 Server/GitHub Sync 的 checksum 共用相同渲染结果;内部缓存不写入 values。这样自动生成凭据的首次统计升级也不会因校验值漂移误滚动后端,同时保留真正修改凭据时的滚动能力。旧版 Chart 已安装实例的首次修复升级可能纠正历史 checksum 并滚动后端一次;文档需披露兼容边界,不将该迁移当作纯统计值调整。 + +部署配置变更需重新创建 Web(Compose restart 不重新注入环境变量;Helm 统计值放在 Web 专用 ConfigMap,只滚动 Web)使新页面生效;旧标签页不自动接收新环境参数。动态停止通过当前页面接口生效;紧急全局停止可关闭独立采集路由。文档不得宣称仅修改 env 即刻撤销已在发送的请求。模块使用正常发布构建产物与缓存策略,不把可变配置放入 immutable JS。 + +### 5. Serialization and loading implementation + +PR-2 不新增 jq 或 Node 运行时依赖。镜像已有 BusyBox awk 负责有界 JSON 语法和部署控制字段校验,base64 将 JSON 作为数据追加到现有 no-store runtime-config.js;浏览器用 JSON.parse 解码,不经 eval,也不把任意 JSON 插入 JS 字面量。配置最大 64 KiB、最多 32 层,重复键拒绝。非法 rules 仍按既有 resolveRules 跳过,不升级为整个配置失败。 + +bootstrap 仅静态包含小型 gate 和注册参数校验;controller/helper 与 adapter 保持独立懒加载 chunk。在首个 React DOM commit 后的独立任务中启动 gate,不把正常挂载与 SDK 绑定。enabled=true 且 consent=denied 时不加载模块,但代理保留精确端点,供当前页显式 start() 授权后使用。静态 adapter factory 在动态导入返回后检查取消信号,迟到的导入不能重新创建 SDK。 + +旧示例目录仅保留指南指针。helper/adapter 与全部断言迁入产品测试树;部署回归直接检查当前生产配置,不保留已删除的历史 edge/Compose 副本。 + +### PR-3: second registered adapter + +| adapter | adapterConfig | collection endpoint | implementation | +| --- | --- | --- | --- | +| umami | websiteId UUID | script.js / api/send | pinned manual SDK, unchanged | +| datafinder | appId: integer 1..2147483647; optional product: lowercase name, default skillhub | POST list only | direct keepalive fetch; no official SDK | + +DataFinder 是配置化接入,不加载官方 SDK。事件名保留动作 ID,params 是仅含原生 event 的 JSON 字符串。 +header.custom 只含 product 和 module。module 由 bootstrap 提供通用 currentPageType getter,经 loader/controller/factory 透传, +发送时读取匹配路由的 fullPath 模板,不用具体 URL、params、query 或 hash;未匹配为 other。采集 chunk 不提前导入 router。 +匿名 UUID 用于 user_unique_id/web_id,localStorage 键为 skillhub.analytics.datafinder.browser-id;存储不可用则当页内存回退。 +不绑定登录账号。部署方需审核浏览器 UV 标识的隐私影响,denied 时不运行。App Key 和其他额外字段拒绝。 +每次一条事件、无批量等待、事件持久化或重试;发送与响应解析共用三秒上限。接收成功需 e=0、sc=1,不只 HTTP 200。 +代理仅开放 POST /_skillhub-analytics/list,沿用 TLS 验证和敏感头清除,保留浏览器 User-Agent。 +通用配置/控制器不认识上述厂商字段,其他 adapter 可忽略通用页面上下文。 + +## Risks and verification + +重点验证默认零 SDK/采集请求、错误配置降级、页面启动不等统计 SDK、DNS失败不阻断Web、stop/init竞争、适配器字段清洗、根/子路径、100M原业务上传、登录/下载及配置回滚。固定 SDK 升级须验证真实发送顺序;不能将静态配置或模拟API测试描述为真实业务E2E。正式测试矩阵在实现和独立Review收敛后生成。 diff --git a/openspec/changes/add-configurable-frontend-analytics/proposal.md b/openspec/changes/add-configurable-frontend-analytics/proposal.md new file mode 100644 index 000000000..6f276a7db --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/proposal.md @@ -0,0 +1,36 @@ +## Why + +Issue #989 / PR #1010 已提供被动动作标签、通用 helper 和独立平台适配示例,但部署方仍需修改 JS、提供文件和手工接入 Nginx。维护者确认新的交付目标:部署只填配置、尽量一次启动、文档可独立复现,同时控制对原业务的影响。此目标是原评审之后新增的正式产品能力,不将其描述为原贡献者返修遗漏。 + +## 分两次交付 + +- PR-1(#1010):完成被动标签、厂商中立 helper、示例 adapter、edge 上传上限与默认 Web 上游修正,以及可独立照做的中文接入指南。示例仍由部署方手工接入,不增加产品运行时开关。 +- PR-2(后续 PR):交付下述配置化产品能力,包括 `web/src/features/analytics` 单一产品源码、runtime-config 可选加载、同源代理路由及 Docker/Compose/Helm 配置。Umami 是首个注册 adapter,通用配置和加载入口不绑定平台。 +- 两个 PR 分别合并。Issue #989 保留到 PR-2 完成并验证后才关闭;PR-1 不自动关闭该 Issue。 + +## What Changes + +以下产品加载和部署变更由 PR-2 交付;PR-1 的稳定接口与验收边界见 design 和 specs。 + +- 保留原作者的被动标签及协议,增加默认关闭的通用可选加载入口。 +- 复用现有 runtime-config;采集参数通过部署 JSON 配置提供,平台参数放在 adapterConfig,不编辑源码。 +- 模块随 Web 发布,平台适配器按需加载;第一版以 Umami 作为一个注册实现,不把厂商 SDK 或字段耦合进业务页面。 +- 现有 Web Nginx 只增加独立的精确采集路由,不再依赖 HTML 注入或额外 Nginx。统计上游地址不公开给浏览器,DNS 仅请求时解析。 +- Compose 与 Helm 透传配置,提供可选自托管 Compose 组合;必要平台信息仍明确由部署方提供。 +- 增补关闭、故障、子路径、上传及登录回归与详细部署文档。 + +## Capabilities + +### New Capabilities + +- `configurable-frontend-analytics`: 配置化、厂商中立的可选采集加载与部署接入。 + +### Modified Capabilities + +无数据库/API能力变更。原被动动作类别及原业务语义不变。 + +## Impact + +影响按阶段区分:PR-1 仅涉及被动标签契约、示例、测试和接入文档;以下产品部署影响属于 PR-2。影响 Web 可选模块、运行时配置、容器启动、Compose/Helm、部署测试和文档。复用镜像已有 BusyBox awk 校验有界 JSON,使用 base64 将 JSON 安全传入现有 runtime-config,避免 envsubst 拼接任意配置,不新增 jq 或其他镜像依赖。默认部署不启动统计服务、不加载采集模块、不外发事件。 + +范围外:后台采集配置页、SkillHub 分析数据库、通用远程代码插件平台、用户级同意弹窗、可靠消息/重试队列、服务端业务成功事件、生产环境部署和自动合并。统计平台自己的数据库属于可选外部服务,不修改 SkillHub schema。 diff --git a/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md b/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md new file mode 100644 index 000000000..11a6d860a --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md @@ -0,0 +1,193 @@ +## ADDED Requirements + +### Requirement: Preserve passive business semantics +Delivered by: PR-1 (passive markers and examples); PR-2 (product integration regression). + +The system SHALL preserve existing static action categories and business interactions without introducing analytics dependencies into business components. + +#### Scenario: Existing business operation +Delivered by: PR-1 (marker/helper assertions); PR-2 (full business regression). + +- **WHEN** a user logs in, publishes, uploads, downloads or navigates with analytics disabled or unavailable +- **THEN** existing permissions, requests, keyboard behavior and business outcomes remain unchanged + +### Requirement: Deploy using configuration only +Delivered by: PR-2. + +The system SHALL deliver optional analytics modules with the Web artifact and accept deployment configuration without source edits or hand-written Nginx rules. + +#### Scenario: Connect an existing supported platform +Delivered by: PR-2. + +- **WHEN** the operator supplies valid analytics JSON, a supported adapter configuration and internal upstream origin +- **THEN** the Web startup generates safe runtime configuration and the required isolated proxy routes without an additional Nginx service + +### Requirement: Default closed and fail isolated +Delivered by: PR-2 (product loader); PR-1 examples SHALL remain opt-in and inert by default. + +The system SHALL default to analytics disabled and consent denied, and SHALL isolate invalid configuration and runtime analytics failures from Web and business availability. + +#### Scenario: Disabled or denied collection +Delivered by: PR-2. + +- **WHEN** configuration is omitted, enabled is false, or consent is denied +- **THEN** the browser does not load analytics modules or SDKs and does not send analytics requests + +#### Scenario: Disabled example +Delivered by: PR-1. + +- **WHEN** the deployment-owned entry remains enabled:false or consent is not approved, or no entry is injected +- **THEN** no observer is installed, no tracker is loaded and no intent is sent; importing the helper alone has no collection side effects + +#### Scenario: Invalid configuration +Delivered by: PR-2. + +- **WHEN** analytics JSON, adapter or upstream is invalid +- **THEN** only analytics is disabled with a diagnostic that does not echo raw values, while existing Web configuration remains usable + +#### Scenario: Statistics service unavailable +Delivered by: PR-2 (including request-time DNS); PR-1 covers an unavailable example tracker without making a product DNS-startup guarantee. + +- **WHEN** the upstream is unreachable or its hostname cannot resolve +- **THEN** the Web still starts and business remains usable without waiting for the statistics service + +### Requirement: Vendor-neutral module contract +Delivered by: PR-1 (helper and example adapter); PR-2 (registered product factories and configuration). + +The system SHALL separate generic configuration, rules and lifecycle from adapter-specific parameters and SDK calls. PR-2 SHALL migrate the helper to one product source under web/src/features/analytics and retain every PR-1 test assertion rather than maintaining duplicate implementations. + +#### Scenario: Registered adapter +Delivered by: PR-2. + +- **WHEN** a supported adapter is selected +- **THEN** its factory validates its own adapterConfig and exposes send and stop while generic code does not depend on vendor fields + +#### Scenario: Stable helper and adapter boundary +Delivered by: PR-1; adopted unchanged by PR-2. + +- **WHEN** a deployment connects a collector to passive markers +- **THEN** IDs follow `^[a-z]+(?:\.[a-z]+){2,}$` and the manifest lifecycle; observeSkillhubIntents({ root, rules, onIntent }) returns stop; frozen DEFAULT_RULES contains the original 12 rules; resolveRules(rules) returns active and skipped entries with id and reason +- **AND** only click, submit and card rule types are supported with their documented host restrictions; invalid or unknown entries are skipped without affecting the page +- **AND** the intent is { id, event }, describes native intent rather than business success, and evolves additive-only within its version +- **AND** adapter send(intent) returns Promise and never rejects, stop prevents new dispatch, and vendor parameters belong only to the adapter + +#### Scenario: Configuration maps to the existing contract +Delivered by: PR-2. + +- **WHEN** enabled, adapter, consent, rules and adapterConfig are supplied +- **THEN** enabled and consent gate optional loading and observation, adapter selects a static registered factory, rules use the existing resolver (omitted means defaults, explicit object replaces them), and adapterConfig is validated only by the selected adapter + +### Requirement: Safe event lifecycle +Delivered by: PR-1 (example lifecycle); PR-2 (product controller and asynchronous module loading). + +The system SHALL preserve finite configured rules, field allowlists, duplicate prevention, bounded loading waits and failure isolation. + +#### Scenario: Stop races with asynchronous initialization +Delivered by: PR-1 (SDK wait/dispatch); PR-2 (module/controller races as well). + +- **WHEN** collection is stopped while a module or SDK is loading +- **THEN** late initialization does not restart collection and no new request dispatches after stop returns, excluding requests already started + +#### Scenario: Repeated initialization and rule changes +Delivered by: PR-1; preserved by PR-2. + +- **WHEN** the same root is initialized repeatedly before stop +- **THEN** it returns the same stop and retains the first configuration and callback without duplicate events +- **AND** stop is idempotent, configuration changes require stop then reinitialization, and an old stop does not remove a new installation + +### Requirement: Compatible deployment paths +Delivered by: PR-2 (normal deployment); PR-1 (opt-in edge example and mapping guide). + +The system SHALL support root and supported sub-path deployments while preserving existing API, auth, upload, cache and security behavior. + +#### Scenario: Large valid upload through normal Web +Delivered by: PR-2. + +- **WHEN** a user uploads a business-valid package larger than 1 MiB through the standard Web entry with analytics enabled +- **THEN** optional analytics routes do not reduce the existing upload limit or alter the upload request + +#### Scenario: Example edge preserves upload boundaries +Delivered by: PR-1. + +- **WHEN** an operator uses the opt-in edge in front of the Web +- **THEN** client_max_body_size 100M applies at server level and is not lowered on application proxy locations; a 5 MiB API/upload probe reaches the upstream rather than returning edge 413, while a body larger than 100 MiB is rejected with 413 +- **AND** any tighter collector-only limit does not change application uploads + +#### Scenario: Example defaults match release Web +Delivered by: PR-1. + +- **WHEN** SKILLHUB_WEB_UPSTREAM is omitted from the example Compose configuration +- **THEN** the edge targets host.docker.internal:80, matching the release WEB_PORT default of 80, and its extra_hosts provides host.docker.internal:host-gateway on native Linux +- **AND** the guide explains overrides and container-to-host reachability, while Docker Desktop/OrbStack already provide the hostname + +#### Scenario: Optional endpoint boundary +Delivered by: PR-1 (example routes); PR-2 (generated product routes). + +- **WHEN** a browser requests an unregistered analytics route +- **THEN** the route is rejected and does not expose the statistics platform administration or arbitrary upstream paths + +### Requirement: Reproducible deployment documentation +Delivered by: PR-1 (manual example guide); PR-2 (configuration-only production guide). + +The system SHALL document configuration fields, existing-platform and optional self-hosted deployment, required initialization, verification, troubleshooting, upgrade, disable and rollback. + +#### Scenario: Operator follows the guide +Delivered by: PR-2. + +- **WHEN** an operator deploys the supported integration using the published guide +- **THEN** no JS or Nginx source file edits are required, all manual platform prerequisites are explicit, and enabled, disabled and failure behavior can be independently verified + +#### Scenario: Operator follows the PR-1 Chinese example guide +Delivered by: PR-1. + +- **WHEN** an operator follows the Chinese integration guide without reading implementation source +- **THEN** the guide explicitly identifies the required entry configuration edits and platform initialization, explains both a custom send/stop adapter and self-hosted Umami, and provides copyable deployment and acceptance commands +- **AND** it explains the 12 defaults, finite rules and skipped diagnostics, upstream/host-gateway, supported sub-path mapping, CSP, actual Network payload and platform event checks, zero collection when disabled, troubleshooting, manifest/ID upgrades and shutdown +- **AND** best-effort/no retry, the 20-intent loading cap, pinned Umami version and remaining transport metadata are disclosed, with configuration-only deployment clearly deferred to PR-2 + +#### Scenario: Separate merges keep the issue open +Delivered by: PR-1 (scope declaration); PR-2 (completion gate). + +- **WHEN** PR-1 merges independently +- **THEN** Issue #989 remains open until PR-2 configuration-only deployment is complete and verified + +### Requirement: Preserve independent analytics deployment identity +Delivered by: PR-2. + +The system SHALL persist the analytics project and network alongside its independent credentials and use this identity consistently for analytics lifecycle commands and local Web attachment. + +#### Scenario: Clean and resume a custom analytics deployment + +- **WHEN** an operator initializes a custom analytics project, cleans SkillHub runtime files, and later manages or reconnects analytics +- **THEN** the original analytics project, network, credentials and data remain selected, regardless of outer configuration or shell variables +- **AND** normal stop operations do not delete analytics volumes + +#### Scenario: Legacy identity cannot be established + +- **WHEN** existing analytics configuration lacks required identity and its original SkillHub configuration is missing +- **THEN** runtime commands fail with restoration guidance before generating defaults or invoking Docker, including repeated attempts +- **AND** the system does not guess a default project or regenerate analytics credentials + +### Requirement: Keep installed-release verification isolated +Delivered by: PR-2. + +The analytics Helm verification script SHALL preserve existing release values and secrets while changing only its intended analytics settings. + +#### Scenario: Verify a release with generated secrets + +- **WHEN** the operator supplies installation values that omit secrets originally generated by the chart +- **THEN** verification reuses the existing release values and secret lookup instead of injecting deterministic test credentials +- **AND** event storage and Web/server rollout assertions remain enforced + +#### Scenario: First analytics upgrade after generated-secret installation +- **GIVEN** a release installed with coherent secret rendering whose application credentials were generated by the chart +- **WHEN** the operator changes only analytics settings for the first time +- **THEN** the Secret resource and deployment checksums use one coherent secret rendering and existing credentials remain unchanged +- **AND** only Web rolls; Server and enabled GitHub Sync are not restarted due to newly generated checksum inputs +- **AND** a genuine configured credential change still changes the relevant deployment checksum + +#### Scenario: Upgrade a legacy generated-secret release +- **GIVEN** an existing release whose old chart generated inconsistent application Secret checksum inputs +- **WHEN** the operator first upgrades to the coherent rendering fix +- **THEN** existing application credentials remain unchanged +- **AND** a one-time backend rollout to correct the old checksum is documented rather than hidden by a preliminary no-op upgrade diff --git a/openspec/changes/add-configurable-frontend-analytics/tasks.md b/openspec/changes/add-configurable-frontend-analytics/tasks.md new file mode 100644 index 000000000..844d432e8 --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/tasks.md @@ -0,0 +1,60 @@ +## 1. Scope and integration +- [x] 1.1 Confirm maintainer authorization, preserve contributor history and resolve latest-main translation conflicts. +- [x] 1.2 Define runtime configuration, adapter boundary, failure behavior and production deployment scope. + +## 2. PR-1 (#1010) +- [x] 2.1 Verify passive markers, frozen published IDs and manifest drift against the current base. +- [x] 2.2 Verify the vendor-neutral helper, frozen defaults, finite rules, skipped diagnostics, repeat-init/stop and example adapter assertions. +- [x] 2.3 Fix and regression-test the example edge's server-level 100M application upload limit, release Web upstream default and native Linux host-gateway mapping. +- [x] 2.4 Provide an independently followable Chinese guide for existing-platform adapters and the local Umami demo, including configuration, deployment, acceptance, troubleshooting, upgrade and shutdown; link the English contract. +- [x] 2.5 Align the two-PR interfaces and manually check OpenSpec structure against add-skill-suites (CLI unavailable; not CLI validation). +- [x] 2.6 Run focused/full tests, typecheck, lint, build, manifest, Compose rendering and diff checks; repeat built-frontend/real-collector browser acceptance and the guide walkthrough, including upload boundaries. +- [ ] 2.7 Independent parent review and publication; keep Issue #989 open. + +## 3. PR-2 (follow-up): optional frontend capability +- [x] 3.1 Move helper/adapter to a single product source under web/src/features/analytics and preserve all PR-1 assertions. +- [x] 3.2 Add default-closed runtime loader and vendor-neutral controller/registry; register Umami as the first adapter. +- [x] 3.3 Cover consent, validation, duplicate init, async stop races and adapter failures. +- [ ] 3.4 Validate this OpenSpec change with the CLI when available and bind the follow-up PR/task. + +## 4. PR-2 (follow-up): deployment +- [x] 4.1 Safely serialize and validate configuration without breaking existing runtime config. +- [x] 4.2 Add isolated proxy snippets with request-time DNS, certificate validation and exact endpoints. +- [x] 4.3 Wire Docker/Compose/Helm defaults and reserved sub-path validation. Helm keeps analytics in a Web-only ConfigMap. Template checks executed with helm 3.19.0: `helm lint --strict` and `configuration-contracts.sh`, which renders before/after values and asserts that analytics-only changes roll only Web while backend changes still roll the server. A real-cluster Helm install/upgrade with analytics enabled has not been run. +- [x] 4.4 Provide optional persistent self-hosted Compose combination without another Nginx. + +## 5. PR-2 (follow-up): documentation +- [x] 5.1 Replace source-editing quickstart with configuration-only production paths for supported platforms; distinguish custom adapter development. +- [x] 5.2 Document fields, platform prerequisites, initialization, verification, troubleshooting, upgrade and shutdown. +- [x] 5.3 Link deployment entry points and document adapter extension and actual privacy boundaries. + +## 6. PR-2 (follow-up): quality gates +- [ ] 6.1 Consolidated independent implementation review, fixes and final base checkpoint. +- [ ] 6.2 Reviewed requirement/scenario/test matrix and targeted source/deployment checks. (Worker matrix, local checks and Helm template checks complete; maintainer verified the real image, backend, Umami, Postgres and browser path. Real-cluster Helm verification and independent review pending.) +- [ ] 6.3 Exact-SHA release Compose S3/auth smoke and Chromium normal/changed/failure scenarios, including default zero module/SDK/request and root/sub-path upload/login/download/CSP/cache compatibility. (Local browser/real-collector and HTTP proxy checks passed; full backend S3/auth smoke remains pending.) +- [ ] 6.4 CI/privacy/readiness, one validation report and retained preview for human acceptance. +- [ ] 6.5 Close Issue #989 only after configuration-only deployment is complete and verified, not when PR-1 merges. + +## 7. PR-3: DataFinder adapter +- [x] 7.1 Register DataFinder as a lazy second adapter; validate appId/product without SDK or browser secrets. +- [x] 7.2 Pass optional vendor-neutral page context from the router; send templates and anonymous browser UUID only, with storage fallback. +- [x] 7.3 Generate one POST-only list proxy, preserving browser User-Agent and existing proxy isolation; cover validation, payload, deadlines and stop races. +- [x] 7.4 Verify unit/build/script gates, Compose rendering and local mock-collector browser integration at root and sub-path; document setup and privacy. +- [x] 7.5 Run full Helm lint/configuration-contracts with the declared PostgreSQL/Redis subcharts available (helm 3.19.0 container; template checks only, no real cluster). +- [ ] 7.6 Independent parent review, real-platform acceptance and publication. + +## 8. Maintainer one-click follow-up +- [x] 8.1 Split Umami and SkillHub Compose projects, add shared external network to Web/Umami only, and implement two-step `runtime.sh` lifecycle with persisted 0600 secrets. `clean` retains independent analytics credentials; no volume deletion. +- [x] 8.2 Add independent Kubernetes Umami/Postgres unit with Secret, PVC, services and port-forward management path; chart remains optional and unchanged. +- [x] 8.3 Document script-new, script-existing and Helm paths, UUID acquisition, upgrades, shutdown and retention; replace stale PR-1 edge fixtures with current deployment assertions. +- [x] 8.4 Verify isolated Compose from an empty runtime directory, repeat secrets/data, Web recreation/network, analytics stop/restart, existing-platform enable, disable and business routes. Evidence: `skillhub-1037-oneclick/evidence/compose-smoke.log`. +- [x] 8.5 Verify real kind: separate stats unit and SkillHub Helm release, Umami DB event increase via Web proxy, analytics-only Helm upgrade rolls Web while server Pod UID remains unchanged. Evidence: `skillhub-1037-oneclick/evidence/kind-smoke.log`. +- [ ] 8.6 Independent maintainer review and PR acceptance remain external; do not close Issue #989 here. + +## 9. Maintainer deployment guard fixes +- [x] 9.1 Keep installed-release smoke values isolated: reuse release values, remove implicit test credentials, and cover successful and rejected event/rollout paths plus incomplete-fix mutations. +- [x] 9.2 Persist independent project/network identity, migrate only from known original configuration, and reject ambiguous legacy state before defaults are generated; cover custom/default multi-instance lifecycle and secret preservation. +- [x] 9.3 Clarify file/image version pinning, the three operator paths, independent configuration ownership, cleanup/upgrade boundaries and smoke upgrade side effects; remove duplicate operational descriptions from the contract. +- [x] 9.4 Complete one consolidated independent review and the focused source/deployment validation matrix for these fixes. Script/model checks, Helm lint/render contracts and an isolated real two-instance Umami/PostgreSQL lifecycle passed; no real-cluster Helm upgrade or Secret lookup was repeated for these fixes. +- [x] 9.5 Build the affected documentation and verify the rendered operator instructions; keep real-cluster and publication status explicit. VitePress build and Chromium 145 checks passed for Chinese/English navigation and 390px layout. Publication and full PR acceptance remain separate from these focused documentation checks. +- [x] 9.6 Reuse one application Secret rendering for its resource and all deployment checksums without mutating user values; preserve explicit-secret checksum bytes and genuine rotation, and document the legacy first-upgrade correction. Real Helm template regression rejects the original chart and passes the fix; real-cluster fresh/legacy upgrade results are retained in the separate final validation record. diff --git a/openspec/changes/add-dsh-native-plugin/README.md b/openspec/changes/add-dsh-native-plugin/README.md index 627c0fdad..b3cc9a4a7 100644 --- a/openspec/changes/add-dsh-native-plugin/README.md +++ b/openspec/changes/add-dsh-native-plugin/README.md @@ -56,8 +56,14 @@ 首期功能范围以本目录规范为准。当前研发基线为 DSH 0.2.0-rc.2,包名为 `@astron-team/skillhub-dsh`;正式平台支持与发布决定按验收门禁执行。过程日志、截图批次和临时研究材料在仓库外留档,仓库仅保留正式规格、验证摘要及可复用测试。 -## 后续增量:安装级技能启停(设计阶段) +## 后续增量:安装级技能启停 -[安装级启停设计](skill-toggle-design.md)定义受管理安装的停用、恢复、更新与卸载联动;正式验收见消费规范,执行拆分见 tasks.md 第44节。此增量尚未实现,不属于已发布0.5.0的能力。 +[安装级启停设计](skill-toggle-design.md)定义受管理安装的停用、恢复、更新与卸载联动;正式验收见消费规范,执行拆分见 tasks.md 第44节。此增量已随 PR #1025 合并,npm 发布与原生平台发布验收另行安排。 - [使用优先页面与二级安装管理](skill-use-first-design.md):日常动作、只读安装选择及精确管理边界。 + +- [已安装技能统一检查更新](installed-update-check-design.md):用户主动检查、技能级汇总筛选与逐位置更新,第46节跟踪验收。 + +## 后续增量 + +- [多来源保存与当前来源更新检查](registry-switching-design.md):范围、设计与本地验证结果;版本发布另行安排。 diff --git a/openspec/changes/add-dsh-native-plugin/change-preview-design.md b/openspec/changes/add-dsh-native-plugin/change-preview-design.md new file mode 100644 index 000000000..d833fafab --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/change-preview-design.md @@ -0,0 +1,100 @@ +# 升级变化预览与本地文件撤销 + +## 1. 范围与已确认交互 + +本设计承接已确认的顺序原型,补充正式实现契约,不表示代码或接口已交付。 + +1. 详情页按精确安装位置进行只读本地检查,发现修改时在对应安装管理位置显示轻量「本地修改 · N」查看入口,不占正文顶部提示行,也不要求先点击升级。日常「使用技能」以紧凑按钮放在右侧操作区,不在标题下独占一行。 +2. 点击升级先重新检查本地状态。有修改时先进入「处理本地修改」;可逐文件/全部撤销,或选择「备份修改并继续」。这一阶段不展示发布版本差异。 +3. 没有修改,或受确认的修改快照已经可靠备份后,进入独立「版本变化」预览。只展示原始旧发布内容到目标发布内容的变化、可用的作者更新说明。 +4. 「确认升级」才授权技能替换;「备份修改并继续」只创建本地备份和后续计划,不修改技能或库存。取消版本预览不删除已经创建的备份。 +5. 独立「查看本地修改」入口只负责查看/撤销,返回详情,不迫使用户进入升级。文件内容区桌面最大 320px、窄屏最大 240px,独立双向滚动、可键盘聚焦,文件头在滚动区外。 + +不提供两个比较对象并列的 Tab,不重复设置「升级」和「确认升级」主动作。不加入逐文件择版合并、版本降级、升级后版本回滚、自动升级或本地文件上传服务端。 + +## 2. 比较对象与可信基线 + +| 比较 | 左侧 | 右侧 | +| --- | --- | --- | +| 本地修改 | 当前安装版本的原始发布内容 | 当前实际安装文件 | +| 版本变化 | 当前安装版本的原始发布内容 | 用户选定的精确目标发布内容 | + +基线绑定完整来源身份、namespace/slug、version/versionId、安装元数据中的发布指纹及目标路径;不可仅凭名称或版本字符串取得另一来源同名内容。 + +原始文件从现有精确版本 resolve 与归档下载链路取得,复用归档路径校验和指纹算法;归档计算指纹必须与已记录的发布指纹一致。元数据仅有哈希,不可用哈希伪造原始正文。缓存命中仍按该身份和指纹验证,不以当前源 latest 补齐旧基线。下架、权限拒绝、超时、指纹不一致分别保留明确失败,不报告为「没有修改」。 + +本地读取先核验可信安装归属、规范化路径、普通文件树及上下文。符号链接、路径重定向、未知归属、共享套件目标或不完整记录不能进入写操作;读取错误不是文件删除。只允许 Host 在既有权限内读取,不把任意绝对路径交给 Client。 + +启停入口按可信安装状态逻辑映射:停用安装的实际 `SKILL.md.disabled` 对应发布 `SKILL.md`;启停转换本身不是用户修改。撤销入口内容时保留当前启停状态,不恢复出第二个入口;存在冲突入口时拒绝。`.skillhub` 中安装元数据与内部状态不是可撤销的用户内容;作者的 `.skillhub/examples.json` 仍按发布内容比较。库存继续保持 v2,不迁移或新增旧消费者无法解析的库存字段。 + +## 3. 读取与展示上限 + +本地修改清单可直接比较可信元数据的原发布哈希与实际文件哈希,不要求预先下载原文;原文不可用仍可报告已检测到修改,但不能提供正文对比或撤销。Host 返回文件增删改清单和真实计数,正文按用户展开文件后按需读取;不一次把完整归档或本地文件树传到 Client。每个差异结果明确 `可展示文本 / 二进制 / 超出限制 / 不可读取`,二进制仅展示类型和可验证的大小变化。超过文本字节、行数或计算预算时返回截断/不可预览状态,不能显示为无变化。正文每侧最多256KiB,最多2000行,并使用有界计算预算,不能对任意大小输入运行无界二次方比较;实现以共享具名常量统一定义并测试,Client 不用截断后的内容进行写授权。 + +行内差异先展示必要上下文;长行在代码区域横向滚动,页面不横向溢出。没有作者更新说明时省略该块,不自动生成解释。刷新、失败重试和切换来源/会话/安装位置均保留明确语义,迟到结果不覆盖新上下文。 + +## 4. 撤销本地文件修改 + +撤销只针对当前安装版本,不改变安装版本、release identity、installedAt、归属或其他安装位置。 + +- 已修改发布文件:恢复该版本的原始字节。 +- 本地新增文件:确认文案明确删除该新增文件。 +- 本地删除的发布文件:恢复原始字节。 +- 全部撤销:预检固定本次全部增删改路径,确认显示文件数量;不会把执行前新增的文件隐含加入。 + +预检生成绑定来源、会话、安装位置、当前版本、完整文件快照、选中文件集合与基线指纹的短期计划。确认执行时在既有目标锁和库存 CAS 纪律下重新核验,任何相关文件、元数据、路径、启停或归属变化均使计划失效;不得只看单文件 mtime。Client 不能提交任意正文或文件系统路径来替代 Host 已确认基线。 + +撤销执行扩展专用文件重置操作日志与恢复分支,复用既有目标锁、操作归属和事务纪律,先暂存全部变更再提交;不能把文件撤销伪装为 `installSkill`,因为其发布指纹与安装恢复假设不适用于局部文件重置。库存及安装元数据保持原发布基线不变,不能把当前本地修改登记为新的发布基线。全部撤销不能在中途错误时报告完整成功。中断或失败报告实际完成/恢复/待核验状态,重启不自动重放用户写意图。用户取消不写入。 + +## 5. 备份后确认覆盖升级 + +这是对旧「local-changes 一律阻止」的受限扩展,不是公开 force 开关。旧升级调用与未携带已确认快照的路径继续拒绝本地漂移。 + +### 时间与授权 + +1. 「备份修改并继续」在锁内取得所选安装的完整当前文件快照,核验原始基线及目标身份,创建并验证持久备份;技能目录和库存保持不变。 +2. 返回不透明备份/预览引用,绑定精确安装、快照哈希、原始发布与候选发布身份、来源和会话。该引用不是执行升级的用户确认。 +3. 展示发布版本变化;只有用户点击「确认升级」才生成/执行对应升级确认计划。 +4. 提交前再次在目标锁下核验该本地快照、库存、启停、归属、权限、目标发布指纹与备份完整性。任一变化均拒绝并回到处理步骤,不复用旧授权或悄悄扩大覆盖范围。 + +只对已明确确认、完整备份的同一快照执行覆盖。复用 `expectedTargetFiles` 传递用户确认的完整实际文件快照,并保持 `allowTargetDrift:false` 执行锁内比较;修改快照是本次授权的执行前状态,不是库存或安装元数据的新发布基线。不得全局修改 `allowTargetDrift` 默认值或绕过原始来源冲突、共享目标、未知版本顺序及相同版本内容漂移保护。旧 CLI 正常语义不变。 + +### 备份与缓存分离 + +备份位于由可信 `stateDirectory` 推导的同级 `backups` 目录(`dirname(stateDirectory)/backups`),与技能目录和发布缓存分离,以 Host 生成的 UUID 标识;目录名不可由 Client 指定,不能被技能发现机制加载。备份包含完整实际内容、必要启停映射及来源/版本/文件哈希清单,不存凭证;发布基线缓存另存,不冒充用户修改备份。 + +备份写入临时目录,逐文件校验后原子提交清单;成功回执之前必须证明内容和清单一致。权限拒绝、磁盘不足、复制中断或最终校验失败均停留在处理页,不进入版本预览、不覆盖文件。失败的半成品不当成成功备份。 + +单份备份上限100MiB,与既有包下载上限一致;单Host持久备份总量上限1GiB、最多20份,任一达到上限则拒绝新增备份。创建前与最终提交均在备份存储锁下复核限额,防止并发突破总量;沿用归档单文件/路径等适用限制并统一具名常量。持久备份不在升级成功、取消预览、缓存淘汰或插件重启时自动删除。第一期不设置自动过期清理、不提供版本回滚;容量不足时明确拒绝新备份,不能以删除既有用户备份腾空间。界面给出可定位的备份记录或路径用于用户保留,不把备份称为已有回滚能力。显式备份清理入口不属于本期。 + +原始发布缓存可以按完整性校验后的独立缓存策略淘汰;淘汰后需重新取得基线,不能退化成不核验写入。 + +## 6. 层次与接口边界 + +- Registry:复用已有精确版本解析、文件/ZIP 下载和更新说明,不增加安装、diff 或本地文件上传公共 HTTP API。 +- 共享本地核心:持有原始内容校验、实际文件快照、差异清单、撤销事务、持久备份与受确认快照的升级执行。 +- DSH Host 私有 Remote:提供按安装引用的只读清单、按文件受限正文差异、撤销预检与确认执行、备份继续和升级预览能力;保留 Agent-scoped 权限、sourceRevision/itemKey 和不透明计划引用。方法名及生成类型由实现契约统一产生,不在 Client 另造第二套文件操作。 +- Client:顺序视图、懒加载差异、明确确认和状态展示,不持有任意路径写能力,不将只读检查结果当执行授权。 + +其他来源安装可按原有可信来源获取当前版本基线并进行本地查看/撤销;更新预览和升级仅允许当前来源。切换来源后尚未受理的升级流程失效;已受理事务仍以固定来源完成或恢复,不能路由到新来源。SaaS 支持状态必须单独联验,不因开源源码有接口即宣称现网支持。 + +## 7. 验收矩阵 + +1. 详情提前提示本地修改;只查看可直接返回;升级顺序符合 §1,无双 Tab 混淆。 +2. 精确版本的增/删/改、空文件、Unicode、长路径、二进制、超长文本、异常编码与截断状态。 +3. 无基线、下载拒绝、指纹错、读取失败和离线缓存命中;所有未知均不假报无修改。 +4. 单文件撤销、全部撤销及取消,新增删除确认明确,版本/归属/未选目标不变。 +5. 启用和停用安装撤销后状态不变,入口冲突、元数据与共享归属不能误写。 +6. 预检后文件变化、文件替换链接、并发 CLI、库存变化、切源和上下文变化使旧计划失效。 +7. 备份成功后取消不写技能且保留备份;备份失败不进入预览;确认升级前新修改要求重新处理。 +8. 正常无修改升级与旧 CLI 回归;备份授权不影响旧路径的漂移保护,库存保持 v2。 +9. 撤销和备份覆盖事务的代表性中断、恢复及重复请求;不能把进程内错误模拟当作全部平台进程强杀验收。 +10. 真实 DSH 1440/390、明暗主题、键盘焦点、每文件独立限高滚动;本地修改不上传服务器。 + +## 8. 新旧消费者日志兼容 + +库存 v2 兼容不代表操作日志兼容。新增文件重置日志放在独立的新操作子目录,不把新kind或schema塞进旧OperationStore会当作损坏记录处理的既有目录。新Host启动时读取并识别新旧两类待恢复操作;任一未核验的pending新事务均阻止本Host的新写入,不得仅因旧listPending为空就开放安装、升级、卸载或启停。恢复只接受同一精确目标与有证明的before/after快照。 + +旧CLI仍通过现有目标锁/库存锁参与互斥,不能更换成仅新Host可见的另一套目标锁。旧DSH不承担新文件重置日志自动恢复,但不得把它作为损坏旧日志无声清除。向旧插件回退存在新pending日志时,不宣称兼容恢复完成;须先用支持该事务的版本完成核验。测试单独覆盖旧库存读取、新日志隔离与pending阻写,不以拒写替代正常旧CLI查询/管理兼容。 + +正式支持平台和真实模型/宿主验收分别记录,编译与原型截图不替代生产功能验收。 diff --git a/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md b/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md new file mode 100644 index 000000000..d9c103ebb --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/installed-update-check-design.md @@ -0,0 +1,32 @@ +# 已安装技能统一检查更新 + +## 范围 +用户主动检查当前来源、当前工作区可见的受管理安装。无进入页面自动扫描、后台轮询、批量执行、自动覆盖或新增Registry/SaaS协议。日常仍按技能聚合;实际检查/更新以精确itemKey为单位。 + +## 交互 +已安装页提供检查更新与进度n/m;结果渐进呈现,不逐项弹通知,不阻断浏览。汇总可更新技能数与检查失败项,支持全部/可更新/检查失败筛选和失败重试,关键词可叠加。已最新无卡片冗余文字;卡片右上角最多展示两个状态标签:安装状态仅在已停用或待核验时展示;更新标签按检查失败、需处理、可更新优先级展示一项,单位置可更新保留目标版本,多位置统一显示“可更新”,其余细节进入详情查看。正常已安装不重复标记。卡片头部组合头像、名称、命名空间与右侧状态;无状态时不预留独立标签行。保持紧凑间距和同排按钮对齐,窄屏下状态与长名称不能重叠。点击卡片(支持键盘)进入现有详情右侧,移除重复的管理安装、详情与查看更新按钮;仅可用技能保留使用技能主动作,点击使用不触发详情跳转。返回列表恢复焦点、滚动和有效筛选。位置结果独立:同技能只计一个可更新技能,但不同位置版本与确认分开。blocked显示需处理,不算可执行更新;unavailable/错误算检查失败,不伪报已最新。 + +## 技术边界 +统一检查使用DSH私有只读检查轮次;保留原单项checkUpdate和精确位置prepareUpgrade事务,不另造批量写入API。客户端有界并发只读检查;重入禁用,取消/离开/切来源/会话/工作区时中止在途并丢弃迟到结果。结果仅适用于同一来源会话及同一安装引用。库存变更、更新/卸载/启停导致旧itemKey失效时清除对应结果。详情返回保留仍有效结果与筛选;详情更新执行始终重新核验,不把检查结果当授权。错误/空库存/不完整库存明确表达,不能全局显示全部已最新。 + +## 验收 +覆盖空安装、单份、同技能多位置不同结果、全部最新、可升级、本地修改blocked、网络失败重试、混合结果、扫描中切换与重复点击、迟到结果、安装变化后结果失效。确认没有任何扫描触发写操作。真实DSH检查进度与汇总筛选、详情返回、逐位置升级确认取消、深浅色与窄屏,原安装不变;本轮不需模型调用。 + +## 工具栏展示约定 +搜索和检查操作在同一响应式工具栏,筛选采用与页面一致的紧凑按钮组(全部/可更新/检查失败),不使用平台原生下拉菜单。筛选和单行摘要放在第二行;未检查仅显示技能数量,运行中明确进度,完成后仅显示有意义的更新与失败计数,零失败隐藏。不重复显示列表数量段落;窄屏允许自然换行,保留可见焦点和选中语义。 + +## 规模与资源边界 +每轮仅建立一次可信库存快照并索引目标,逐项只核验对应目标与库存文件版本;不能将全量库存读取/解析或全安装目录核验放入逐项循环。库存文件变更使本轮失效,重新检查建立新快照。快照建立前后需核验文件版本,避免跨变更读取。每轮同来源同技能共用远端resolve;不同安装位置仍独立判断版本结果。失败重试新建轮次,不永久缓存失败或版本。 + +轮次引用只由Host生成并绑定真实会话/工作区/来源/文件服务/运行时,客户端不提供路径。数量、生命周期和并发有界,结束、取消、过期和销毁释放资源;单个位置等待取消不误杀同技能其他位置共享请求。旧轮次结果不得流入新上下文,检查快照不能作为写入授权。 + +性能验收覆盖10/100/500处安装,分别记录库存读取、目标核验、远端请求次数和耗时;以读取次数和工作量接近线性为主要回归断言,不使用机器相关固定耗时阈值。真实DSH验证新私有协议、渐进结果、取消和原逐项更新确认。 + +## 已安装卡片的信息与展示来源 +已安装卡片采用紧凑横向布局:左侧头像,中间真实标题、一行简介及弱化的安装版本/命名空间,右侧必要状态与操作。桌面两列、窄屏一列,极窄卡片操作自然换行,不挤压标题。安装位置版本不同时展示多版本摘要,不把最新市场版本冒充本地版本。保留使用技能主操作与整卡详情入口;更多菜单仅提供管理安装和检查更新。管理安装进入既有详情右侧,检查更新复用只读轮次并限定当前技能的精确安装引用,多位置分别检查,不执行更新。菜单与使用操作不冒泡打开详情,关闭菜单恢复触发器焦点,进入详情后返回恢复卡片焦点。日常卡片不展示启停、卸载或检查可用性。最多两个状态标签,可更新使用柔和蓝色、已停用中性灰、失败/需处理琥珀色,明暗主题均可读且不只靠颜色表达。窄卡状态自然换行,不挤掉标题。无摘要时展示简短缺省,不编造用途。 + +列表展示信息从可信受管理安装位置的 SKILL.md 或 SKILL.md.disabled 读取,返回可选 displayName/summary。固定文件、普通文件、有界读取、拒绝符号链接和异常路径;YAML解析仅抽取有界字符串,不执行标签或内容。失败仅省略展示字段,不改变安装状态、完整性判定或来源。展示字段不进入安装引用、授权和更新快照。只在列表读取,保持一次库存读取,无逐卡网络详情请求、全目录摘要哈希或影响检查更新的快照热路径;会话、来源、根目录变化仍在返回前核验。 + +## 本地修改后续处理 + +统一检查保持只读,`blocked/local-changes` 不算可直接执行更新。用户进入详情后的顺序处理、文件撤销、验证备份及最终确认由 [change-preview-design.md](./change-preview-design.md) 定义;不能把检查结果本身作为覆盖授权。 diff --git a/openspec/changes/add-dsh-native-plugin/interface-design.md b/openspec/changes/add-dsh-native-plugin/interface-design.md index 0b3dd9fd3..d6803a6f9 100644 --- a/openspec/changes/add-dsh-native-plugin/interface-design.md +++ b/openspec/changes/add-dsh-native-plugin/interface-design.md @@ -356,7 +356,7 @@ accepted 不表示安装完成。工具立即返回 accepted 及 operationId, ### 选定版本升级 -保留 `checkUpdate` 与 `prepareUpgrade` 的原签名及“检查最新版”行为,新增 Agent-scoped `checkVersionUpdate(sourceRevision,itemKey,version,signal)` 和 `prepareVersionUpgrade(sourceRevision,itemKey,version,signal)`。两组接口共享当前库存重读、所选目标基线、版本比较、文件修改保护与确认计划;定向接口必须按指定 version 解析,响应版本不一致即拒绝,不使用 latest 替换不可用版本。执行仍只提交原 executePlan,无新文件写入接口或 SaaS 安装接口。 +保留 `checkUpdate` 与 `prepareUpgrade` 的原签名及“检查最新版”行为,新增 Agent-scoped `checkVersionUpdate(sourceRevision,itemKey,version,signal)` 和 `prepareVersionUpgrade(sourceRevision,itemKey,version,signal)`。两组接口共享当前库存重读、所选目标基线、版本比较、文件修改保护与确认计划;定向接口必须按指定 version 解析,响应版本不一致即拒绝,不使用 latest 替换不可用版本。原有无修改升级仍只提交原 executePlan,不增加 SaaS 安装接口。后续本地变化查看、文件撤销与备份授权覆盖的增量 Host 私有契约以 [change-preview-design.md](./change-preview-design.md) 为准;旧调用路径继续拒绝未确认的本地漂移。 详情按 source/session/itemKey/所选 version 隔离只读检查缓存,切换仅触发读取。较新且可核验的版本显示升级到该版本,相同版本不重复显示升级,旧版本或不可排序版本明确阻断。确认使用捕获的库存项与精确目标,不因后台库存刷新重建另一升级意图;升级完成使库存失效,旧 itemKey 的检查结果不会绑定到新记录。 diff --git a/openspec/changes/add-dsh-native-plugin/proposal.md b/openspec/changes/add-dsh-native-plugin/proposal.md index 7ac8e3936..baca22657 100644 --- a/openspec/changes/add-dsh-native-plugin/proposal.md +++ b/openspec/changes/add-dsh-native-plugin/proposal.md @@ -50,3 +50,7 @@ ## 安装级启停增量 为SkillHub可确认归属的DSH安装增加按位置停用与恢复,将入口重命名为 SKILL.md.disabled,保持目录、资源及正文内容不变。用户级与项目级互不隐式联动;会话级调用控制、外部技能管理与SaaS不纳入。扩展共享核心库存和操作日志,CLI兼容及迁移必须先通过门禁,不能将新字段伪装为现有v2。当前设计不代表已实现或已发布;见[启停设计](skill-toggle-design.md)及tasks.md第44节。 + +## 后续增量:多来源切换 + +允许保存多个具名来源,同一 Profile/插件实例共享一个当前来源;旧来源安装保留本地使用和管理,更新仅限当前来源。正式设计与验收见 [registry-switching-design.md](registry-switching-design.md),本地实现与验证完成,尚未合并发布。 diff --git a/openspec/changes/add-dsh-native-plugin/registry-switching-design.md b/openspec/changes/add-dsh-native-plugin/registry-switching-design.md new file mode 100644 index 000000000..99873d61b --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/registry-switching-design.md @@ -0,0 +1,94 @@ +# 多来源保存与当前来源更新检查(本地实现与验证完成) + +本增量由 2026-11-02 需求讨论提出,已实现并完成本地自动化及真实 Host/浏览器验证;版本发布另行安排。本文归属既有 DSH OpenSpec,不创建另一套独立产品协议。 + +## 已确认范围 + +- 保存多个具名 Registry,任一时刻仅选一个当前市场来源。首期不聚合多个市场,不新增登录或私有访问能力。 +- 切换市场不删除、移动、停用既有技能,不改写安装的来源身份;已安装技能保留使用及管理能力。 +- 更新检查、可更新筛选和可执行升级入口仅适用于当前来源。其他来源未检查不等于已是最新,不计入本轮成功或失败数量。 +- 切换后取消旧来源只读更新检查,丢弃迟到响应并隐藏旧更新提示。切回后重新检查,旧结果不能直接成为升级授权。 +- 绝不以新来源同名技能替换旧来源安装;新安装遇目标冲突沿用现有保护。 +- 删除保存的来源只删除连接配置,不删除其安装。变更URL是连接目标变化,不能自动迁移既有安装归属。 +- 已提交的文件事务遵守现有事务/恢复机制,切换来源不等于回滚或取消已接受的写入;旧确认引用必须失效。 +- 保持共享库存v2、原有安装记录及CLI兼容,不能为了多来源修改历史库存来源。 +- 升级前查看变化、场景助手、会话加载面板、套件接入均不在本增量范围。 + +## 当前实现接缝(本地 origin/main 12f850c5a) + +- `plugins/dsh/src/host/config.ts`:仅 registryUrl 与 detailTabs;地址缺省为现有默认,显式空串断连。 +- `plugins/dsh/src/client/config-page.tsx`:通过宿主配置表单写入 registryUrl;配置可能只读。 +- `plugins/dsh/src/host/index.ts`:Host 服务持有单一 registry/sourceRevision;重配导致来源上下文变化。 +- `listInstalledForAgent` 传当前 registryUrl 到本地运行时;来源绑定引用贯穿可用性和管理方法。因此“保留其他来源安装管理”必须核对其投影和授权,不能只增加地址下拉框。 + +## 共享作用域与交互 + +用户已确认同一 DSH Profile/插件实例共享当前来源,不做每会话独立选择。 + +- 技能中心顶部不展示来源选择器。来源选择及具名来源的新增、编辑、删除统一放在现有设置页的“技能来源”中;选择后保存生效,不新增管理中心。 +- 浏览页只展示当前市场。已安装页展示全部可信受管安装,仍以使用技能为主要操作;仅其他来源安装显示一个来源标识,管理在详情页二级区域进行。 +- 其他来源详情仅展示可信本地内容和本地管理,不向当前市场请求同名详情,也不提供错误的官方链接、远程版本或升级按钮。 +- 当前来源更新筛选和汇总只统计当前来源。没有匹配安装时显示“当前来源暂无可检查的技能”。 +- 来源只读或配置服务不可用时禁用修改;保存冲突提示重新读取,不覆盖另一个页面的选择。 +- 删除当前来源同时断开连接,不自动切往别处。断开后远程浏览/检查不可用,本地受管技能仍可使用、诊断、启停和卸载。 + +## 配置与兼容设计 + +新增可选 `savedRegistries: Array<{ name: string; url: string }>`,保留 `registryUrl` 为唯一当前地址,不新增 activeId 双重状态。列表缺省为空,旧自定义地址作为当前项可见,首次读取不强制回写。省略 registryUrl 的官方默认和显式空串断开语义保持不变。 + +地址验证及来源比较复用现有逻辑,不扩大 URL 等价规则;相同来源去重,名称只用于展示,不能成为库存归属。地址不接受凭证、查询串和片段。添加或改名不自动切换;修改当前条目的 URL 时将列表和当前地址原子保存,并按来源切换处理。 + +复用 Host configForms 的 revision CAS 和订阅:设置页来源选择写入 registryUrl 草稿并在保存时生效;删除当前项等复合编辑原子提交。其他页面通过配置通知和 Host sourceRevision 丢弃旧读取及确认。无额外数据库、localStorage 或库存迁移。 + +## 全来源本地管理与更新隔离 + +- `cli/src/integrations/installed-projection.ts:45` 当前过滤非当前来源;保留现有当前来源投影,新增明确的全部受管来源读取,共用库存/根目录校验。 +- 投影携带可信库存 registry。Host 的 itemKey、locationKey、卡片分组、缓存和导航均使用 registry + namespace + slug(位置键另含 scope/path),不能将两源同名技能合并。 +- `plugins/dsh/src/host/index.ts` 的 readInstalledItem、本地 use/diagnose/toggle/remove 及回执使用重读 item 的真实来源;保持根目录、指纹、修改检测、共享目标、sandbox、库存 CAS 与 sourceRevision 校验。 +- getInstalledDetail 对非当前来源采用本地路径;远程详情仅允许当前来源。显式断开也不短路本地能力。 +- 单项 checkUpdate/prepareUpgrade 在 resolve 前由 Host 拒绝来源不符;批量 scan 保持当前来源快照。UI 隐藏按钮不是授权边界。 +- 切换时清理读取缓存、失效未提交计划、终止旧检查;无法终止的请求通过 revision 丢弃结果。已受理事务保留原始身份和幂等键,继续可查询结果。 + +## 实施顺序及风险 + +1. 兼容配置与共享切换,覆盖只读、并发保存和重启。 +2. 全来源可信投影和来源身份贯穿本地管理,保持 CLI 默认查询语义及库存 v2。 +3. 后端当前来源更新限制、扫描统计及竞态处理。 +4. 页面来源选择、旧来源本地详情及精简状态展示。 +5. 回归、真实 Host/浏览器验收及文档;分别记录自动测试与人工预览证据。 + +重点风险是跨源同名误分组、详情串源、断开时本地能力被旧逻辑短路,以及切换与已受理事务竞态。均需专项用例;不通过放宽既有确认或文件保护解决。回退不迁移库存,新字段可由旧版本忽略,原 registryUrl 仍可读取;旧插件仍按原行为只展示当前来源。 + +## 验收标准 + +1. A安装技能后切至B,A技能文件/启停状态不变,已安装仍可识别及使用;管理不得将来源冒认为B。 +2. B下检查更新只访问B,不产生A远程请求;B同名新版不触发A安装的更新提示。 +3. A慢请求与切B竞态:响应晚到不污染B结果;原更新确认不可执行。 +4. B下其他来源安装不计入已最新、检查失败、可更新数量;切回A发起新检查。 +5. A不可用与B不可用独立处理;删除A连接后安装保留。 +6. 同技能双范围安装分别判断版本,来源与安装范围两个维度不可混淆。 +7. 升级已提交时切换来源,真实文件事务最终结果仍可追踪,不重复提交、不伪造取消。 +8. 旧registryUrl配置、显式空串、只读配置、重启、多个页面均有明确兼容行为。 + +9. 显式断开或删除当前连接后,本地管理仍可用且无远程请求;两源同名技能不合并,旧来源详情不串源。 +10. 真实 DSH 双页面切换和重启保持选择;浏览器验证紧凑布局、按钮行为及错误反馈,提供可访问的本地预览。 + + +## 本地验证结果 + +- 构建、类型检查、lint 与 OpenSpec 严格校验通过;DSH 全量 677 项通过,最终配置序列化修复后补跑配置 10 项与安装 UI 8 项专项。 +- CLI 类型检查、lint、构建通过;全量首轮受环境库存和并发超时干扰,六个失败文件以隔离 HOME、原时限逐一复验通过,保留首次失败记录。 +- 两个支持的 Host(0.2.0-rc.2、0.2.1-rc.2)均运行真实双来源文件事务与浏览器流程。慢响应切换、已受理升级继续、幂等、跨页配置 CAS、断开管理、同名分组通过。 +- 新版同 Profile 真实进程重启后,保存的来源名称及当前选择保留;重启不重复运行配置/安装观察器。 +- 新增可复现入口见 `plugins/dsh/test/integration/README.md`“多来源切换与本地安装管理”。Registry 为本地 HTTP 夹具;未调用模型,不构成 SaaS、跨操作系统或冷安装验收。 +- 真实浏览器曾发现配置 schema 回调无法还原,已改为可序列化结构并新增跨进程序列化回归;不以 mock ConfigForm 通过替代 Host 兼容性。 + +## 真实模型补充验收 + +真实模型首轮把来源地址误用为上下文引用,Host 正确拒绝但对话检查失败。补充参数字段说明,明确当前来源仅代表检查资格,区分只读检查和写入确认,并为安装引用过期与操作回执过期分别提供恢复指引。该指引不放松 Host 校验,不承诺强制约束模型措辞。 + +修复后真实模型新会话三轮完成查询、切源后当前来源检查,以及按钮操作后重新查询;旧失败会话经一次新列表读取恢复。真实模型调用使用隔离的本地 Registry 数据,未执行升级/卸载,不代表生产 SaaS 全链路验收。旧历史仍有措辞偏差,新回复也可能冗长,原始失败记录保留。 + +## 设置页信息层级 + +设置页采用“技能来源 / 页面显示”两个胶囊标签,复用宿主控件与主题;Tab仅切换内容,不保存配置、不丢弃草稿,也不触发连接测试。每个标签用一张内容卡片包裹。来源页以同一列表选择和管理来源,行内显示名称与地址,新增或编辑时才展开字段;不重复显示当前地址输入及保存来源列表。旧配置中未保存到列表的当前来源仍可见可管理。测试连接和断开操作作用于草稿,保存前不切换宿主;页面显示集中版本、审核与评论选项。共用明确的保存/重置区域,保存跨Tab草稿并保持原配置revision CAS。只读、加载、保存中、冲突与连接测试失败仍需可见,隐藏Tab中的校验错误应能引导定位。窄屏不出现横向溢出,Tab支持键盘操作。 diff --git a/openspec/changes/add-dsh-native-plugin/release-guide.md b/openspec/changes/add-dsh-native-plugin/release-guide.md index eca6cde82..d1a3cbc07 100644 --- a/openspec/changes/add-dsh-native-plugin/release-guide.md +++ b/openspec/changes/add-dsh-native-plugin/release-guide.md @@ -10,11 +10,22 @@ 插件运行在 DSH 主机,不部署进 SkillHub 网站,也不要求网站嵌入插件 UI。插件内部 Remote 接口随插件版本交付;Registry API 随服务端交付。两者分别管理版本和发布节奏。 -### 1.1 当前插件发布 +### 1.1 当前候选与发布顺序 -本轮仅发布 DSH 插件 `0.5.0`,提供只读技能可用性诊断。CLI 保持已发布的 `0.2.0`,无需再次发布;库存格式与兼容范围不变。发布标签为 `dsh-v0.5.0`,通过专用工作流上传经过检查的 tarball;发布后从 npm 重新下载安装验证。 +CLI `0.3.0`(`cli-v0.3.0`)已发布,本轮继续发布 DSH `0.6.1`(`dsh-v0.6.1`),范围为技能启停、统一只读检查更新、紧凑已安装卡片,以及发布前库存兼容修复。不发布服务端镜像或部署 SaaS。 -### 1.2 0.4.0 库存升级说明 +共享库存保持严格 v2,不写入 `installationState`;启停状态从可信的 `SKILL.md` / `SKILL.md.disabled` 入口派生,不要求 CLI 用户同步升级。已发布 CLI `0.2.0` 的正常查询和普通目标管理必须继续兼容;默认更新、同步及 Suite 不得无意重新启用停用目标。旧 CLI 显式 force 重装、更新、同步仍按原覆盖语义执行,可能恢复标准入口;显式 remove 仍可删除,新版重新读取须反映真实变化。 + +1. 使用真实旧公开 CLI `0.2.0`、DSH `0.5.0` 建立基线,在同一隔离 HOME 交替操作,验证普通/停用目标混存、来源与位置隔离、默认保护和显式覆盖语义。仅证明拒写或字节未损坏不算兼容通过。 +2. 读 v1/v2 不迁移。未发布 v3 必须在锁内校验身份、入口与未完成事务,备份原始字节后安全转换 v2;普通操作日志保持旧 DSH 可读,启停日志隔离影响。并发及五阶段强杀恢复重新验收。 +3. 完成候选检查、双 RC、跨平台、真实浏览器及草稿保护;旧候选结果仅在输入等价时可复用。兼容修复及真实旧包验收已通过;最终版本包和公开包仍按本流程单独验收。 +4. CLI 0.3.0 已完成公开字节及安装验收,不重复发布;DSH 0.6.1 通过后按专用工作流发布,核对公开字节、干净安装和升级保留。此顺序是交付安排,不是要求用户同步升级 CLI。保留已存在的 `dsh-v0.6.0` 标签,不移动到新提交;DSH 0.6.1 公开验收完成前不声明已发布。 + +旧消费者不承诺识别启停专用日志:中断启用已留下合法标准入口时,旧版仍可能执行合法管理。新恢复必须先核精确目标身份、版本、时间、指纹和归属,发现旧版变更即保留现场并返回 unknown,不能用旧意图覆盖新内容。CLI 的 v3 转换只核当前库存和入口并保留日志,不扫描全机 Host;NativeRuntime 在自己的日志域先处理恢复。 + +插件内置核心,不调用 CLI。回退不能只恢复库存:须核对入口、内容、元数据和日志,不能用过时备份覆盖新记录。完整协议以 [启停设计 §7](skill-toggle-design.md#7-库存协议与实施门禁) 为准。 + +### 1.2 历史:0.4.0 库存升级说明 此前库存升级交付 CLI `0.2.0` 和 DSH 插件 `0.4.0`,不发布服务端镜像、不部署 SaaS。插件内置安装核心,不要求另装 CLI;但同一用户使用 CLI 共同管理技能时,两者必须使用兼容库存格式。 @@ -80,6 +91,10 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output `check-dsh-plugin.yml` 使用 Linux、macOS、Windows 三平台矩阵,Node 24.2.0 与当前 Node 24 双版本、Bun 1.3.13,分别运行共享 CLI 检查、插件 `bun run check` 和隔离 Web Profile 的 Host 安装冒烟,并保存带平台名的 tarball 与脱敏摘要。新 RC `0.2.1-rc.2` 另有 Linux 双 Node 原生 Host 检查,复用对应 Node 档的原矩阵产物;不将此结果扩大为新 RC 的 Windows/macOS 原生文件操作验收。步骤显式使用 Bash,避免 Windows 默认 shell 对脚本的不同解释;平台失败不取消其他平台任务。矩阵通过只证明该次构建与自动化测试结果,真实 DSH Profile 安装、页面和对话验收仍须单独完成。平台测试中的跳过项必须记录,不能算作通过。 +新增独立安装启停验收矩阵:Linux/macOS/Windows × DSH `0.2.0-rc.2`/`0.2.1-rc.2`,固定 Node `24.2.0`,复用对应平台检查过的候选 tarball。`--scenario skill-toggle` 与 `--scenario skill-toggle-watch-off` 分别创建全新隔离 Host/Profile,不能复用可用性探针遗留的安装状态。核心恢复测试在五个持久化边界终止本次测试子进程;Windows 使用原生进程终止,POSIX 使用 SIGKILL。核心进程恢复、Host 启停发现及真实浏览器交互分别记录,不将核心测试称为完整 Host 强杀重启验收。矩阵配置不等于本次已通过,结果见最终发布验证记录。 + +Host 准备允许通过显式 `--download-cache` 共享受校验临时目录中的公共 npm `_cacache` 下载内容。CI 恢复缓存前及实际使用时都检查目录归属、链接与权限;按平台、架构、依赖准备代码和 DSH 版本区分缓存,可复用同族旧版本的公共内容。不得缓存 `node_modules`、Profile、技能、库存、历史、凭证或日志;只有 `install-host` 使用该缓存,候选安装与 Host 运行恢复独立私有缓存。每次验收仍创建全新状态,冷缓存未命中执行真实安装,安装 600 秒与各业务检查时限不变;耗时、缓存内容是否存在及固定错误码只作诊断,准备失败不得记为业务通过。 + ### 2.2 插件包检查 - Host、Client、patch、Remote 元数据、声明及许可证包含在包中,exports 指向有效文件。 @@ -90,7 +105,7 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output ### 2.3 隔离环境验证 -本次 `0.5.0` 候选分别验证 DSH `0.2.0-rc.2` 与 `0.2.1-rc.2`,Node `>=24.2.0 <25`。Host、app-boot、核心模块及注入 Client 模块须为同一精确发行版。新 RC 仅使用 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`;旧 RC 的 Cordis、Loader、Include、Timer 须为已验证的完整组合:`4.0.4 / 1.0.5 / 1.0.9 / 1.1.6` 或 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`,不得交叉混装;以实际 Host 解析结果为准。已发布 `0.1.0` 尚不包含后一组合支持及历史回执修复,`0.1.1` 起包含这些修复,`0.2.0` 延续此兼容基线;发布前使用本次 tarball 验证。在独立测试账户或容器中准备 Web Profile、工作区和测试技能,连接测试 Registry。仅隔离 Profile/DSH home 不足以隔离共享 inventory。 +本次 `0.6.1` 候选分别验证 DSH `0.2.0-rc.2` 与 `0.2.1-rc.2`,Node `>=24.2.0 <25`。Host、app-boot、核心模块及注入 Client 模块须为同一精确发行版。新 RC 仅使用 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`;旧 RC 的 Cordis、Loader、Include、Timer 须为已验证的完整组合:`4.0.4 / 1.0.5 / 1.0.9 / 1.1.6` 或 `4.0.5 / 1.0.6 / 1.0.10 / 1.1.7`,不得交叉混装;以实际 Host 解析结果为准。已发布 `0.1.0` 尚不包含后一组合支持及历史回执修复,`0.1.1` 起包含这些修复,`0.2.0` 延续此兼容基线;发布前使用本次 tarball 验证。在独立测试账户或容器中准备 Web Profile、工作区和测试技能,连接测试 Registry。仅隔离 Profile/DSH home 不足以隔离共享 inventory。 首次创建测试 Web Profile,然后安装本地候选包: @@ -175,7 +190,7 @@ SaaS 使用其现有流水线部署;公共接口契约、测试数据规则与 ### 4.1 独立发布流程 -插件使用独立版本,tag 前缀固定为 `dsh-v`(本次为 `dsh-v0.5.0`),不复用 CLI 的 `cli-v*` 发布流程。专用工作流为 `.github/workflows/release-dsh-plugin.yml`;发布前仍须确认 npm 包名、兼容基线及候选包验收,本次候选为 `0.5.0`,manifest 已显式设为 `private: false`;缺省 Registry 为 `https://skill.xfyun.cn`,显式空串/空白仍断开,已有自定义来源保留。发布前分别验证缺省、断开和自定义配置,以及正式域名的固定版本安装、状态识别和本地管理。正式发布仍以最终提交、候选验收和审批结果为准。 +插件使用独立版本,tag 前缀固定为 `dsh-v`(本次为 `dsh-v0.6.1`),不复用 CLI 的 `cli-v*` 发布流程。专用工作流为 `.github/workflows/release-dsh-plugin.yml`;发布前仍须确认 npm 包名、兼容基线及候选包验收,本次候选为 `0.6.1`,manifest 已显式设为 `private: false`;缺省 Registry 为 `https://skill.xfyun.cn`,显式空串/空白仍断开,已有自定义来源保留。发布前分别验证缺省、断开和自定义配置,以及正式域名的固定版本安装、状态识别和本地管理。正式发布仍以最终提交、候选验收和审批结果为准。 发布流程依次完成:校验版本 → 测试与构建 → 生成并检查 tarball → 隔离 DSH 候选验收 → 发布审批 → 发布 npm → 校验已发布包。当前工作流自动执行版本校验、测试与构建、包检查、上传及完整性校验;隔离 DSH 的 UI、真实模型和平台验收按本次确认范围另行执行,未验项目须单独注明;CI 矩阵成功不等同于这些验收通过。工作流使用仓库的 `NPM_TOKEN` 发布凭证,源码及 npm 包不包含凭证;配置了环境保护规则时还须完成对应审批。 @@ -198,7 +213,7 @@ SaaS 使用其现有流水线部署;公共接口契约、测试数据规则与 ### 4.3 发布说明 -`0.5.0` 的变更说明见 [release-notes.md](release-notes.md)。正式版本发布前按最终候选及验收结果更新,不沿用开发版本的支持声明。 +`0.6.1` 的变更说明见 [release-notes.md](release-notes.md)。正式版本发布前按最终候选及验收结果更新,不沿用开发版本的支持声明。 每个版本包含: diff --git a/openspec/changes/add-dsh-native-plugin/release-notes.md b/openspec/changes/add-dsh-native-plugin/release-notes.md index e536cfcd0..98ca6e7a3 100644 --- a/openspec/changes/add-dsh-native-plugin/release-notes.md +++ b/openspec/changes/add-dsh-native-plugin/release-notes.md @@ -1,6 +1,16 @@ # SkillHub DSH 原生插件发布说明 +## 0.6.1 候选:启停、检查更新与紧凑卡片 + +- **受管理技能启停**:[#1025](https://github.com/iflytek/skillhub/pull/1025) 按确认的位置将 `SKILL.md` 转换为 `SKILL.md.disabled`,启用恢复入口,保留目录和资源。更新停用安装后仍保持停用;文件修改、共享归属、目标被占用及不可信记录仍阻止写操作。停用不撤回已加载会话内容,也不停止任务。 +- **统一检查、逐项更新**:[#1030](https://github.com/iflytek/skillhub/pull/1030) 主动只读检查当前来源库存,渐进显示结果和失败重试。一次可信快照、有界并发、同来源技能共用远端 resolve;取消及上下文变化不接纳迟到结果。真正更新仍进入详情按位置确认,不提供批量自动覆盖。 +- **日常围绕使用技能**:[#1032](https://github.com/iflytek/skillhub/pull/1032) 已安装页采用桌面两列、窄屏一列的紧凑横向卡片,显示真实本地名称、一行摘要和实际安装版本;至多两个语义标签。整卡进入详情,保留使用技能主按钮,管理安装/检查更新收进更多菜单。单卡检查不清除其他卡结果,使用和菜单不误触详情。 +- **库存兼容**:共享库存保持严格 v2,不写入 `installationState`;启停状态从可信的 `SKILL.md` / `SKILL.md.disabled` 入口派生,不要求 CLI 用户同步升级。已发布 CLI `0.2.0` 的正常查询和普通目标管理必须继续兼容;默认更新、同步及 Suite 不得无意重新启用停用目标。旧 CLI 显式 force 重装、更新、同步仍按原覆盖语义执行,可能恢复标准入口;显式 remove 仍可删除,新版重新读取须反映真实变化。 [#1033](https://github.com/iflytek/skillhub/pull/1033) 已完成未发布 v3 的安全转换、普通/启停日志兼容隔离及真实旧包交替验收。 +- **交付边界**:保留现有双 RC、Node/Cordis 与来源边界;详情官网链接仅使用 API 的安全 `webUrl`,缺失即隐藏。该增量不包含 SaaS 部署、场景助手或技能清单导入导出。 + +上述功能及库存兼容修复已合并。CLI `0.3.0` 已发布;DSH `0.6.1` 仍是候选,不把历史功能验收等同最终版本包或公开包验收。发布顺序和限制见 [发布指南](release-guide.md),结果由 [验证记录](verification.md) 单独维护。 + ## 0.5.0 技能可用性诊断 - 技能卡片“更多 → 检查可用性”与对话工具 `astron_skillhub_check_availability` 提供只读诊断,区分文件完整性、同名覆盖、宿主未知及用户/模型调用策略([#1023](https://github.com/iflytek/skillhub/pull/1023))。 @@ -10,7 +20,7 @@ ## 版本与交付状态 -当前准备版本为 `0.5.0`;上一公开版本为 `0.4.0`。发布状态以对应 npm 版本和发布工作流为准。 +当前准备版本为 DSH `0.6.1`;CLI `0.3.0` 已发布并完成公开包验收,不重复发布。保留已存在的 `dsh-v0.6.0` 标签,DSH 候选使用新标签 `dsh-v0.6.1`,尚不据此声明 DSH 已公开发布。历史版本说明保留;实际公开状态以对应 npm 版本和专用发布工作流为准。 `@astron-team/skillhub-dsh@0.3.1` 已发布,独立标签为 `dsh-v0.3.1`。[发布准备 PR #995](https://github.com/iflytek/skillhub/pull/995) 已合并,[专用发布工作流](https://github.com/iflytek/skillhub/actions/runs/39556075587) 成功;[公开 npm 包](https://www.npmjs.com/package/@astron-team/skillhub-dsh/v/0.3.1) 与工作流产物逐字节一致,发布时 `latest` 指向 `0.3.1`。两版 Linux 的严格安装、原生探针及浏览器操作复验通过,测试工件与公开包字节一致,结果见 [验证摘要](verification.md)。历史已发布版本保持不变。 @@ -100,7 +110,7 @@ Node 保持 `>=24.2.0 <25`。精确兼容与验证边界见 [兼容设计](compa ## 安装、更新与维护 -可通过 `dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.3.1 --ignore-scripts` 安装到已有 Web Profile。发布前从源码执行 `bun run check` 后生成 `.tgz`,使用固定版本 DSH 的 `plugin ... add` 安装。具体准备、命令、配置和测试流程见 [中文指南](../../../plugins/dsh/README.md)、[英文指南](../../../plugins/dsh/README.en.md) 及 [发布指南](release-guide.md)。 +`0.6.1` 公开可用后,可通过 `dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.6.1 --ignore-scripts` 安装到已有 Web Profile;发布前使用本地候选 tarball。发布前从源码执行 `bun run check` 后生成 `.tgz`,使用固定版本 DSH 的 `plugin ... add` 安装。具体准备、命令、配置和测试流程见 [中文指南](../../../plugins/dsh/README.md)、[英文指南](../../../plugins/dsh/README.en.md) 及 [发布指南](release-guide.md)。 更新或回退前保留原包和校验值、停止新操作并处理待恢复事务,然后停止 Host、安装兼容包并重新启动。浏览器旧资源可强制刷新;这不能代替 Host 依赖重载。禁用或移除包不会卸载技能,不应删除技能目录、inventory 或操作日志。 diff --git a/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md b/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md index c48258bcb..4d16bd9dc 100644 --- a/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md +++ b/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md @@ -26,9 +26,9 @@ 停用入口不是垃圾,不得按清理期限删除。状态记录绑定精确安装及入口别名;不能以 `SKILL.md` 缺失推导 disabled,不能看到任意 `.disabled` 文件就认领。所有安装列表、详情、诊断、使用前校验及运行时上下文读取同一共享投影,模型和 UI 只收到白名单公开状态。 -稳态读取指纹仅在受信库存记录为 disabled 且精确安装身份匹配时,按该状态将该安装内精确 `SKILL.md.disabled` 逻辑映射为 `SKILL.md` 计算,保留原始内容字节;其余作者文件照常参与。不对任意扩展名归一化,不跳过别名内容。两入口同时存在、入口缺失、别名冲突、目录或链接类型异常均阻断;原包自带该同名作者文件时必须阻断启停;升级候选包自带该文件则阻断停用状态下的升级,不能覆盖合法作者资源,也不因此改变普通上传协议。发布/导出必须先恢复原稿,或由经过验证的规范导出流程在输出副本恢复入口名并校验原指纹,不能把停用目录原样作为标准包发布。标准打包仍要求 SKILL.md,停用目录不能直接发布。 首次停用改名后且库存提交前、启用恢复及升级暂存等转换阶段,只能由受信操作日志与不可变计划绑定的精确目标、阶段和基线授权入口映射;不得通过通用布尔参数对任意目录放开映射。 +稳态读取指纹仅在精确受管安装由可信停用入口投影为 disabled 且安装身份匹配时,按该状态将该安装内精确 `SKILL.md.disabled` 逻辑映射为 `SKILL.md` 计算,保留原始内容字节;其余作者文件照常参与。不对任意扩展名归一化,不跳过别名内容。两入口同时存在、入口缺失、别名冲突、目录或链接类型异常均阻断;原包自带该同名作者文件时必须阻断启停;升级候选包自带该文件则阻断停用状态下的升级,不能覆盖合法作者资源,也不因此改变普通上传协议。发布/导出必须先恢复原稿,或由经过验证的规范导出流程在输出副本恢复入口名并校验原指纹,不能把停用目录原样作为标准包发布。标准打包仍要求 SKILL.md,停用目录不能直接发布。 首次停用改名后且库存提交前、启用恢复及升级暂存等转换阶段,只能由受信操作日志与不可变计划绑定的精确目标、阶段和基线授权入口映射;不得通过通用布尔参数对任意目录放开映射。 -停用成功仅表示选定位置的文件与库存提交完成。项目副本停用后用户副本可能接替,外部 Provider 也可能仍提供同名技能;不自动启停赢家、不自动将旧“使用”动作改绑给赢家。停用安装不提供“使用”,可提供启用、检查更新、更新和卸载。启用后仍需发现和策略核验,不能直接标记可调用。 +停用成功仅表示选定位置的入口变更与持久日志提交完成。项目副本停用后用户副本可能接替,外部 Provider 也可能仍提供同名技能;不自动启停赢家、不自动将旧“使用”动作改绑给赢家。停用安装不提供“使用”,可提供启用、检查更新、更新和卸载。启用后仍需发现和策略核验,不能直接标记可调用。 ## 3. 精确计划与真人确认 @@ -61,17 +61,17 @@ flowchart TD C -->|是| E[持久化入口变更意图与恢复基线] E --> F[排他创建目标硬链接并记录 linked 阶段] F --> F2[核验同 inode 后移除旧入口] - F2 --> G[在最新库存 CAS 提交所选位置状态] + F2 --> G[核验最新库存所有权并持久化日志提交点] G -->|确定已提交| H[保留提交结果并完成日志] G -->|确定未提交| I[核验后无覆盖恢复入口] G -->|未知| J[重读库存与两侧文件] - J -->|匹配 after| H - J -->|匹配 before 且可逆| I + J -->|日志 committed 且文件匹配 after| H + J -->|未提交且所有权未变、入口可逆| I J -->|冲突或证据不足| K[保留现场并要求恢复] H --> L[单独观察发现状态] ``` -CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新变更。入口变更前日志保存身份、before/after、精确路径与阶段。文件转换失败不得直接改状态;不支持同目录硬链接时拒绝,不退化为可能覆盖目标的 rename 或复制删除。日志与库存采用既有持久化流程;进程强杀恢复测试不等于硬件断电耐久性验证。 +持有目标锁与库存锁核验最新所有权后,持久日志 committed 阶段是启停逻辑提交点;磁盘库存不保存入口状态,保留其他位置最新变更。入口变更前日志保存身份、before/after、精确路径与阶段。文件转换失败不得直接改状态;不支持同目录硬链接时拒绝,不退化为可能覆盖目标的 rename 或复制删除。日志与库存采用既有持久化流程;进程强杀恢复测试不等于硬件断电耐久性验证。 启用时标准入口必须不存在且停用入口可信;标准入口存在任何内容、链接或无法读取均阻断,不覆盖、不合并。停用时别名目标必须不存在。停用入口被修改、指纹未知或两入口均缺失同样阻断。两入口均存在、库存与文件矛盾或未知协议进入待核验状态。 @@ -80,13 +80,13 @@ CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新 | 故障窗口 | 结果与恢复规则 | | --- | --- | | intent 写入失败或未移动 | 不移动、不更新状态;报告失败 | -| linked 中间态或已转换,确定 CAS 未提交 | 同一锁域检查两侧与原基线,安全时逆向移动;否则保留现场并要求恢复 | -| CAS 报错但提交结果未知 | 重读 inventory 和两侧文件,禁止立即反向移动 | -| CAS 已提交,完成日志失败或响应丢失 | 保留新状态与文件,回执告警或待核验;不得反向恢复旧入口 | +| linked 中间态或已转换,日志确定未提交 | 同一锁域检查两侧与原基线,安全时逆向移动;否则保留现场并要求恢复 | +| 日志提交报错且结果未知 | 重读日志、inventory 所有权和两侧文件,禁止立即反向移动 | +| 日志 committed 已持久化,终态日志失败或响应丢失 | 保留新状态与文件,回执告警或待核验;不得反向恢复旧入口 | | 重启发现未完成事务 | 获取执行租约和兼容锁后核验;不自动重放启停,不抢占活跃操作 | | 恢复目标被占用 | 保留停用入口及日志,拒绝覆盖并提供受控诊断;不删除新占用内容 | -目标恢复完成前阻断冲突写入;其他独立目标仍可按共享库存 CAS 更新。恢复仅处理本次目标,不恢复整份旧库存快照。共享核心需支持新日志版本,旧未完成操作必须先按兼容路径收尾或阻止迁移,不能将旧日志强制转换。 +目标恢复完成前阻断冲突写入;其他独立目标仍可按共享库存 CAS 更新。恢复仅处理本次目标,不恢复整份旧库存快照。兼容未发布旧 v3 事务时,若持久库存的目标状态已经是 after 且文件完整匹配,则保留旧 CAS 的已提交结果;不能因旧日志仍为 moved 就回滚已提交事务。共享核心需支持新日志版本,旧未完成操作必须先按兼容路径收尾或阻止迁移,不能将旧日志强制转换。 ## 6. 停用期间的生命周期 @@ -99,16 +99,15 @@ CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新 ## 7. 库存协议与实施门禁 -先前 `inventory-store.ts` 的磁盘协议为 `schemaVersion: 2`,target/record/顶层均为 strict schema,target 无启停或入口状态字段。不得在 v2 上偷偷添加字段,不能依赖旧客户端忽略字段,也不能把启停状态另存为绕过库存的第二可写权威。 +共享库存继续使用严格 `schemaVersion: 2`,不新增 installationState 字段,不要求用户同步升级 CLI。启停状态不再作为磁盘库存字段,而是新消费者对精确受管安装入口的内存投影:只有标准入口为 enabled,只有可信停用入口为 disabled。两入口并存、入口缺失、类型异常和内容不符按完整性冲突处理,不伪报可用。不建立第二份可写启停状态文件。 -本轮冻结库存与操作日志为显式 v3。库存保留 records/suites 结构,target 新增必填 installationState(enabled/disabled);入口名由状态推导,不另存冗余字段。v1/v2 读取投影为 enabled;正常首次写入 v3 前,在兼容锁域保留旧库存原始字节及 SHA-256 对应的不可覆盖备份。旧 strict v2 消费者应拒绝新格式,实际写入前拒绝仍须矩阵验证。日志 v3 增加 enable/disable、精确入口 before/after 与恢复阶段,兼容读取旧 v2 日志,未知版本拒绝。以下门禁约束实现与交付: - -1. 旧 v1/v2 读取只做投影,不写回;旧记录默认逻辑 enabled,但缺失文件、缺失基线仍保留异常,不伪造可用。 -2. 首次写迁移在兼容锁域保存不可变原始字节及摘要备份,原子提交;保留所有其他位置、Suite、归属、版本及未知完整性事实。未完成旧日志阻断不安全迁移。 -3. CLI 与插件共同升级;所有库存/生命周期消费者通过同一模型。旧 strict v2 拒绝新格式的源码事实不证明所有旧命令在修改文件前拒绝,必须运行明确历史版本的读写矩阵,覆盖安装、升级、删除、sync 和 Suite。 -4. 迁移前停止不兼容写入进程;当前核心只检查可信操作状态目录内可发现的未完成日志,不宣称能够扫描或协调全部外部进程;新 schema 不能阻止任意旧程序直接修改文件。未通过旧消费者证据门禁不得开放启停或发布仅 Host 支持的中间版本。 -5. 回滚前停写并核验全部停用入口、标准入口占用和版本;先安全恢复或导出并完成目标协议验证,再讨论降级。只恢复 v2 库存备份不是有效回滚;冲突时保留现状,不提供未经证明的一键降级。 -6. 入口别名映射与库存状态必须参与同一逻辑目标锁和 CAS 比较;v3、恢复日志及旧消费者验证通过前不得发布仅支持 Host 的中间版本。平台耐久保证与发现边界由实际验证记录,不以方案文字代替通过。 +1. 普通读写和启停均不得把共享库存升级为旧 CLI 无法读取的格式。读已有 v1/v2 不引入写入;已有未发布 v3 库存须校验身份、文件状态与未完成事务后,在同一锁域保留原始字节备份并安全转换为 v2,不能直接恢复过时备份覆盖新记录。 +2. 入口转换由现有目标锁、持久操作日志和库存 CAS 共同协调。改名后的文件状态变化不等于库存身份发生并发修改;提交和恢复需分别核验身份、版本、指纹、归属与日志授权的转换阶段,保留其他目标的最新变更。 +3. 普通安装、更新、删除操作日志继续采用旧 DSH 可读协议;启停专用新日志须隔离兼容影响,不能使旧 DSH 的普通管理全局失效。未知或未完成事务仍须阻断冲突目标操作,不得跳过恢复保护。 +4. 已发布 CLI 0.2.0 必须能查询合法 v2 库存并继续管理普通目标。默认更新、同步及 Suite 不得因新版启停引起无意重新启用;需要以真实旧 npm 包验证。旧版不提供启停语义,其显式 force 重装、force 更新、force 同步沿用原覆盖契约,可能恢复标准入口;显式 remove 沿用删除契约。新版重新读取必须反映这些真实文件变化,不能残留虚假停用状态。 +5. 新 CLI 的停用升级、sync、Suite 和 doctor 保护继续保留。不能用旧版本显式覆盖行为作为理由放松新版默认保护。 +6. 验收覆盖同一 HOME 下新旧消费者交替操作、不同来源/位置、普通与停用目标混存、并发与五阶段强杀恢复。仅证明旧版拒绝整份库存或数据未损坏不算兼容通过;未通过前保持发布阻断。 +7. 旧工具不会识别隔离的新启停日志。若启用中断后已出现有效标准入口,旧工具仍可按既有契约执行合法操作;新版恢复必须在任何入口修改前比较来源、位置、版本、安装时间、指纹、归属及 Suite 引用,后续合法写入使基线失配时返回 unknown 且保持文件不动。不能宣称旧工具会拒绝所有待恢复目标。CLI 不扫描所有 Host 状态目录;v3 转换只核验本库存对应的入口与内容并保留原始证据,NativeRuntime 对本状态目录未完成事务先恢复。 ## 8. 发现反馈与验收 @@ -124,7 +123,7 @@ NativeRuntime.inspectToggle / acceptToggle / executeToggle,沿用既有 operat ### 首期 CLI 消费范围 -CLI 不新增启停命令,需正确读取 v3 并保护停用记录。缺少原生持久事务观察器的 CLI 停用升级安全拒绝,提示使用 DSH 执行;force 不绕过。namespace sync 不更新或 prune 停用安装;Suite 不接管停用安装;doctor 不通过散落别名重建停用归属,保留已有记录。DSH 停用更新与卸载是交付必需能力。 +CLI 不新增启停命令,需读取兼容 v2 库存并从可信入口派生状态、保护停用记录。缺少原生持久事务观察器的 CLI 停用升级安全拒绝,提示使用 DSH 执行;force 不绕过。namespace sync 不更新或 prune 停用安装;Suite 不接管停用安装;doctor 不通过散落别名重建停用归属,保留已有记录。DSH 停用更新与卸载是交付必需能力。 ### 无覆盖入口变更 diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-change-preview/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-change-preview/spec.md new file mode 100644 index 000000000..ab4202e03 --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-change-preview/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Sequential local handling and release preview +The plugin SHALL inspect local changes for the selected trusted installation before upgrade preparation, and SHALL expose local inspection directly from installation details. When modifications exist, the user SHALL first discard selected modifications or explicitly back up the exact local snapshot before proceeding to a separate release-change preview. Only the final explicit upgrade confirmation SHALL authorize replacement. The interface SHALL NOT present local changes and release changes as interchangeable parallel tabs. + +#### Scenario: Inspect without upgrading +- **WHEN** the user opens local changes from installation details +- **THEN** the view compares the installed release with current local files and permits returning without entering an upgrade flow + +#### Scenario: Modified installation upgrade +- **WHEN** the user requests an upgrade of a modified installation +- **THEN** local changes are handled first, successful backup alone does not modify the installation, and release differences are shown before a separate upgrade confirmation + +#### Scenario: Unmodified installation +- **WHEN** the local snapshot matches the verified installed release +- **THEN** the user proceeds directly to the release preview without an unnecessary local-handling step + +### Requirement: Verified original content and bounded differences +Both comparisons SHALL use exact releases verified against recorded source identity and fingerprint. Missing or unreadable baselines SHALL remain unavailable rather than being represented as unchanged. Difference content SHALL be loaded on demand with bounded byte, line and computation limits and explicit binary or truncated states. Each expanded file display SHALL have a maximum height of 320 CSS pixels on desktop and 240 CSS pixels on narrow layouts, with independently scrollable content and a visible file header. + +#### Scenario: Wrong or unavailable original release +- **WHEN** the original archive is unavailable or its fingerprint does not match the installed release +- **THEN** comparison and discard operations remain unavailable and another source's same-named release is not substituted + +#### Scenario: Large or binary content +- **WHEN** a changed file is binary or exceeds the preview limits +- **THEN** the view reports that state explicitly without sending unbounded content or treating the file as unchanged + +### Requirement: Discard local file modifications without changing release +A confirmed discard operation SHALL restore modified or missing published files to the current installed release and delete only confirmed local additions. It SHALL NOT change the installed version, source, ownership, installation timestamp or sibling targets. A discard-all plan SHALL bind the complete reviewed file set. Invocation entry normalization SHALL preserve the selected installation's enabled or disabled state, and internal installation metadata SHALL NOT be treated as user-editable release content. + +#### Scenario: Restore a modified file +- **WHEN** the user confirms discarding one modified published file +- **THEN** only that file is restored to the verified current-release bytes and the installed version remains unchanged + +#### Scenario: Delete a local addition +- **WHEN** the user selects a locally added file for discard +- **THEN** confirmation explicitly states that the file will be deleted and cancellation preserves it + +#### Scenario: Disabled installation +- **WHEN** a disabled installation's invocation file is restored +- **THEN** the original release content is restored through the trusted disabled-entry mapping without enabling the installation or creating a conflicting second entry + +### Requirement: Snapshot-bound mutation authorization +Discard and overwrite execution SHALL revalidate the exact installation identity, normalized target, complete relevant file snapshot, metadata, ownership and permissions under the existing target-lock and inventory-CAS discipline. Drift SHALL invalidate the plan rather than extending the reviewed scope. Operations SHALL retain recoverable journals, idempotent request semantics and explicit failure outcomes. The shared inventory SHALL remain version 2 and supported older CLI behavior SHALL remain unchanged. + +#### Scenario: Modification after confirmation preparation +- **WHEN** local files or installation identity change after a discard or overwrite snapshot is prepared +- **THEN** execution is rejected without applying the obsolete plan and the user is asked to review the current state + +#### Scenario: Interrupted discard +- **WHEN** a discard transaction is interrupted during commit +- **THEN** the operation reports a recoverable or verified outcome and restart does not blindly replay a new user write intention + +### Requirement: Durable backup before explicit overwrite upgrade +Persistent backup creation SHALL enforce a 100 MiB per-backup limit, a 1 GiB per-Host total limit and a maximum of 20 backups without silently evicting existing backups. Backing up and continuing SHALL copy and verify the complete confirmed local snapshot into a Host-private location outside skill discovery roots before opening the release preview. It SHALL NOT modify skill files or inventory. The final upgrade SHALL require a fresh explicit confirmation and revalidate that the verified backup and current snapshot still match. The old unconfirmed upgrade path SHALL continue refusing local drift. Backup failure SHALL prevent overwrite and remain visible with retry. + +#### Scenario: Backup succeeds and preview is cancelled +- **WHEN** backup completes but the user cancels the subsequent release preview +- **THEN** the installation remains unchanged and the verified backup is retained + +#### Scenario: Backup cannot be verified +- **WHEN** backup fails because of permissions, capacity, interruption or content mismatch +- **THEN** the plugin does not proceed to release preview or replacement and does not report a successful backup + +#### Scenario: Local files change after backup +- **WHEN** files change between successful backup and upgrade submission +- **THEN** the old snapshot authorization is rejected and the new content must be reviewed and backed up separately + +### Requirement: No release rollback or silent backup deletion +The plugin SHALL NOT expose version downgrade or post-upgrade release rollback in this capability. Local file discard SHALL refer only to the current installed release. Persistent user backups SHALL NOT be deleted automatically on cancellation, successful upgrade, cache eviction or restart. Cache storage SHALL be separate from persistent user backups, and capacity failure SHALL NOT silently evict user backups. + +#### Scenario: Upgrade completes +- **WHEN** a newer release is installed successfully +- **THEN** the result does not offer restoring the previous release and retained backup data is not presented as an implemented rollback feature + +### Requirement: Host-local processing and source isolation +Original release downloads SHALL reuse the existing Registry contract; local file contents SHALL NOT be uploaded to a Registry for comparison. Host-private APIs SHALL mediate all filesystem reads and mutations. Only current-source installations SHALL enter release-update previews and upgrades; trusted other-source installations MAY retain local inspection and discard using their own verified baseline. + +#### Scenario: Source changes during an unsubmitted preview +- **WHEN** the active source changes before final upgrade acceptance +- **THEN** the old preview cannot authorize an upgrade through the new source + +### Requirement: Compatible isolated file-reset journals +New file-reset journal schemas SHALL be stored outside the legacy operation directory so older consumers cannot classify them as corrupt legacy records and remove them. The new Host SHALL inspect pending new and legacy transactions before admitting any new write. Existing shared target-lock and inventory-lock discipline SHALL remain effective for older CLI consumers. + +#### Scenario: New reset transaction is pending +- **WHEN** an interrupted file-reset operation has not been reconciled +- **THEN** the new Host blocks new installation, upgrade, removal and toggle writes until the pending state is verified, even if the legacy operation list is empty + +#### Scenario: Older consumer sees shared inventory +- **WHEN** a supported older CLI reads inventory after a completed file reset +- **THEN** it can parse unchanged version-2 inventory and does not read or delete the isolated new journal as corrupt legacy data diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-registry-connection/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-registry-connection/spec.md index 6ed5e71a5..26b1cca69 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-registry-connection/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-registry-connection/spec.md @@ -94,3 +94,46 @@ The open-source versions endpoint SHALL accept optional `downloadableOnly`, defa #### Scenario: Existing consumers omit the new parameter - **WHEN** a management consumer omits downloadableOnly - **THEN** its authorized lifecycle history remains available and no authentication policy changes + +### Requirement: Saved sources with one shared active source +The plugin SHALL support named saved sources with one active registryUrl shared by a Profile/plugin instance. Configuration writes SHALL preserve revision CAS, omitted defaults and explicit disconnection. Removing a source SHALL NOT remove its installations. + +#### Scenario: Shared switch and conflicting edits +- **WHEN** one page switches from A to B while another holds an old configuration revision +- **THEN** all pages observe B and stale writes are rejected rather than overwriting the selection + +### Requirement: Local management independent of the active market +The plugin SHALL retain trusted managed installations from other sources, including while disconnected. Local actions SHALL use their original inventory identity and preserve existing filesystem and confirmation protections. Different sources with equal coordinates SHALL NOT merge. + +#### Scenario: Old source installation after a switch +- **WHEN** A has an installation and the active source becomes B or disconnected +- **THEN** the installation remains locally usable and manageable and its detail does not request B's same-name skill + +### Requirement: Update checks restricted to the active source +The Host SHALL restrict individual and bulk update checks and upgrade preparation to installations from the active source before remote requests. Other-source installations SHALL NOT count as latest, failed or updateable. Switching SHALL discard old results and invalidate unsubmitted plans without abandoning accepted transactions. + +#### Scenario: Slow response after switching +- **WHEN** A's update check completes after switching to B +- **THEN** its result is discarded, only B participates in current update counts, and switching back to A requires a fresh check before upgrade + +#### Scenario: Cross-source upgrade attempt +- **WHEN** an old-source item is submitted for an update check or upgrade against the active source +- **THEN** the Host rejects the request before resolve and does not replace its installation with a same-name package + +#### Scenario: Model checks updates after a source switch +- **WHEN** a user asks the conversation agent to check installed skills for updates +- **THEN** the tool contract directs the agent to obtain fresh opaque routing references from the installed list, check only current-source items, and distinguish update eligibility from an available newer version +- **AND** a stale context is not presented as missing human approval; the agent is directed to refresh once and report an unresolved failure rather than repeatedly retry or invent a confirmation step + +### Requirement: Settings grouped without losing unsaved changes +The settings page SHALL group source configuration and detail visibility into keyboard-accessible tabs. Switching tabs SHALL preserve the same unsaved draft and SHALL NOT save or test a connection. Each tab SHALL use one content card. Source selection and management SHALL share a single list with inline editing on demand, without a duplicated current-address editor. A current URL absent from saved sources SHALL remain visible and manageable. + +#### Scenario: Save changes from multiple settings tabs +- **WHEN** a user edits the source and detail visibility in separate tabs and saves +- **THEN** all pending edits are submitted together with their original configuration revision +- **AND** an invalid hidden source field brings the source tab and management area into view; conflicting external edits are rejected without silently rebasing the draft + +#### Scenario: Return to the saved values +- **WHEN** edits return to the saved values without an intervening configuration revision change +- **THEN** no unsaved-change state remains and save is disabled +- **AND** returning values after an external revision change does not bypass conflict detection diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md index 0f31e899c..91a602fc5 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md @@ -134,7 +134,7 @@ The plugin SHALL prepare upgrade and uninstall plans on the Host with exact sour - **THEN** the Host rejects it rather than extending or reusing its authority ### Requirement: Protected explicit upgrade -The plugin SHALL check for updates only on user request and SHALL upgrade only to a resolved newer exact version with a matching fingerprint. It SHALL preserve invocation restrictions and resources, refuse local drift and shared Suite ownership, and SHALL NOT silently downgrade or replace changed content under the same version. +The plugin SHALL check for updates only on user request and SHALL upgrade only to a resolved newer exact version with a matching fingerprint. It SHALL preserve invocation restrictions and resources, refuse unconfirmed local drift and shared Suite ownership, and SHALL NOT downgrade or replace changed content under the same version. Explicit handling of local modifications SHALL follow the snapshot-bound backup and final-confirmation requirements in dsh-change-preview; this SHALL NOT introduce a general force option. #### Scenario: No valid newer release - **WHEN** the candidate is older, incomparable, or the same version with different content @@ -148,9 +148,9 @@ The plugin SHALL check for updates only on user request and SHALL upgrade only t - **WHEN** the same source coordinate also has another project, Agent or Suite-owned installation - **THEN** that sibling is not included in the upgrade and is preserved; shared ownership or an alias of the selected physical target still blocks the operation -#### Scenario: Local content modified or baseline missing -- **WHEN** existing content differs from its recorded baseline or has no usable baseline -- **THEN** upgrade is blocked with a reason and no force option +#### Scenario: Unconfirmed local content modified or baseline missing +- **WHEN** existing content differs from its recorded baseline without a verified backup and explicit snapshot-bound authorization, or has no usable original release baseline +- **THEN** upgrade is blocked with a reason and no general force option; eligible local changes may enter the separate protected handling flow before a new final confirmation ### Requirement: Exact local uninstall The plugin SHALL uninstall only one confirmed trusted target, without remote deletion or changing other targets. It SHALL verify identity, path, baseline and ownership and SHALL isolate files before inventory commit so failure can be reconciled. It SHALL NOT treat arbitrary filesystem errors as absence. @@ -436,3 +436,44 @@ Daily catalog, installed-skill groups, conversation inventory cards and default #### Scenario: Explicit installation management - **WHEN** the user selects a particular installation in secondary management or uses an exact operation receipt - **THEN** its exact reference remains authoritative for that request and is never silently replaced by the skill-level selector's winner + + +### Requirement: User-triggered installed update overview +The installed view SHALL check current managed installations only on explicit user action, aggregate results by skill, and retain exact installation identity for individual updates. It SHALL NOT execute updates during the scan. + +#### Scenario: Mixed installation results +- **WHEN** installations of one skill return different update states +- **THEN** the view counts the skill once as upgradable if any location can upgrade, and the detail exposes each location independently +- **AND** blocked or unavailable results are not represented as current or executable upgrades + +#### Scenario: Context or inventory changes during a scan +- **WHEN** the source, session, workspace, or installation reference changes +- **THEN** stale results cannot populate the current overview or authorize an update + +#### Scenario: Retry and individual confirmation +- **WHEN** a user retries failed checks or opens an upgradable skill +- **THEN** checks remain read-only and the existing precise per-installation update confirmation is required before mutation + +#### Scenario: Large inventory update checking +- **WHEN** a user checks many installations, including several locations of the same skill +- **THEN** a bounded Host-owned scan reads and indexes the trusted inventory once, validates only the requested target and inventory revision per result, and shares remote version resolution for the same source and skill within that scan +- **AND** inventory or context changes invalidate the scan, scan resources expire or are released on cancellation, and mutation preparation retains its independent full validation + +### Requirement: Shared state remains compatible with released consumers +New lifecycle operations SHALL retain the strict v2 shared inventory format readable by released CLI 0.2.0. Activation state SHALL be derived from the verified entry file of the exact managed installation, without an additional writable activation authority. Ordinary operation journals SHALL remain readable by released DSH 0.5.0; activation-specific journals SHALL NOT prevent unrelated ordinary operations in that consumer. + +#### Scenario: Old and new consumers alternate ordinary operations +- **WHEN** a new consumer installs, updates, removes, synchronizes or repairs an ordinary installation +- **THEN** the old CLI can still list the same inventory and manage unrelated ordinary installations +- **AND** the operation does not upgrade the shared inventory to an unreadable schema + +#### Scenario: Released consumer explicitly replaces a disabled installation +- **WHEN** the old CLI explicitly force-replaces or removes a disabled installation under its existing overwrite or removal contract +- **THEN** the new consumer observes the resulting real entry, contents and ownership on its next read +- **AND** it does not apply stale disabled state to replacement contents or claim that a removed installation remains present +- **AND** default update, sync and Suite operations are separately tested for unintended re-enablement + +#### Scenario: Activation interruption or ambiguous entry files +- **WHEN** activation is interrupted or both entry names, neither entry, or untrusted entry contents are observed +- **THEN** exact target identity, durable journal stages and verified files govern recovery under the existing lock domain +- **AND** ambiguous contents are not reported as a usable installation or overwritten to make the state appear consistent diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index 1be87d5d9..865d585cc 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -396,3 +396,49 @@ - [ ] 45.4 独立评审、插件检查和真实DSH新对话/案例取消/填入/管理验证已通过;原安装保留。旧草稿往返人工核对受宿主侧栏隔离会话不可见限制,自动保护测试已通过,待补人工证据。 - [x] 45.5 按人工反馈取消管理弹窗,统一详情内联管理,修复确认层被背景卡片覆盖并验证。 + +## 46. 已安装技能统一检查更新 + +- [x] 46.1 确定主动统一检查、逐项更新与位置边界。 +- [x] 46.2 实现有界只读检查、上下文失效、进度汇总与筛选。 +- [x] 46.3 接入详情逐位置更新,保持原确认与库存变更失效。 +- [x] 46.4 测试、独立评审与真实DSH预览验收。 + +- [x] 46.5 优化统一检查的全量库存重复读取与同技能resolve重复请求,保留精确目标与上下文保护。 +- [x] 46.6 完成10/100/500位置规模测量、独立复审和真实DSH新协议回归。 + + +## 47. CLI 0.3.0 / DSH 0.6.0 配套发布收尾 + +- [ ] 47.1 按启停设计 §7 完成严格 v2 兼容修复和发布说明;正常查询/管理兼容 CLI 0.2.0,明确旧版显式覆盖/删除语义,不要求用户同步升级。 +- [ ] 47.2 在原生 Windows/macOS 补技能入口转换、五阶段核心进程强制终止恢复及两版 Host 独立启停场景;明确核心恢复不等于 Host 强杀恢复。 +- [ ] 47.3 补旧对话草稿往返保护的真实浏览器证据,核对候选完整性、旧公开包升级和真实新旧包同 HOME 交替操作、停用默认保护及未发布 v3 安全转换;拒写不计兼容通过。 +- [ ] 47.4 完成发布准备 PR、独立评审与 CI,先发布 CLI 0.3.0 并验证,再发布 DSH 0.6.0;核对公开字节、安装与保留原技能。 +- [ ] 47.5 同步路线图 #998 的已完成、暂缓及后续范围,记录最终发布结果与证据边界。 + +## 48. 多来源切换与当前来源更新检查 + +- [x] 48.1 兼容保存具名来源,复用 Host 配置 CAS、订阅及共享切换。 +- [x] 48.2 全来源可信安装投影、来源身份贯穿本地操作和断开后的管理。 +- [x] 48.3 单项及批量更新只允许当前来源,处理切换竞态和已受理事务。 +- [x] 48.4 设置页管理来源选择及其他来源本地详情,技能中心不展示来源选择器,保持精简操作。 +- [x] 48.5 完成 registry-switching-design.md 十项验收、CLI 兼容回归和真实 DSH 预览,更新用户文档。 +- [x] 48.6 补真实模型自然语言验收:参数引用、切源更新隔离、卡片操作后续对话;修复模型将来源地址误作 sourceRevision 的指引缺口,保留首轮失败证据。 + +## 49. 设置页信息层级与视觉优化 + +- [x] 49.1 两个胶囊Tab、内容卡片与统一来源选择/行内编辑,保留草稿和CAS契约。 +- [x] 49.2 验证跨Tab保存/重置、来源管理、只读/冲突/连接错误及明暗/窄屏。 +- [x] 49.3 更新真实Host预览与证据,区分测试来源能力和正式服务能力。 + +## 50. 升级变化预览与本地文件撤销 + +- [x] 50.1 按 change-preview-design.md 冻结顺序交互、精确基线、Host 私有契约与库存 v2 兼容边界。 +- [x] 50.2 实现并验证原始发布内容校验、只读本地变更清单及按文件受限差异;二进制/未知/超限显式表达。 +- [x] 50.3 实现逐文件及全部撤销的预检、确认、锁内复验和可恢复事务,保留版本、归属及启停状态。 +- [x] 50.4 实现持久备份并继续、精确快照授权及最终升级复验,备份失败拒绝覆盖,不增加通用 force 或版本回滚。 +- [x] 50.5 接入详情提前查看、先处理本地修改再查看版本变化的顺序页面;每文件差异桌面320px/窄屏240px限高,保留单主动作。 +- [x] 50.6 验证基线不可用、文件变化、源/上下文切换、权限、磁盘/备份失败、并发和代表性中断恢复;运行CLI及插件相关回归。 +- [x] 50.7 完成独立评审与真实DSH明暗/窄屏/键盘验收,分别记录核心、宿主、模型及平台证据;SaaS与发布独立安排。 + +第50章验证边界:CLI冻结隔离HOME完整860测试通过,DSH完整688测试通过,后续UI小修另做受影响回归;核心19专项75断言、独立审查及Linux真实Host/浏览器顺序升级、撤销、备份与漂移拒绝已验证。新增reset的原生Windows/macOS与五阶段SIGKILL矩阵尚未执行,不能用旧安装/启停crash回归替代。SaaS、真实模型与PR CI未在本轮执行。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index 4b4fff4cd..4d6a1179e 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -519,3 +519,62 @@ CLI 全量结果与后续专项分开记录,不声称最终新增用例都来 管理安装改为进入对应详情,右侧直接展示各位置管理;对话入口沿已有详情导航。安装与危险操作确认保留。修复portal位于卡片操作区z-index上下文导致背景按钮盖过确认框的问题;共享确认层移出卡片,原生dialog内保留top layer。 最终插件619项测试/12357断言、build/typecheck/lint通过。新版真实DSH市场/已安装详情、两位置、深浅色/360窄屏、确认取消和elementFromPoint背景遮挡验证通过,50份技能文件SHA不变。旧历史会话引用按预期拒绝,重新读取后进入详情。无模型调用、未重验旧RC;搜索drawer嵌套确认本轮未独立点击。外部证据real/inline-management-acceptance.md,包SHA256 de575972578be50f4860472cb436111467c0bf9f8dc66ebd6401871b7aa73f03。未提交或发布。 + + +### 已安装技能统一检查更新(2026-10-28) + +已实现用户主动发起的只读检查,最多3个并发、逐位置进度、技能级汇总、可更新/失败筛选及失败重试。进入页面不自动扫描,不提供批量更新。有效结果按itemKey传入详情,更新仍走原逐位置准备与确认;库存和来源/会话/工作区变化使旧引用失效。 + +最终 build/typecheck/lint、626项测试(12392次断言,89个文件)通过,独立复审无阻断发现。真实DSH预览检查12个安装位置,发现4个可更新技能;实际1.2.0→1.3.0进入原确认并取消。受控只读响应覆盖混合状态、并发、筛选重试、详情返回、取消与迟到响应;明暗主题及360窄屏无横向溢出。 + +来源/会话/同会话工作区切换、ABA和库存替换由真实React组件浏览器夹具验证;本轮未完成真实Host跨会话或工作区切换验收,不将组件证据扩大为Host实测。无模型调用、无安装执行写入,47份技能/项目/配置文件前后哈希一致。未提交、推送或发布。 + +外部证据:`dsh-update-overview-20261028/acceptance.md`、`review.md`、`final-check.log`、`browser-result.json`、`component-result.json`。运行包SHA256:`ec70b36c4629e2f8552dfc70c14f2e495f5cf2e2ca9d16e236c81ae3efec7cd6`;97个部署文件与最终包一致。 + +### 人工反馈:更新检查工具栏样式 + +搜索与操作同行,筛选改为紧凑按钮组,摘要合并且隐藏初始零统计与零失败。定向7项测试/33断言、build/typecheck/lint通过;真实DSH验证12处安装检查、筛选、失败重试、升级确认取消,以及键盘和明暗/窄屏布局。截图在主题切换稳定后采集并人工查看。无模型调用、无安装执行。补充证据见外部 `dsh-update-overview-20261028/toolbar/`;此前跨工作区Host验收边界不变。 + +### 统一检查更新:性能整改 + +统一检查改为DSH私有begin/check/end轮次,可信库存每轮读取一次并索引;逐目标复核保留,库存文件身份或上下文变化使整轮失效。同技能远端resolve每轮去重,Host实际并发最多3。轮次数量最多4、空闲60秒/硬上限30分钟;取消初始化时保留槽至真实构建结束,避免槽外堆积。原单项检查与升级准备完整验证不变;不新增Registry/SaaS协议或CLI命令。 + +真实临时文件系统10/100/500处安装:库存读取均1次,metadata读取30/300/1500次,库存stat43/403/2003次;快照与核验耗时35/186/838ms,仅为本机观测,不包含网络。Host受控远端对应resolve5/50/250次(每技能两位置),额外验证12并发调用实际远端不超过3;未测旧版500项墙钟耗时或生产SaaS负载。 + +最终插件build/typecheck/lint和636测试/12451断言通过;CLI共享核心build/typecheck/lint和809测试/3578断言通过。独立复审18项定向测试通过,无剩余阻断。覆盖库存ABA/原子替换/metadata变化、会话与来源上下文、TTL、取消和初始化槽位保护。 + +真实DSH最终包两轮12/12扫描通过;等待begin返回时取消,迟到响应不启动检查,随后重扫成功。混合结果/失败重试/详情往返/原升级确认取消及主题窄屏在同逻辑候选包验证,最终补丁只修初始化取消槽位。实际Host跨工作区切换仍未完成,组件竞态验证单独记录。无模型调用、无安装执行,47份本轮基线文件不变;历史release-checklist已在本轮前被调整,未覆盖用户状态。 + +最终包SHA256 `2befcc2fd11e9da294183afe8b9e5c3f3d9dfb291b0c0072114fc22e76fce130`,97个运行文件与包一致。外部证据 `dsh-update-overview-20261028/performance/summary.md`、`review.md`、`browser/acceptance.md`、`browser/final-validation.md`。未提交、推送或发布。 + + +### 0.6.0 发布前兼容审计与待验收(2026-10-29) + +此前“库存/日志 v3、旧包拒写”记录保留为历史实现证据,不再作为当前兼容验收标准。正式协议已调整为启停设计 §7:严格 v2 库存、可信入口派生状态,普通日志兼容旧 DSH,启停日志隔离影响。正常查询与普通目标管理必须兼容已发布 CLI 0.2.0;旧版显式 force 覆盖和 remove 保留原语义,不承诺其拥有新版启停保护。实现与真实旧包交替验收进行中,发布阻断,尚无修复通过结论。 + +旧候选 HEAD 36adc3b 的插件 647 测试通过不证明后续兼容修复通过。Linux 草稿往返在独立原生分支会话验证取消/选案例不发送及返回旧草稿原字节保持;旧候选运行字节与预览等价,只标复用。全新空白会话受宿主侧栏隐藏规则影响,不扩大为所有空会话验证。 + +Windows Node 24.2.0、DSH 0.2.0-rc.2、Chrome 156 的新隔离 Profile 已通过官方命令安装旧候选并进入技能中心和安装弹窗,但选择“仅此项目”未生效,尚未确认可写上下文或产品原因;未提交安装、未调用模型。启停确认/取消、详情和 NTFS 转换未执行,因此 5.4 Windows 增量不标通过。保留失败证据,未改用户原预览。 + + +兼容审查补充:日志搬迁改为先排他 link 原记录,持久化目标目录后 unlink 源记录并持久化源目录,再写扩展后继;双份需完整相等,原字节 SHA 备份不覆盖。独立定向日志/投影 15 测试、74 断言及 CLI typecheck 通过,不代表全套与真实旧包矩阵完成。无效入口投影为 unreadable,Host 不再默认 enabled,补充卡片与模型上下文测试通过。 + +旧消费者不必理解 activation 日志。中断启用后的合法标准入口仍可能被旧版正常管理;新恢复在写入前必须比较身份、版本、installedAt、指纹与归属,变化即 unknown 并保留现场。该有限保证替代“旧版必能阻止全部待恢复操作”的过度承诺;真实旧包在 moved 阶段写入后的恢复验收仍须单列。CLI 安全转换 v3 不扫描全机 Host 日志,保留原日志与技能文件;NativeRuntime 处理自己日志域的 pending 恢复。 + + +### Windows 官方 Host 准备超时诊断(2026-10-30) + +DSH 0.6.0 发布检查在 Windows / DSH 0.2.1-rc.2 的 `install-host` 阶段两次达到 600 秒上限,均未进入技能探针;不能将该失败解释为启停断言失败。公开下载缓存存在不证明依赖解析或解包已加速。诊断摘要新增有界、白名单 npm 阶段计时及缓存观察计数,原始日志仍私有;计数不是去重请求数或完整缓存命中率。 + +本机诊断发现新版 Host 依赖树存在多份嵌套同版本 Zod。隔离 npm Registry 的最小相容范围实验中,SDK 范围 `^3.25.0 || ^4.0.0` 与十二个 exact `4.4.3` 消费方共存:默认解析为根 `4.6.7` 加十二份 `4.4.3`,`--prefer-dedupe` 则保留一份 `4.4.3`;两棵树均通过 npm 依赖约束检查。 + +候选调整仅在官方 Host 准备命令增加 `--prefer-dedupe`,不添加 Zod override、直接依赖、忽略 peer 的选项,也不改变安装脚本禁用、空白 Profile、进程清理、600 秒准备上限或业务断言。它可能在原范围内选择较旧的兼容依赖,因此仍须验证实际 Host 依赖身份和完整探针。 + +真实 Windows Node 24.2.0 / npm 11.3.0 的新版 Host lock-only 对照确认:默认 687 个安装位置、51 份 Zod,prefer-dedupe 为 636 个安装位置、1 份 Zod。本机两次完整冷安装仍达到 600 秒上限;减少重复依赖并不证明冷安装超时已解决。 + +候选提交 `74e6d93b7` 的 [CI 40996963179](https://github.com/iflytek/skillhub/actions/runs/40996963179) 中,旧版 Host 的 Windows Node 24.2.0 完整探针通过,准备耗时 348647ms;Windows Node 24 的准备完成(459541ms),但随后技能可用性探针在 user-install 的 accepted 断言失败。Linux/macOS 基础检查通过,后续官方 Host 和启停矩阵因前置失败未执行,不能算通过。安装工具返回状态与固定失败码需要补充诊断;保留现有断言。最终发布包验收仍待完成。修改验收脚本不等于改变已发布 CLI 或 DSH 产品运行逻辑。 + + +后续固定提交 `a431833b33bcf7523949537c50b9409c00c70ce3` 的 [CI 41011761831](https://github.com/iflytek/skillhub/actions/runs/41011761831) 已通过全部 14 项 DSH 检查。Windows / Host 0.2.1-rc.2 的启停与关闭监听场景准备分别耗时 348044ms、295943ms;Host 0.2.0-rc.2 分别为 291219ms、322063ms,均在全新 Profile 中通过真实探针。此前安装接受断言失败本轮未复现;新增诊断保留其固定状态和错误码,不修改断言。 + +本机部分缓存复测仍在 600787ms 超时,因此上述结果证明该固定提交的 CI 矩阵通过,不保证所有 Windows 环境在 600 秒内完成。旧 `dsh-v0.6.0` 标签保持不变;后续发布候选调整为 DSH 0.6.1,使用独立新标签。CLI 0.3.0 已发布,不重复发布,也不发布 SkillHub 应用 Release。0.6.1 发布身份、安装包及公开 npm 验收按发布流程单独验证。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index ce204ede5..7ba52a5b8 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -12,11 +12,13 @@ Version `0.4.0` uses the independent `dsh-v0.4.0` tag. It adds versioned example `0.5.0` adds read-only skill availability diagnosis in installed cards and conversations, with separate integrity, discovery and invocation-policy results. It retains the compatibility and inventory format of `0.4.0`. +`0.6.1` includes managed enable/disable, unified read-only update checks, and compact horizontal installed cards with a More menu. Shared inventory remains v2 without a coordinated CLI upgrade. Compatibility acceptance with the actual CLI 0.2.0 package has passed. Check npm for public availability. + ## Use an installed skill Market, installed and everyday conversation lists show one card per skill. “使用技能” asks the Host to verify the unique installation actually usable in the current session. Catalog use creates a conversation in the same workspace; conversation use keeps the current session. Choose an example or custom task to insert an editable draft. This never sends a message, invokes a model or overwrites the original draft. -“管理安装” opens skill details, where the inline installation panel shows each location’s version, state and explicit use/update/enable/disable/removal actions. Disabled or unverified skills lead to management. Installation scope remains in advanced confirmation options, with the current-user default unchanged. +In the installed card’s More menu, “管理安装” opens skill details, where the inline installation panel shows each location’s version, state and explicit use/update/enable/disable/removal actions. Disabled or unverified skills lead to management. Installation scope remains in advanced confirmation options, with the current-user default unchanged. An active session is required. Shadowed skills, modified files, disabled user invocation or changed context are reported without modifying the draft. Historical receipts are not proof of current installation. This feature is available from `0.4.0`. @@ -76,8 +78,8 @@ Published `0.1.0` still requires the original Cordis `4.0.4` baseline and does n Query the exact npm version first and install only after confirming availability. Check DSH, Node and a complete Cordis combination listed above, stop the target Web Profile Host, then install the plugin. A verified local tarball can also be installed using the next section: ```sh -npm view @astron-team/skillhub-dsh@0.5.0 version -dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.5.0 --ignore-scripts +npm view @astron-team/skillhub-dsh@0.6.1 version +dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.6.1 --ignore-scripts dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open ``` @@ -85,9 +87,9 @@ Replace `skillhub-test` with the intended Web Profile; initial creation is docum ## Upgrade from an older version -`0.4.0` uses inventory schema v2 with per-location versions. The installation core remains bundled, so a separate CLI is unnecessary. If the same user also manages these skills through SkillHub CLI, upgrade every such CLI to `0.2.0` before upgrading the plugin and stop scheduled/background writes from old clients. Confirm availability with `npm view @astron-team/skillhub@0.2.0 version`, then run `npm install -g @astron-team/skillhub@0.2.0`, verify `skillhub version`, then install the exact plugin version above. +Shared inventory remains strict v2 without a persisted `installationState`; activation is derived from trusted `SKILL.md` / `SKILL.md.disabled` entries. A coordinated CLI upgrade is not required. Published CLI `0.2.0` must retain normal queries and ordinary-target management; default update, sync and Suite paths must not unintentionally enable disabled targets. Historical explicit force install, upgrade and sync retain their overwrite semantics and may restore the standard entry; explicit remove retains deletion semantics. New readers must reflect these actual file changes. -Reading v1 inventory does not migrate it. Before the first v2 write, a byte-verified `inventory.v1..backup.json` is retained beside the inventory. User and project copies may retain different versions. Old CLI/v2 inventory mixed writes are unsupported. Once locations have changed independently, restoring the old inventory or downgrading the plugin alone is not a rollback: skill files, installation metadata and operation records must also be reconciled. +Reading v1/v2 does not migrate it. Any v3 inventory from unpublished builds requires identity, entry and pending-transaction validation, an original-byte backup under the same lock, and safe conversion to v2. Never restore a stale backup over newer records. Ordinary journals remain readable by historical DSH; toggle journals isolate compatibility impact. Compatibility acceptance using alternating operations with actual historical packages has passed; check npm for public availability. Authors provide examples at the fixed `.skillhub/examples.json` path inside each skill package. The file keeps its location after installation and follows the skill's version and fingerprint; no separate global example copy is created. Skills without examples use a custom task; invalid declarations remain ordinary files without exposing invalid examples. @@ -118,16 +120,21 @@ The Loader entry ID is `astron-skillhub`; `skillhub` is the Host/Remote service Open DSH **Plugins → @astron-team/skillhub-dsh → SkillHub 设置**. -1. Enter the HTTP(S) instance base address in **Registry 地址**. A deployment subpath is allowed; credentials, query parameters, fragments and API endpoint suffixes are not. +1. Select, add or edit a source in the **技能来源** card. Use an HTTP(S) instance base address. A deployment subpath is allowed; credentials, query parameters, fragments and API endpoint suffixes are not. 2. Select **测试连接** (Test connection) to test the current draft URL without saving it. The result reports public catalog or basic-search availability and elapsed time. Requests have a 10-second timeout; failures can be retried. Editing the address clears the previous result. Success does not establish download or all optional-module availability. -3. Choose visibility for versions, security audit and reviews. +3. Open **页面显示** (Page display) to choose visibility for versions, security audit and reviews. Switching tabs retains the draft and does not save it. 4. Select **保存配置** (Save). After the saved confirmation, open **技能中心** and search for a public test skill. -**已配置** means a nonempty address is saved, not that connectivity has been tested. An empty address disconnects the source. **重置修改** (Reset changes) restores the current Host snapshot without a network reload or save. Read-only connections cannot edit configuration. On a revision conflict, reset the draft before editing the current values. +Configure and switch sources under the **技能来源** tab in SkillHub settings. Selection and management share a single list; adding or editing reveals inline fields. Disconnecting takes effect only after saving and preserves installed skills. Both tabs share Save and Reset, applying all pending edits together. Select a saved source and save to apply it; the Skill Center header does not show a source selector. The active source is shared across pages and conversations of the same Profile/plugin instance. Configuration revision checks reject conflicting edits rather than overwriting another page. + +Switching preserves existing installations. Installed skills from other trusted sources remain locally usable and manageable, including availability checks, enable/disable and removal; only the current source participates in update checks and upgrades. Same-name skills from different sources remain distinct. Removing the active saved source disconnects without deleting installations; old-source details never borrow current-market content. + +**当前使用** identifies the saved active source; **待保存** identifies a pending selection. Neither indicates a successful connection test. Choose **不连接技能来源** and save to disconnect. **重置修改** (Reset changes) restores the current Host snapshot without a network reload or save. Read-only connections cannot edit configuration. On a revision conflict, reset the draft before editing the current values. | Field | Meaning | | --- | --- | | `registryUrl` | Registry base URL; defaults to `https://skill.xfyun.cn` when omitted; explicit empty/whitespace disconnects without fallback; custom values are preserved | +| `savedRegistries` | Optional named sources `[{ name, url }]`; defaults to an empty list. `registryUrl` remains the sole active source | | `detailTabs.versions` | Version history visibility | | `detailTabs.securityAudit` | Security audit visibility | | `detailTabs.reviews` | Read-only review visibility | @@ -138,7 +145,7 @@ Each visibility setting accepts `auto`, `show` or `hide`. Auto detects actual an - Browse supports search, server sorting, real labels and explicit **Load more**, normally 12 results per page. A first-page 404/405 can fall back to bounded legacy search, with an explicit pagination/sorting limitation. A failed label filter never silently returns unfiltered results. - Details show fixed-version documentation and optional tabs. Select a downloadable version before confirming installation. Existing-target protection remains; skill downgrades are not supported. -- Installed skills are grouped by identity and support keyword filtering. Open “管理安装” for independent location rows and precise confirmed operations; missing directories retain stale-record cleanup. +- Installed skills are grouped by identity and support keyword filtering. Compact horizontal cards use two columns on desktop and one on narrow screens. Click a card to open details; usable skills keep the primary use action. The more menu opens installation management or checks only this skill’s installation locations without applying updates. Cards show locally installed names, one-line summaries and actual installed versions, marking differing copies as multiple versions. Up to two semantic badges show disabled/unverified installation and update status (blue for updates, gray for disabled, amber for warnings), without a redundant installed label. Details provide independent location rows and precise confirmed operations; missing directories retain stale-record cleanup. - `astron_skillhub_search` only searches. Result cards provide a detail action; the drawer offers explicit installation confirmation. - An explicit request to install a uniquely selected result does not require opening the drawer first. `astron_skillhub_install` verifies the search-time version and content snapshot. It accepts a `selectionRef` or a full coordinate, exclusively, and a `user`/`project` scope. Expired or ambiguous references are rejected without switching to current latest. @@ -211,7 +218,7 @@ Version selection is read-only. A verifiable newer selected version offers an ex `astron_skillhub_list_installed` lists current-source managed records in the current user/project roots, not every skill on the machine. `astron_skillhub_check_update` checks the latest or an explicitly requested newer version. `astron_skillhub_upgrade` and `astron_skillhub_uninstall` require an exact installed reference and real human confirmation, including explicitly selected scopes. Without a native question provider they return a confirmation card. Models cannot authorize operations using paths, plan IDs or confirmation flags. `astron_skillhub_operation` reads the original session-owned receipt without resubmitting a mutation. -New conversational installs default to user scope after an explicit install request. Project installation is explicit/advanced. Updating one location preserves other locations' versions. Local edits and stale references block mutations. CLI installations sharing this inventory must be upgraded together; old CLI/new inventory mixed writes are unsupported. +New conversational installs default to user scope after an explicit install request. Project installation is explicit/advanced. Updating one location preserves other locations' versions. Local edits and stale references block mutations. Shared inventory remains compatible with published CLI 0.2.0; historical explicit overwrite and removal retain their original semantics. ### Skill availability diagnosis (0.5.0) @@ -221,7 +228,7 @@ Choose “管理安装 → 检查可用性” on a conversational installed-skil The result distinguishes local integrity, current-session discovery, and user/model invocation policies. Shadowing, local modifications and unknown Host state are separate outcomes. A name-only lookup reports Host observations without proving installation ownership or integrity. Diagnosis never modifies, removes or enables/disables a skill, edits a draft, or sends a message. A historical result is not a guarantee about a later invocation. -## Managed skill enable/disable (unreleased increment) +## Managed skill enable/disable (0.6.1) Open “管理安装” and choose enable or disable on the explicit location row. User and project copies are independent: disabling the user copy does not disable a project copy. UI and conversation mutations require real human confirmation. `astron_skillhub_disable` / `astron_skillhub_enable` accept fresh installation references, never arbitrary paths. @@ -229,7 +236,7 @@ Disabling changes only the entry from `SKILL.md` to `SKILL.md.disabled`, keeping This does not revoke loaded conversation content or stop tasks. Another same-named installation can remain available. Unverified Host cache observations remain unknown; the older RC may need a new session to observe a fallback copy. Removing this plugin does not enable its disabled installations. -The increment writes inventory v3 with per-location `installationState`. Reading v1/v2 is non-mutating; migration preserves the original bytes and a digest-addressed backup. The plugin bundles its compatible core and does not invoke an external CLI. Stop old writers and use the matching compatible CLI build before sharing inventory: published CLI `0.2.0` and `0.1.12` are incompatible. Release versions and order are not assigned yet. An old inventory backup alone is not a downgrade procedure. Native Windows/macOS entry conversion still requires acceptance before release. +Protocol and real historical-package interoperability remain release gates; see above. The detail-header website link uses only the detail API’s optional `webUrl`. Missing, null, blank or unsafe values hide the action without breaking skill details or installation. There is no plugin website setting, route construction or Registry fallback; legacy `websiteUrl` settings are ignored. The open-source server uses its trusted `SKILLHUB_PUBLIC_BASE_URL` to supply an absolute link, or null when no valid website base is configured. SaaS must provide this additive API field separately. @@ -238,3 +245,17 @@ Installation locations in the detail view expose secondary actions through a Mor Detail installation checks use a dismissible top-right notification. Ordinary success expires after five seconds; conflicts and failures remain dismissible. Repeated checks replace the notification, and leaving the skill clears it. Upgrade candidates and safety blocks stay in the installation row. Check notifications use a scope/version heading and one concise conclusion. Installation scope is directly visible without an advanced disclosure. Confirmation, progress and completion share a stable two-column layout (stacked on narrow screens), with a separate use action after completion. Default scope selection is unchanged. + +The installed page supports an explicit update check with up to three concurrent read-only requests and a 30-second timeout per installation. Progress, distinct upgradable skill counts, failed installation counts, filters, and failed-only retry are shown progressively. Opening details or leaving the list cancels pending checks; returning from details retains valid completed results. Source, session, workspace, or installation-reference changes invalidate old results. Details show checked target versions per location; execution still revalidates the target. Updates still require confirmation for the exact installation in details. + +### Release changes and local file modifications + +Installed skill details expose local changes before starting an upgrade. Comparisons use the original content from the installation's exact source, version and release fingerprint. An unavailable or invalid original is reported explicitly; another registry's same-named skill is never substituted. + +Upgrades check local changes first. Discard selected changes after confirmation, or choose **Back up changes and continue**. A successful backup opens a separate release preview; only **Confirm upgrade** replaces the installation. Cancelling leaves the installation intact and retains any completed backup. File differences load on demand, have bounded previews and independently scrollable content. + +Discarding changes restores files from the currently installed release: modified files are restored, local additions deleted and deleted release files recovered. Version, ownership and enabled/disabled state remain unchanged. Version downgrades and post-upgrade version rollback are not provided. Further file changes invalidate the prepared operation. + +Persistent backups live under `skillhub/backups` in DSH Home, outside skill directories. Limits are 100 MiB per backup, 1 GiB in total and 20 backups. Reaching a limit rejects the new backup; backups are not automatically deleted. Failed backups never authorize overwriting, and edits after backup require a fresh check. Local files are compared and backed up on the Host, never uploaded to the Registry. + +Manual update actions on conversation cards also open the change preview. Model tools retain their existing confirmation semantics for unmodified installations and cannot authorize backup-and-overwrite; local modifications still require handling in the page. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index 613e6944e..105d3244a 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -12,11 +12,13 @@ SkillHub 的 DSH 原生插件。在 DSH 侧栏打开 **技能中心**,匿名 `0.5.0` 新增技能卡片及对话中的只读可用性诊断,分别报告文件完整性、宿主发现和调用策略,保留 `0.4.0` 的兼容范围及库存格式。 +`0.6.1` 包含受管理技能启停、统一只读检查更新,以及横向已安装卡片和更多菜单。共享库存继续使用 v2,不要求用户同步升级 CLI;已通过与 CLI 0.2.0 的真实旧包兼容验收。公开可用状态以 npm 为准。 + ## 使用已安装技能 市场、已安装列表和对话中的日常列表均按技能展示。点击“使用技能”,由 Host 核验当前会话实际可用的唯一安装;不会让用户猜安装位置。技能中心入口在同一工作区新建对话,对话内入口保留当前会话。案例显示在原生输入框上方,确认案例或自定义任务后填入可编辑草稿;不会自动发送、调用模型或覆盖原会话草稿。 -“管理安装”进入技能详情,右侧直接展示每个位置的版本和状态,可明确使用此位置、检查更新、启停或卸载。全停用或状态无法确认时先进入管理安装。安装范围放在安装确认的高级选项中,未安装技能仍默认安装到当前用户。 +已安装卡片的更多菜单中,“管理安装”进入技能详情,右侧直接展示每个位置的版本和状态,可明确使用此位置、检查更新、启停或卸载。全停用或状态无法确认时先进入管理安装。安装范围放在安装确认的高级选项中,未安装技能仍默认安装到当前用户。 需要当前活动会话。被项目同名技能覆盖、本地文件变更、禁止用户调用或会话/来源变化时会提示原因;历史回执不能代替当前安装状态。此功能自 `0.4.0` 起提供。 @@ -76,8 +78,8 @@ npm pack --ignore-scripts --pack-destination /absolute/path/to/candidate-output 先查询 npm 上的精确版本,确认可用后安装。核对 DSH、Node 和上表完整 Cordis 组合,停止目标 Web Profile 的 Host,再执行插件安装;也可按下一节安装已验证的本地 tarball: ```sh -npm view @astron-team/skillhub-dsh@0.5.0 version -dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.5.0 --ignore-scripts +npm view @astron-team/skillhub-dsh@0.6.1 version +dsh plugin --profile skillhub-test add @astron-team/skillhub-dsh@0.6.1 --ignore-scripts dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open ``` @@ -85,9 +87,9 @@ dsh --profile skillhub-test --host 127.0.0.1 --port 0 --no-open ## 从旧版本升级 -`0.4.0` 使用按安装位置记录版本的库存 v2。插件仍内置安装核心,无需为了使用插件单独安装 CLI;如果同一用户还使用 SkillHub CLI 管理这些技能,先将这些 CLI 升级到 `0.2.0`,再升级插件,并停止旧版 CLI 的定时或后台写入。先用 `npm view @astron-team/skillhub@0.2.0 version` 确认可用,再使用 `npm install -g @astron-team/skillhub@0.2.0`,确认 `skillhub version`,再执行上面的精确插件版本安装命令。 +共享库存保持严格 v2,不写入 `installationState`;启停状态从可信的 `SKILL.md` / `SKILL.md.disabled` 入口派生,不要求 CLI 用户同步升级。已发布 CLI `0.2.0` 的正常查询和普通目标管理必须继续兼容;默认更新、同步及 Suite 不得无意重新启用停用目标。旧 CLI 显式 force 重装、更新、同步仍按原覆盖语义执行,可能恢复标准入口;显式 remove 仍可删除,新版重新读取须反映真实变化。 -读取旧库存不迁移;首次写入 v2 前,在库存同目录保存经字节核对的 `inventory.v1..backup.json`。同一技能的用户与项目副本可保留不同版本。旧 CLI 与 v2 库存混写不受支持;已分位置更新后,不能只恢复旧库存文件或降级插件来回退,必须同时核对实际技能文件、安装元数据和操作记录。 +读取 v1/v2 不迁移。未发布构建产生的 v3 必须在身份、入口与未完成事务校验后,同锁保存原字节备份并安全转换为 v2;不能恢复过时备份覆盖新记录。旧 DSH 普通管理日志保持兼容,启停日志隔离影响。已通过真实旧包交替操作的兼容验收;公开可用状态以 npm 为准。 案例由作者随技能包提供固定文件 `.skillhub/examples.json`,安装后保持相同位置,跟随技能版本和指纹,不创建另一个全局案例副本。无案例时使用自定义任务;无效声明按普通文件保留,不提供错误的案例入口。 @@ -118,16 +120,23 @@ Loader entry ID 为 `astron-skillhub`;`skillhub` 是 Host/Remote 服务名。 进入 DSH **Plugins/插件 → @astron-team/skillhub-dsh → SkillHub 设置**。 -1. 在“技能来源”填写 **Registry 地址**,使用实例的 HTTP(S) 基础地址,可包含部署子路径;不要填写 API 路由、凭证、查询参数或片段。 +1. 在“技能来源”卡片中选择来源,或添加、编辑来源。地址使用实例的 HTTP(S) 基础地址,可包含部署子路径;不要填写 API 路由、凭证、查询参数或片段。 2. 点击“测试连接”,使用当前草稿地址测试,不保存配置。结果显示公开目录可读或仅支持基础搜索及耗时;最长等待 10 秒,失败或超时可重试。修改地址后清除旧测试结果,连接成功不代表技能下载或所有可选接口可用。 -3. 在“详情标签”设置版本、安全审核及评论的显示方式。 +3. 切换到“页面显示”标签,设置版本、安全审核及评论的显示方式;切换标签保留草稿,不会自动保存。 4. 点击“保存配置”,看到“配置已保存”后进入侧栏“技能中心”搜索公开测试技能。 -“已配置”只表示 Host 已保存非空来源,不代表连通测试成功。空地址表示断开来源;“重置修改”恢复当前 Host 配置快照,不发起网络重载或保存。只读连接不可编辑。若保存发生 revision 冲突,先重置修改,再基于当前值编辑。 +来源配置与切换统一放在 SkillHub 设置的“技能来源”标签中,选择保存的来源后保存生效;技能中心顶部不展示来源选择器。当前来源由同一 Profile/插件实例的所有页面和会话共享;并发编辑通过配置版本校验,冲突时不会覆盖其他页面。 + +切换来源不删除既有安装。“已安装”保留其他来源的受管技能,支持本地使用、诊断、启停与卸载;更新检查和升级仅适用于当前来源。不同来源同名技能分开处理。删除当前保存来源会断开连接,已安装技能保留;其他来源详情不借用当前市场内容。 + +来源选择与管理使用同一列表,新增或编辑时才展开行内字段;不会同时展示两份地址输入。两个标签共用“保存配置”和“重置修改”,一次处理所有未保存改动。断开连接也在保存后生效,不删除已安装技能。 + +“当前使用”表示已保存的来源,“待保存”表示当前草稿选择,都不代表连接测试成功。选择“不连接技能来源”并保存可断开;“重置修改”恢复当前 Host 配置快照,不发起网络重载或保存。只读连接不可编辑。若保存发生 revision 冲突,先重置修改,再基于当前值编辑。 | 字段 | 行为 | | --- | --- | | `registryUrl` | Registry 基础地址;缺省为 `https://skill.xfyun.cn`;显式空串或仅空白断开,不回退;自定义值保留 | +| `savedRegistries` | 可选具名来源列表 `[{ name, url }]`;缺省为空,`registryUrl` 仍是唯一当前来源 | | `detailTabs.versions` | 版本历史显示策略 | | `detailTabs.securityAudit` | 安全审核显示策略 | | `detailTabs.reviews` | 只读评论显示策略 | @@ -138,7 +147,7 @@ Loader entry ID 为 `astron-skillhub`;`skillhub` 是 Host/Remote 服务名。 - **浏览技能**:搜索、服务端排序、真实标签筛选、点击加载更多。新目录接口每批 12 项;旧来源首批 404/405 可回退受限搜索,明确提示不支持分页和排序。选中标签失败不会退回未过滤结果。 - **技能详情**:查看文档、版本和可选模块;可“查看此版本”后确认安装指定的可下载版本。已有其他版本时保留覆盖保护,不执行降级。 -- **已安装**:按技能聚合并支持关键词过滤。默认提供使用与管理安装;详情右侧按位置显示版本、状态和操作,升级、启停与卸载均只影响确认的位置。目录缺失时仍可清理失效记录。 +- **已安装**:按技能聚合并支持关键词过滤。紧凑横向卡片在桌面显示两列、窄屏一列,点击卡片进入详情。可用技能保留“使用技能”主按钮,更多菜单提供“管理安装”和只检查当前技能各安装位置的“检查更新”,不自动执行更新。展示本地技能文件中的名称、一行摘要与实际安装版本;不同位置版本不同时明确标注多个版本。右上角最多显示安装异常/停用与更新状态两个标签(可更新为浅蓝、停用为灰、异常为琥珀色),正常已安装不重复标记。详情右侧按位置显示版本、状态和操作,升级、启停与卸载均只影响确认的位置。目录缺失时仍可清理失效记录。 - **对话搜索**:`astron_skillhub_search` 只搜索,不安装或执行技能。结果提供明确详情动作;详情抽屉可确认安装。 - **对话安装**:用户明确要求安装唯一搜索结果时,不必先打开详情。`astron_skillhub_install` 核验搜索时的精确版本和快照;接受 `selectionRef` 或完整坐标,二者互斥,范围为 `user`/`project`。过期或歧义引用拒绝执行,不改装当前 latest。 @@ -216,7 +225,7 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh - `astron_skillhub_upgrade` / `astron_skillhub_uninstall`:必须使用列表中的精确引用,并由 Host 发起真实用户确认。模型不能传路径、确认标志或执行计划。原生问答不可用时返回确认卡;取消不修改文件。 - `astron_skillhub_operation`:以原回执查询本会话操作结果,不重复提交操作;历史成功不代表当前仍安装。 -多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。启用逐位置库存后,同机共同管理技能的 CLI 必须同步升级;旧版 CLI 与新库存混用不受支持。 +多份安装先选择目标;更新不改变其他位置版本。本地修改、来源/会话变化和失效引用阻止操作。共享库存兼容已发布 CLI 0.2.0;旧版显式覆盖与删除仍遵循原语义。 ### 技能可用性诊断(0.5.0) @@ -226,7 +235,7 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 诊断分别核验本地文件、当前会话加载结果,以及用户/模型调用策略。同名技能生效、本地修改和宿主无法核验会给出不同结论。按技能名查询只说明当前宿主观察到的事实,不证明安装来源或内容完整性。检查不会修改、卸载、启停技能或发送消息;历史结果不保证下一次调用仍然可用。 -## 受管理技能启停(未发布增量) +## 受管理技能启停(0.6.1) 在“管理安装”中,对明确位置选择“停用”或“启用”。用户级和项目级副本独立管理;停用用户副本不会关闭项目副本。页面与对话均需真实用户确认,取消不写文件。对话工具 `astron_skillhub_disable` / `astron_skillhub_enable` 使用新查询中的位置引用,不能提交自由路径。 @@ -234,8 +243,22 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 这不撤回会话已加载的正文,也不终止任务;同名的其他安装可能继续可用。宿主缓存未确认时明确显示未知,不把文件操作成功当作发现已刷新。旧 RC 的同名接替可能需新会话才能观察到。卸载插件不会自动启用这些技能。 -本增量首次写入使用库存 v3(每位置 `installationState`),读取 v1/v2 不改写,迁移保留原始库存字节及摘要备份。插件内置兼容核心,仍不调用外部 CLI;同机使用 CLI 管理相同库存时,须先停止旧写入进程并使用配套兼容构建。现有公开 CLI `0.2.0`/`0.1.12` 不支持此协议,不可混写;配套正式版本与发布顺序另行确定。不能只恢复旧库存文件来降级,须核对入口、安装元数据及操作日志。原生 Windows/macOS 文件转换的实机验收仍是发布前门禁。 +协议与旧包交替操作验收仍是发布门禁,详见上文。 详情标题旁“在官网查看”仅使用详情API返回的可选`webUrl`。字段缺失、null、空白或不安全时隐藏按钮,详情与安装照常使用。插件不再提供网站地址配置、不拼路由、不从Registry回退;旧`websiteUrl`配置忽略。开源服务端复用可信`SKILLHUB_PUBLIC_BASE_URL`生成完整地址,未配置合法网站基址则返回null。SaaS需单独同步此可选字段。 安装确认直接展示安装范围,默认范围规则不变。确认、进行中与成功共用稳定的双栏布局,小屏自动纵排;右侧在提交前显示等待提示,完成后使用按钮与进度条分开显示。 + +已安装页可主动“检查更新”,以最多 3 个并发检查当前可见的安装位置,并渐进显示进度、可更新技能数与失败位置数;支持关键词叠加全部/可更新/检查失败筛选及失败重试。每项检查最多等待 30 秒,失败不会视为已最新。打开详情或离开列表会取消在途检查,详情返回保留仍有效的结果;来源、会话、工作区或安装引用变化后旧结果失效;进入详情后按位置展示已检查的目标版本,执行前仍重新核验。检查只读,更新仍需在详情按具体位置确认。 + +### 升级变化与本地文件修改 + +在已安装技能详情中可直接查看本地修改,不必先执行升级。比较以该安装来源、精确版本及发布指纹对应的原始内容为准;原文无法取得或校验失败时明确显示不可用,不以另一来源的同名技能替代。 + +升级先检查本地修改:可以确认撤销单个文件或全部修改,也可选择「备份修改并继续」。备份成功后进入独立版本变化预览,最后点击「确认升级」才更新技能。取消预览不改技能,也不会删除已创建的备份。文件差异按需读取,文本过大或二进制文件会显示预览限制;每个展开文件独立滚动。 + +撤销只恢复当前安装版本的文件:已修改文件还原原文、本地新增文件删除、本地删除文件恢复;已安装版本、归属及启停状态不变。不提供版本降级或升级后回滚。确认后文件再次变化时需重新检查。 + +持久备份保存在 DSH Home 的 `skillhub/backups` 目录,与技能目录分离;单份上限 100 MiB、总量 1 GiB、最多 20 份,达到上限拒绝新备份,首期不自动删除。备份失败不覆盖,备份后又有本地修改也需重新处理。本地文件仅在 Host 比较和备份,不上传 Registry。 + +对话卡片上的人工更新确认同样进入变化预览。模型工具仍保留原有无本地修改时的升级确认语义,不具备备份覆盖授权;发现本地修改时继续拒绝,需由用户进入页面处理。 diff --git a/plugins/dsh/bun.lock b/plugins/dsh/bun.lock index 6873c341b..b17ddab0f 100644 --- a/plugins/dsh/bun.lock +++ b/plugins/dsh/bun.lock @@ -16,6 +16,7 @@ "class-variance-authority": "0.7.1", "react-markdown": "10.1.0", "rehype-sanitize": "6.0.0", + "yaml": "2.9.1", "zod": "4.4.3", }, "devDependencies": { diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 98b6ada22..605158007 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub-dsh", - "version": "0.5.0", + "version": "0.6.1", "private": false, "type": "module", "license": "Apache-2.0", @@ -100,6 +100,7 @@ "class-variance-authority": "0.7.1", "react-markdown": "10.1.0", "rehype-sanitize": "6.0.0", + "yaml": "2.9.1", "zod": "4.4.3" }, "devDependencies": { diff --git a/plugins/dsh/scripts/probe-host.mjs b/plugins/dsh/scripts/probe-host.mjs index ea0e12a71..be8da8ced 100644 --- a/plugins/dsh/scripts/probe-host.mjs +++ b/plugins/dsh/scripts/probe-host.mjs @@ -1,6 +1,7 @@ +import { safeAvailabilityCheckpoint, safeToggleCheckpoint, safeFixtureLocation, safeOperationObservation, knownHostFailureCodes } from './smoke-native-host.mjs'; import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, realpath, writeFile, access } from 'node:fs/promises'; +import { mkdir, readFile, realpath, writeFile, access, open } from 'node:fs/promises'; import { openSync, closeSync } from 'node:fs'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; @@ -28,6 +29,11 @@ let stage = 'setup'; let selectedProbe; let observerStatusFile; let dependencyStatusFile; +let availabilityCheckpoint; +let toggleCheckpoint; +let fixtureLocation; +let operationObservation; +let hostLogFile; let failureCode = 'PROBE_FAILED'; function fail(code, message) { failureCode = code; throw new Error(message); } async function main() { @@ -77,6 +83,7 @@ await mkdir(run, { mode: 0o700 }); const resultName = `probe-${id}-result.json`; const resultFile = join(root, resultName); const observer = join(run, 'observer.mjs'); +hostLogFile = join(run, 'host.log'); observerStatusFile = join(run, 'observer-status.json'); dependencyStatusFile = join(run, 'dependency-status.json'); const runtimeProbe = values.probe === 'runtime-baseline' || values.probe === 'runtime-unsupported'; @@ -178,6 +185,12 @@ try { catch (error) { if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; } if (report) { if (report.passed !== true) { + if (['skill-availability', 'skill-toggle', 'skill-toggle-watch-off'].includes(values.probe)) operationObservation = safeOperationObservation(report.operation); + if (values.probe === 'skill-availability') availabilityCheckpoint = safeAvailabilityCheckpoint(report.checkpoint); + if (['skill-toggle', 'skill-toggle-watch-off'].includes(values.probe)) { + toggleCheckpoint = safeToggleCheckpoint(report.checkpoint); + fixtureLocation = safeFixtureLocation(report.location); + } stage = 'host-assertion'; fail('HOST_ASSERTION_FAILED', 'Host probe failed; inspect its result file.'); } @@ -204,6 +217,21 @@ try { } main().catch(async () => { const diagnostics = {}; + if (availabilityCheckpoint) diagnostics.availability = availabilityCheckpoint; + if (toggleCheckpoint) diagnostics.toggle = toggleCheckpoint; + if (fixtureLocation) diagnostics.location = fixtureLocation; + if (operationObservation) diagnostics.operation = operationObservation; + if (hostLogFile && ['skill-availability', 'skill-toggle', 'skill-toggle-watch-off'].includes(selectedProbe)) { + let file; + try { + file = await open(hostLogFile, 'r'); + const { size } = await file.stat(); + const bytes = Buffer.alloc(Math.min(size, 1024 * 1024)); + const { bytesRead } = await file.read(bytes, 0, bytes.length, Math.max(0, size - bytes.length)); + diagnostics.observedHostCodes = knownHostFailureCodes(bytes.subarray(0, bytesRead).toString('utf8')); + } catch { /* Diagnostics must not replace the original failure. */ } + finally { await file?.close().catch(() => {}); } + } for (const [key, file] of [['observer', observerStatusFile], ['dependencies', dependencyStatusFile]]) { if (!file) continue; try { diagnostics[key] = JSON.parse(await readFile(file, 'utf8')); } catch { /* Missing marker is itself diagnostic. */ } diff --git a/plugins/dsh/scripts/smoke-native-host.mjs b/plugins/dsh/scripts/smoke-native-host.mjs index ff9b5a832..117aa2171 100644 --- a/plugins/dsh/scripts/smoke-native-host.mjs +++ b/plugins/dsh/scripts/smoke-native-host.mjs @@ -1,9 +1,9 @@ import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { closeSync, openSync } from 'node:fs'; -import { access, mkdir, mkdtemp, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'; +import { access, chmod, open, lstat, mkdir, mkdtemp, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { delimiter, dirname, join, resolve } from 'node:path'; +import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; @@ -13,6 +13,12 @@ export function selectDshVersion(version, family = 'fresh') { if (version === '0.2.1-rc.2' && family === 'retained') throw new Error('DSH 0.2.1 requires the current Cordis family.'); return version; } +export function scenarioProbes(scenario = 'smoke') { + if (scenario === 'smoke') return ['runtime-baseline', 'plugin-lifecycle', 'skill-availability']; + if (scenario === 'skill-toggle' || scenario === 'skill-toggle-watch-off') return ['runtime-baseline', scenario]; + throw new Error('Unsupported Host scenario'); +} + const PNPM_VERSION = '10.33.0'; export const retainedCordisFamily = { @@ -113,6 +119,59 @@ export async function selectTarball(input) { return target; } +export function safeAvailabilityCheckpoint(value) { + if (!value || typeof value !== 'object') return undefined; + const scenarios = ['setup', 'user-install', 'user-winner', 'project-install', 'project-winner', 'user-shadowed', 'remote', 'by-name', 'content-modified', 'skill-missing', 'directory-missing', 'policy', 'absent', 'incomplete-provider', 'failed-provider']; + const checkpoints = ['setup', 'install', 'install-poll', 'reference', 'before-digest', 'execute', 'assertion', 'after-digest', 'mutation']; + const assertions = ['none', 'isError', 'kind', 'schemaVersion', 'association', 'scope', 'integrity', 'discovery', 'name', 'reason', 'invocation', 'installation', 'namespace', 'slug', 'version', 'fingerprint', 'render', 'privacy', 'unchanged', 'accepted', 'completed', 'reference']; + if (!scenarios.includes(value.scenario) || !checkpoints.includes(value.checkpoint) || !assertions.includes(value.assertion)) return undefined; + const result = { scenario: value.scenario, checkpoint: value.checkpoint, assertion: value.assertion }; + const statuses = ['accepted', 'not-confirmed', 'awaiting-confirmation', 'cancelled']; + // Mirror the public installationFailureCodes contract; never forward error messages or IDs. + const codes = ['CONTEXT_STALE', 'REQUEST_CONFLICT', 'SOURCE_NOT_CONFIGURED', 'SOURCE_DENIED', + 'SKILL_UNAVAILABLE', 'RATE_LIMITED', 'SOURCE_UNAVAILABLE', 'TARGET_UNAVAILABLE', 'UNCONFIRMED']; + if (statuses.includes(value.install?.status)) { + result.install = { status: value.install.status }; + if (codes.includes(value.install.code)) result.install.code = value.install.code; + } + return result; +} + +export function safeToggleCheckpoint(value) { + if (!value || typeof value !== 'object') return undefined; + const scenarios = ['setup', 'install', 'initial-discovery', 'watch-off-disable', 'watch-off-diagnosis', 'watch-off-uninstall', 'cancel', 'disable', 'disabled-files', 'disabled-use', 'enable', 'disable-again', 'disabled-upgrade', 'disabled-uninstall', 'same-name-install', 'same-name-disable', 'same-name-fallback', 'same-name-diagnosis', 'cleanup']; + const assertions = ['none', 'invoke', 'operation', 'discovery', 'equal', 'notEqual', 'deepEqual', 'rejects', 'truthy']; + if (!scenarios.includes(value.scenario) || !assertions.includes(value.assertion)) return undefined; + return { scenario: value.scenario, assertion: value.assertion }; +} + +export function safeFixtureLocation(value) { + if (value?.fixture !== 'skill-toggle-fixture.mjs' || !Number.isInteger(value.line) || value.line < 1 || value.line > 10000) return undefined; + return { fixture: value.fixture, line: value.line }; +} + +export function safeOperationObservation(value) { + if (!value || typeof value !== 'object') return undefined; + const fields = { + phase: ['accepted', 'staging', 'revalidating', 'committing', 'recovering', 'completed', 'failed', 'recovery-required', 'unknown'], + files: ['pending', 'installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed', 'enabled', 'disabled', 'failed', 'unknown'], + discovery: ['unconfirmed', 'confirmed', 'failed'], recovery: ['not-needed', 'restored', 'incomplete', 'unknown'], + }; + const safe = {}; + for (const [name, allowed] of Object.entries(fields)) if (allowed.includes(value[name])) safe[name] = value[name]; + if (typeof value.deadlineReached === 'boolean') safe.deadlineReached = value.deadlineReached; + return Object.keys(safe).length ? safe : undefined; +} + +const diagnosticCodes = ['EACCES', 'EPERM', 'ENOENT', 'EEXIST', 'ENOSPC', 'EBUSY', 'EXDEV', 'ENOTEMPTY', 'EIO', 'ELOCKED', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'AUTHORIZATION', 'NETWORK', 'FILESYSTEM', 'INVALID_INPUT', 'VALIDATION', 'UNCLASSIFIED']; +export function safeHostFailureCodes(value) { + return Array.isArray(value) ? diagnosticCodes.filter(code => value.includes(code)) : []; +} +export function knownHostFailureCodes(log) { + // Observed codes are diagnostic hints, not attribution of the assertion's cause. + return diagnosticCodes.filter(code => new RegExp('\\b' + code + '\\b').test(log)); +} + export function safeProbeFailure(log, probe) { const stages = ['setup', 'validate-root', 'prepare-fixture', 'fixture-ports', 'registry-ready', 'host-result', 'host-assertion']; const codes = ['PROBE_FAILED', 'REGISTRY_EXITED', 'REGISTRY_TIMEOUT', 'CHILD_EXITED', 'HOST_ASSERTION_FAILED', 'HOST_RESULT_TIMEOUT']; @@ -130,6 +189,20 @@ export function safeProbeFailure(log, probe) { if (['root-realpath', 'temporary-realpath', 'temporary-containment', 'home-identity'].includes(validation)) { safe.validation = validation; } + const availability = safeAvailabilityCheckpoint(result.diagnostics.availability); + if (probe === 'skill-availability' && availability) safe.availability = availability; + if (['skill-toggle', 'skill-toggle-watch-off'].includes(probe)) { + const toggle = safeToggleCheckpoint(result.diagnostics.toggle); + const location = safeFixtureLocation(result.diagnostics.location); + if (toggle) safe.toggle = toggle; + if (location) safe.location = location; + } + if (['skill-availability', 'skill-toggle', 'skill-toggle-watch-off'].includes(probe)) { + const operation = safeOperationObservation(result.diagnostics.operation); + if (operation) safe.operation = operation; + const codes = safeHostFailureCodes(result.diagnostics.observedHostCodes); + if (codes.length) safe.observedHostCodes = codes; + } safe.dependencies = {}; for (const name of ['loader', 'pluginPackages', 'clientModules', 'skillhub']) { const present = result.diagnostics.dependencies?.[name]; @@ -142,6 +215,85 @@ export function safeProbeFailure(log, probe) { return undefined; } +// npm emits completed timers incrementally with --timing, even when killed before +// its final timing.json. Never expose arbitrary timer names, URLs or log lines. +const npmPreparationLogLimit = 1024 * 1024; +export function summarizeNpmPreparation(log, truncated = false) { + const phases = new Set(['idealTree', 'idealTree:init', 'idealTree:userRequests', 'idealTree:buildDeps', + 'idealTree:fixDepFlags', 'reify', 'reify:loadTrees', 'reify:diffTrees', 'reify:retireShallow', + 'reify:createSparse', 'reify:loadBundles', 'reify:unpack', 'reify:unretire', 'reify:build', + 'reify:trash', 'reify:save', 'command:install']); + const result = { completedTimingsMs: {}, lastCompletedPhase: null, + cacheObservations: { hit: 0, miss: 0, revalidated: 0 }, truncated: truncated || log.length > npmPreparationLogLimit }; + let bounded = log.slice(-npmPreparationLogLimit); + if (result.truncated) { + const newline = bounded.indexOf('\n'); + bounded = newline < 0 ? '' : bounded.slice(newline + 1); + } + for (const line of bounded.split('\n')) { + const timer = /^npm timing ([A-Za-z:]+) Completed in ([0-9]+)ms\r?$/.exec(line); + if (timer && phases.has(timer[1])) { + const elapsed = Number(timer[2]); + if (Number.isSafeInteger(elapsed) && elapsed <= 3600000) { + result.completedTimingsMs[timer[1]] = elapsed; + result.lastCompletedPhase = timer[1]; + } + } + const request = /^npm http fetch GET [0-9]{3} \S+ [0-9]+ms \(cache (hit|miss|revalidated)\)\r?$/.exec(line); + if (request) result.cacheObservations[request[1]]++; + // npm also logs package-cache lookups separately from HTTP fetch responses. + if (/^npm http cache \S+ [0-9]+ms \(cache hit\)\r?$/.test(line)) result.cacheObservations.hit++; + } + return result; +} + +async function readPreparationLog(path) { + let file; + try { + file = await open(path, 'r'); + const { size } = await file.stat(); + const buffer = Buffer.alloc(Math.min(size, npmPreparationLogLimit)); + const { bytesRead } = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length)); + return { text: buffer.subarray(0, bytesRead).toString('utf8'), truncated: size > buffer.length }; + } catch { return { text: '', truncated: false }; } + finally { await file?.close(); } +} + +// Only npm's public download content is shared; every Host/config/profile stays fresh. +export async function prepareDownloadCache(path) { + if (!isAbsolute(path)) throw new Error('Download cache must be an absolute temporary directory.'); + const bases = [tmpdir()]; + if (process.env.CI && process.env.RUNNER_TEMP && isAbsolute(process.env.RUNNER_TEMP)) bases.push(process.env.RUNNER_TEMP); + const target = resolve(path); + for (const base of bases) { + const basePath = resolve(base); + const canonicalBase = await realpath(basePath); + // Only the trusted anchor may be canonicalized: never resolve unverified descendants. + const suffix = [basePath, canonicalBase].map(anchor => relative(anchor, target)) + .find(value => value && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value)); + if (!suffix) continue; + let current = canonicalBase; + const parts = suffix.split(sep); + for (const [index, part] of parts.entries()) { + current = join(current, part); + await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; }); + const info = await lstat(current); + if (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid()) + || (process.platform !== 'win32' && index < parts.length - 1 && (info.mode & 0o022) !== 0)) { + throw new Error('Download cache must contain only current-user directories without links.'); + } + } + await chmod(current, 0o700); + const content = join(current, '_cacache'); + const info = await lstat(content).catch(error => { if (error.code !== 'ENOENT') throw error; }); + if (info && (!info.isDirectory() || info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid()))) { + throw new Error('Invalid download content directory.'); + } + return { path: current, populated: Boolean(info && (await readdir(content)).length) }; + } + throw new Error('Download cache must be below the current temporary directory.'); +} + export function isolatedEnvironment(root, inherited = process.env) { // In particular, do not inherit model credentials, DSH overrides or Node preload hooks. const allowed = /^(PATH|SystemRoot|WINDIR|COMSPEC|PATHEXT|TEMP|TMP|TMPDIR|LANG|LC_ALL|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|CI)$/i; @@ -154,6 +306,7 @@ export function isolatedEnvironment(root, inherited = process.env) { APPDATA: join(root, 'appdata'), LOCALAPPDATA: join(root, 'localappdata'), DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', npm_config_cache: join(root, 'npm-cache'), npm_config_userconfig: join(root, 'empty.npmrc'), + npm_config_logs_dir: join(root, 'logs/npm'), npm_config_globalconfig: join(root, 'empty-global.npmrc'), CI: 'true' }; } @@ -169,14 +322,15 @@ async function findNpm() { } async function main() { - const { values } = parseArgs({ options: { 'dsh-version': { type: 'string', default: '0.2.0-rc.2' }, artifact: { type: 'string' }, report: { type: 'string' }, help: { type: 'boolean' }, 'cordis-family': { type: 'string', default: 'fresh' } } }); + const { values } = parseArgs({ options: { 'download-cache': { type: 'string' }, 'dsh-version': { type: 'string', default: '0.2.0-rc.2' }, artifact: { type: 'string' }, report: { type: 'string' }, help: { type: 'boolean' }, 'cordis-family': { type: 'string', default: 'fresh' }, scenario: { type: 'string', default: 'smoke' } } }); if (values.help) { - console.log('node scripts/smoke-native-host.mjs --artifact --report [--cordis-family fresh|retained] [--dsh-version 0.2.0-rc.2|0.2.1-rc.2]'); + console.log('node scripts/smoke-native-host.mjs --artifact --report [--download-cache ] [--scenario smoke|skill-toggle|skill-toggle-watch-off] [--cordis-family fresh|retained] [--dsh-version 0.2.0-rc.2|0.2.1-rc.2]'); return; } if (!values.artifact || !values.report) throw new Error('Specify --artifact and --report; see --help.'); const [major, minor] = process.versions.node.split('.').map(Number); if (major !== 24 || minor < 2) throw new Error('Use the fixed Node >=24.2.0 <25 baseline.'); + const probes = scenarioProbes(values.scenario); const dshVersion = selectDshVersion(values['dsh-version'], values['cordis-family']); const installManifest = hostInstallManifest(values['cordis-family']); const artifact = await selectTarball(values.artifact); @@ -185,6 +339,8 @@ async function main() { await mkdir(dirname(reportFile), { recursive: true }); const root = await mkdtemp(join(tmpdir(), 'skillhub-native-smoke-')); const env = isolatedEnvironment(root); + const downloads = values['download-cache'] ? await prepareDownloadCache(values['download-cache']) : undefined; + if (downloads) env.npm_config_cache = downloads.path; for (const directory of ['home', 'native-install-home', 'agents', 'workspace', 'logs']) await mkdir(join(root, directory), { mode: 0o700 }); for (const name of ['empty.npmrc', 'empty-global.npmrc']) await writeFile(join(root, name), '', { mode: 0o600 }); await writeFile(join(root, 'package.json'), JSON.stringify(installManifest)); @@ -193,14 +349,24 @@ async function main() { process.once('SIGINT', stop); process.once('SIGTERM', stop); const summary = { passed: false, platform: process.platform, node: process.versions.node, - dsh: dshVersion, pnpm: PNPM_VERSION, cordisFamily: values['cordis-family'], + scenario: values.scenario, dsh: dshVersion, pnpm: PNPM_VERSION, cordisFamily: values['cordis-family'], artifactSha256: createHash('sha256').update(await readFile(artifact)).digest('hex'), - modelCalled: false, probes: [], stage: 'install-host' }; + modelCalled: false, probes: [], preparation: { cacheMode: downloads ? 'shared-public-downloads' : 'isolated', cachePopulated: downloads?.populated ?? false }, stage: 'install-host' }; const run = (name, args, timeoutMs = 300000) => runBounded(args, { cwd: join(root, 'workspace'), env, log: join(root, 'logs', name + '.log'), timeoutMs, signal: abort.signal }); try { - await run('install-host', [npm, 'install', '--prefix', root, '--ignore-scripts', '--no-audit', '--no-fund', - '--registry=https://registry.npmjs.org', `@deepseek-ai/dsh@${dshVersion}`, `pnpm@${PNPM_VERSION}`], 600000); + const installStarted = Date.now(); + try { + // Prefer reuse within the original exact/range/peer constraints to reduce duplicate files. + await run('install-host', [npm, 'install', '--prefer-dedupe', '--timing', '--loglevel=http', '--prefix', root, '--ignore-scripts', '--no-audit', '--no-fund', + '--registry=https://registry.npmjs.org', `@deepseek-ai/dsh@${dshVersion}`, `pnpm@${PNPM_VERSION}`], 600000); + } finally { + env.npm_config_cache = join(root, 'npm-cache'); + summary.preparation.elapsedMs = Date.now() - installStarted; + const installLog = await readPreparationLog(join(root, 'logs/install-host.log')); + summary.preparation.observedCodes = knownHostFailureCodes(installLog.text); + summary.preparation.npm = summarizeNpmPreparation(installLog.text, installLog.truncated); + } const dsh = join(root, 'node_modules/@deepseek-ai/dsh/lib/bin.js'); await access(dsh); summary.stage = 'initialize-web'; @@ -217,7 +383,7 @@ async function main() { const manifest = JSON.parse(await readFile(join(root, 'home/profiles/skillhub-m0/node_modules/@astron-team/skillhub-dsh/package.json'), 'utf8')); if (manifest.name !== '@astron-team/skillhub-dsh') throw new Error('Unexpected installed candidate identity.'); const probeScript = fileURLToPath(new URL('./probe-host.mjs', import.meta.url)); - for (const probe of ['runtime-baseline', 'plugin-lifecycle', 'skill-availability']) { + for (const probe of probes) { summary.stage = probe; await run(probe, [probeScript, '--root', root, '--probe', probe], 150000); const lines = (await readFile(join(root, 'logs', probe + '.log'), 'utf8')).trim().split('\n'); @@ -238,7 +404,7 @@ async function main() { } catch (error) { summary.failure = ['SPAWN_FAILED', 'DEADLINE_OR_CANCELLED', 'PROCESS_FAILED', 'CLEANUP_DEADLINE'].includes(error.code) ? error.code : 'INVALID_RESULT'; - if (['runtime-baseline', 'plugin-lifecycle', 'skill-availability'].includes(summary.stage)) { + if (probes.includes(summary.stage)) { const log = await readFile(join(root, 'logs', summary.stage + '.log'), 'utf8').catch(() => ''); summary.probeFailure = safeProbeFailure(log, summary.stage); } diff --git a/plugins/dsh/src/client/catalog-page.tsx b/plugins/dsh/src/client/catalog-page.tsx index 7fb4ce62a..bff181fad 100644 --- a/plugins/dsh/src/client/catalog-page.tsx +++ b/plugins/dsh/src/client/catalog-page.tsx @@ -1,3 +1,4 @@ +import { sameRegistry } from './registry-config.js'; import { SkillWebsiteLink } from './skill-website-link.js'; import { UpgradeControl } from './upgrade-control.js'; import { RemovalControl } from './removal-control.js'; @@ -70,6 +71,15 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa const context = useQuery({ queryKey: ['skillhub-context'], queryFn: async () => unwrap(await remote.getContext()), retry: false }); const compatibility = useQuery({ queryKey: ['skillhub-compatibility'], queryFn: async () => unwrap(await remote.getCompatibility()), retry: false }); const revision = context.data?.sourceRevision; + const previousRevision = useRef(revision); + useLayoutEffect(() => { + if (previousRevision.current && revision && previousRevision.current !== revision) { + setInstallCandidate(null); setUpgradeCandidate(null); setRemovalCandidate(null); + navigation.selected = null; setSelected(null); + navigation.installedOrigin = undefined; setInstalledOrigin(undefined); + } + previousRevision.current = revision; + }, [revision, navigation]); // Share the installed tab's cache and settled-operation invalidation, without blocking market reads. const inventory = useQuery({ queryKey: ['skillhub-installed', installed?.sessionId, revision], enabled: !!installed && !!revision && !!context.data?.configured && tab === 'browse', retry: false, @@ -117,8 +127,8 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa setSelected(item); } function manageSkill(item: NonNullable['items'][number], intent: 'remove' | 'upgrade') { - if (intent === 'remove') { if (revision && context.data) setRemovalCandidate({ item, revision, source: context.data.registryUrl }); return; } - if (revision && context.data) setUpgradeCandidate({ item, revision, source: context.data.registryUrl }); + if (intent === 'remove') { if (revision && context.data) setRemovalCandidate({ item, revision, source: item.registry ?? context.data.registryUrl }); return; } + if (revision && context.data) setUpgradeCandidate({ item, revision, source: item.registry ?? context.data.registryUrl }); } function error(message: string, retry: () => void) { return

{message}

; @@ -168,8 +178,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa

{compatibility.isError ? '无法读取 DSH 依赖版本核验结果。' : `当前 DSH 依赖组合未通过核验,安装、升级、卸载和恢复写入已禁用。请使用 ${compatibility.data?.candidateDshVersion} 对应的完整发行依赖。`}

} {context.isPending ? : context.isError ? error(context.error.message, () => { void context.refetch(); }) - : !context.data.configured ?

连接 SkillHub

{entries.configuration ? '在 DSH 的插件页面打开 SkillHub 配置,填写来源地址并保存。' : '尚未配置技能来源,且当前配置入口不可用。请先恢复 DSH 的插件配置功能,再连接 SkillHub。'}

- : selected ? <> + : selected && context.data.configured ? <>

{sourceMatches ? detail.data?.title || selected.displayName || selected.slug : selected.displayName || selected.slug}

@@ -181,7 +190,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa : detail.isPending ? : detail.isError ? error(detail.error.message, () => { void detail.refetch(); }) : <> - {installation && installed && item.namespace === selected.namespace && item.slug === selected.slug) ?? []} + {installation && installed && sameRegistry(item.registry, context.data.registryUrl) && item.namespace === selected.namespace && item.slug === selected.slug) ?? []} sourceRevision={revision!} services={installed} verified={!!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length} onInstall={() => setInstallCandidate({ item: selected, revision: revision! })} />}
@@ -190,8 +199,8 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa onContextStale={() => { void detail.refetch(); }} onSelectVersion={version => { const next = { ...selected, latestVersion: version }; navigation.selected = next; setSelected(next); }} />
} : <> @@ -201,6 +210,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa : installed ? setInstallCandidate({ item: { namespace: item.namespace, slug: item.slug, latestVersion: item.version, summary: '' }, revision: revision! })} onRemove={item => manageSkill(item, 'remove')} onUpgrade={item => manageSkill(item, 'upgrade')} navigation={installedNavigation!} source={context.data.registryUrl} sourceRevision={revision!} remote={remote} modes={context.data.detailTabs} scrollContainer={scroller} /> :

请先打开一个 DSH 会话,再查看该用户和项目的安装记录。

} + {!context.data.configured ?

连接 SkillHub

{entries.configuration ? '在 DSH 插件页面的 SkillHub 设置中配置技能来源。' : '来源配置暂不可用,已安装技能仍可管理。'}

: <>
{ event.preventDefault(); search(draft.trim()); }}>
{ navigation.draft = event.target.value; setDraft(event.target.value); }} />
@@ -221,9 +231,9 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa {items.length === 0 ?

{listing.hasNextPage ? '此批没有可展示的已发布技能,可继续加载。' : '没有找到匹配的技能,请尝试其他关键词。'}

:
{items.map(item => record.namespace === item.namespace && record.slug === item.slug) - ? {skillInstallationSummary(inventory.data.items.filter(record => record.namespace === item.namespace && record.slug === item.slug))} : undefined} - actions={ record.namespace === item.namespace && record.slug === item.slug) ?? []} + && inventory.data.items.some(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug) + ? {skillInstallationSummary(inventory.data.items.filter(record => sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug))} : undefined} + actions={ sameRegistry(record.registry, context.data.registryUrl) && record.namespace === item.namespace && record.slug === item.slug) ?? []} services={installed} sourceRevision={revision!} verified={!!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length} onInstall={() => revision && setInstallCandidate({ item, revision })} onManage={() => openSkill(item)} />} @@ -233,7 +243,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa {listing.isFetchNextPageError &&

下一批暂时无法读取,已加载的技能保留。请重试。

} {listing.hasNextPage &&
已显示 {items.length} 项
} } - + } } ; } diff --git a/plugins/dsh/src/client/change-preview.tsx b/plugins/dsh/src/client/change-preview.tsx new file mode 100644 index 000000000..188d754b8 --- /dev/null +++ b/plugins/dsh/src/client/change-preview.tsx @@ -0,0 +1,153 @@ +import { HostWriteStatus, useWritableHost } from './host-compatibility.js'; +import { ChangeRequests } from './upgrade-requests.js'; +import { useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'; +import type { ChangesView, ChangeDiffView, FileResetPlanView, HostExecutePlanRequest, NativeOperation, UpgradePlanView } from '../host/types.js'; +import { Badge, Button, Card, CardHeader, CardContent } from './ui.js'; +import type { UpgradeServices } from './upgrade-control.js'; + +export interface ChangeServices { + inspect(revision: string, itemKey: string, signal: AbortSignal): Promise>; + diff(revision: string, itemKey: string, snapshotId: string, path: string, signal: AbortSignal): Promise>; + preview(revision: string, itemKey: string, snapshotId: string, version: string | null, backup: boolean, signal: AbortSignal): Promise>; + confirm(revision: string, itemKey: string, snapshotId: string, signal: AbortSignal): Promise>; + prepareReset(revision: string, itemKey: string, snapshotId: string, paths: string[], signal: AbortSignal): Promise>; + reset(revision: string, itemKey: string, input: HostExecutePlanRequest & { hostInstanceId: string }, signal: AbortSignal): Promise>; +} +function value(response: RemoteResult): T { if (!response.ok) throw new Error('读取或操作未确认,请重新检查安装状态。'); return response.value; } +export function ChangePreviewStyles() { return ; } +function FileChange({ file, view, revision, itemKey, services, onUndo, disabled }: { file: ChangesView['changes'][number]; view: ChangesView; revision: string; itemKey: string; services: ChangeServices; onUndo?(trigger: HTMLButtonElement): void; disabled?: boolean }) { + const [open, setOpen] = useState(false); + const diff = useQuery({ queryKey: ['skillhub-change-file', revision, itemKey, view.snapshotId, file.path], enabled: open, retry: false, gcTime: 0, + queryFn: async ({ signal }) => value(await services.diff(revision, itemKey, view.snapshotId!, file.path, signal)) }); + return
setOpen(event.currentTarget.open)}>{file.kind === 'added' ? '新增' : file.kind === 'deleted' ? '删除' : '修改'}{file.path} + {diff.isPending ?

正在读取差异…

: diff.isError ?

无法读取差异。

+ : diff.data.state === 'text' ? <>
{diff.data.lines.map((line, index) => {line.text})}
{diff.data.truncated &&

差异过长,仅展示部分内容。

} + :

{diff.data.state === 'binary' ? '此文件不支持文本差异预览。' : diff.data.state === 'too-large' ? '文件超出预览限制。' : '原始内容暂不可用,无法比较。'}

} +
{onUndo && }
; +} + +/** Sequential local handling → release differences → final explicit acceptance. */ +export function ChangePreview({ services, revision, itemKey, upgrade, targetVersion, installationKey, onClose, onCompleted }: { + services: UpgradeServices & { changes: ChangeServices }; revision: string; itemKey: string; upgrade: boolean; targetVersion?: string; installationKey?: string; onClose(): void; onCompleted?(version: string): void; +}) { + const writable = useWritableHost(services); + const [view, setView] = useState(); const [error, setError] = useState(''); const [pending, setPending] = useState(false); + const resetTrigger = useRef(null); + const resetCancel = useRef(null); + const [reset, setReset] = useState(); const [operation, setOperation] = useState(); + const [requests] = useState(() => new ChangeRequests(() => sessionStorage)); + const requestKeys = { reset: JSON.stringify([services.sessionId, installationKey ?? itemKey, 'reset']), upgrade: JSON.stringify([services.sessionId, installationKey ?? itemKey, 'upgrade']) }; + const activeKey = useRef(requestKeys.upgrade); + useEffect(() => { if (reset && !pending) resetCancel.current?.focus(); }, [reset, pending]); + const closeReset = () => { setReset(undefined); requestAnimationFrame(() => resetTrigger.current?.focus()); }; + const [resetting, setResetting] = useState(false); const controller = useRef(new AbortController()); + const execution = useRef<{ request: HostExecutePlanRequest & { hostInstanceId: string }; kind: 'reset' | 'upgrade' }>(); + useEffect(() => { const active = controller.current; return () => active.abort(); }, []); + const signal = () => controller.current.signal; + const load = async () => { + for (const kind of ['reset', 'upgrade'] as const) { + const saved = requests.get(requestKeys[kind]); + if (saved) { + activeKey.current = requestKeys[kind]; + execution.current = { kind, request: { ...saved.request, hostInstanceId: saved.hostInstanceId } }; + if (saved.operationId) { const original = value(await services.operation(saved.operationId, saved.hostInstanceId)); if (!original) throw new Error('原操作暂不可读,请重试。'); setResetting(original.kind === 'reset-files'); execution.current.kind = original.kind === 'reset-files' ? 'reset' : 'upgrade'; setOperation(original); return; } + throw new Error('有未确认的文件操作,请查询原请求。'); + } + } + const local = value(await services.changes.inspect(revision, itemKey, signal())); + signal().throwIfAborted(); + if (upgrade && local.status === 'ready' && !local.changes.length) await preview(local, false); + else setView(local); + }; + const run = (action: () => Promise) => { if (pending) return; setPending(true); setError(''); void action().catch(failure => { if (!signal().aborted) setError(failure instanceof Error ? failure.message : '操作未完成。'); }).finally(() => { if (!signal().aborted) setPending(false); }); }; + useEffect(() => { run(load); /* The keyed parent owns context replacement. */ + }, []); + async function preview(local: ChangesView, backup: boolean) { + const result = value(await services.changes.preview(revision, itemKey, local.snapshotId!, targetVersion ?? null, backup, signal())); + signal().throwIfAborted(); setView(result); + } + async function prepareReset(paths: string[]) { const result = value(await services.changes.prepareReset(revision, itemKey, view!.snapshotId!, paths, signal())); signal().throwIfAborted(); setReset(result); } + async function execute() { + const saved = execution.current; if (!saved) return; + await requests.submit(activeKey.current, saved.request.planId, saved.request.hostInstanceId, async input => value(await (saved.kind === 'reset' ? services.changes.reset(revision, itemKey, { ...input, hostInstanceId: saved.request.hostInstanceId }, signal()) : services.executePlan(input, signal())))); + const admitted = requests.get(activeKey.current); + const result = admitted?.operation ?? (admitted?.operationId ? value(await services.operation(admitted.operationId, admitted.hostInstanceId)) : null); + if (!result) throw new Error('操作未确认,请查询原请求。'); signal().throwIfAborted(); setOperation(result); setReset(undefined); setResetting(saved.kind === 'reset'); + } + useEffect(() => { + if (!operation || ['completed', 'failed', 'unknown', 'recovery-required'].includes(operation.phase)) return; + let disposed = false; + const timer = setTimeout(() => { void services.operation(operation.operationId, operation.context.hostInstanceId).then(response => { if (disposed) return; if (!response.ok || !response.value) throw new Error('Operation unavailable'); setOperation(response.value); }).catch(() => { if (!disposed) setError('进度暂未确认,请重试原请求。'); }); }, 800); + return () => { disposed = true; clearTimeout(timer); }; + }, [operation, services]); + useEffect(() => { if (operation?.phase === 'completed' && resetting) { requests.closeResult(activeKey.current, operation); setOperation(undefined); execution.current = undefined; setResetting(false); void load().catch(failure => setError(failure instanceof Error ? failure.message : '重新读取失败。')); } }, [operation?.phase, resetting]); + return
+

{view?.kind === 'version' ? '版本变化' : upgrade ? '处理本地修改' : '本地修改'}

{view?.status === 'ready' &&

{view.kind === 'local' ? `v${view.version} 原始发布内容 → 当前本地文件` : `v${view.version} 原始发布内容 → v${view.candidateVersion} 发布内容`}

}
+ {error &&

{error}

} + {pending &&

{execution.current ? '正在提交…' : '正在检查并准备内容…'}

} + {operation ? <>

{operation.phase === 'completed' ? '升级完成。' : operation.phase === 'failed' ? '操作未完成,请核验安装状态。' : ['unknown', 'recovery-required'].includes(operation.phase) ? '操作需要恢复核验,请勿重复提交。' : '正在执行…'}

{['unknown', 'recovery-required'].includes(operation.phase) && } : <> + {view?.status === 'blocked' &&

当前无法比较或操作:{view.reason}

} + {view?.status === 'ready' && <> + {view.kind === 'version' && view.changelog &&
更新说明

{view.changelog}

} + {view.backupSaved &&
本地修改已备份{view.backupLocation && {view.backupLocation}}
{view.backupLocation && }
} + {view.changes.length ? <>
{view.changes.length} 个文件变化{view.kind === 'local' && }
+
{view.changes.map(file => { resetTrigger.current = trigger; run(() => prepareReset([file.path])); } : undefined} />)}
:

{view.kind === 'local' ? '没有本地修改。' : '两个发布版本的文件内容相同。'}

} + } + {reset &&
{ if (event.key === 'Escape' && !pending) { event.preventDefault(); closeReset(); } }}>{reset.paths.length === 1 ? `撤销 ${reset.paths[0]!.path} 的修改?` : `撤销这 ${reset.paths.length} 个文件的修改?`}

{reset.paths.length === 1 && reset.paths[0]!.kind === 'added' ? '将删除这个本地新增文件。' : `将恢复所选发布文件的原始内容${reset.paths.some(file => file.kind === 'added') ? `,并删除 ${reset.paths.filter(file => file.kind === 'added').length} 个本地新增文件` : ''},版本保持不变。`}

    {reset.paths.slice(0, 5).map(file =>
  • {file.kind === 'added' ? '删除本地新增文件:' : '恢复原始内容:'}{file.path}
  • )}
{reset.paths.length > 5 &&

另有 {reset.paths.length - 5} 个文件。

}
} +
+ {upgrade && view?.status === 'ready' && !reset && } +
+ }
+
; +} diff --git a/plugins/dsh/src/client/config-page.tsx b/plugins/dsh/src/client/config-page.tsx index 80eb6822c..1024d6adf 100644 --- a/plugins/dsh/src/client/config-page.tsx +++ b/plugins/dsh/src/client/config-page.tsx @@ -1,5 +1,6 @@ +import { editSavedRegistry, registryChoices, preserveCurrentRegistry, RegistryConfigError, type SavedRegistry, sameRegistry, validateRegistryConfig } from './registry-config.js'; import { catalogErrorMessage } from './catalog-error.js'; -import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Input, SelectField, Skeleton } from './ui.js'; +import { Button, Card, CardHeader, CardContent, Input, SelectField, Skeleton, Tabs, TabsList, TabsTrigger, TabsContent } from './ui.js'; import { ConfigStyles } from './config-styles.js'; import { NativeControlStyles } from './native-control-styles.js'; import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type FormEvent } from 'react'; @@ -23,6 +24,16 @@ export function ConfigPage({ form, testConnection }: { form: ConfigForm(); const [pending, setPending] = useState(false); const [message, setMessage] = useState(''); + const [messageTone, setMessageTone] = useState<'success' | 'error' | 'info'>('info'); + const [tab, setTab] = useState('source'); + const [editingSource, setEditingSource] = useState(null); + const [activeEditor, setActiveEditor] = useState(null); + const editorRevision = useRef(state.revision); + useEffect(() => { + if (editorRevision.current === state.revision) return; + editorRevision.current = state.revision; + if (!draft) { setEditingSource(null); setActiveEditor(null); } + }, [state.revision, draft]); const value = draft?.value ?? state.value; const registryUrl = value?.registryUrl.trim() ?? ''; const request = useRef(); @@ -61,70 +72,127 @@ export function ConfigPage({ form, testConnection }: { form: ConfigForm; if (!value || state.status === 'unavailable') return

当前连接无法编辑 Host 配置。

; + const sources = registryChoices(value); + function change(next: SkillHubConfig) { if (state.revision === undefined) return; - setDraft({ value: next, revision: draft?.revision ?? state.revision }); - setMessage(''); + const current = state.value; + const unchanged = current && next.registryUrl === current.registryUrl + && modules.every(([key]) => next.detailTabs[key] === current.detailTabs[key]) + && (next.savedRegistries?.length ?? 0) === (current.savedRegistries?.length ?? 0) + && (next.savedRegistries ?? []).every((entry, index) => entry.name === current.savedRegistries?.[index]?.name && entry.url === current.savedRegistries?.[index]?.url); + setDraft(unchanged && (!draft || draft.revision === state.revision) ? undefined : { value: next, revision: draft?.revision ?? state.revision }); + setMessage(''); setMessageTone('info'); + } + + function editSource(index: number, next: SavedRegistry) { + const active = activeEditor === index || sameRegistry(value?.registryUrl, value?.savedRegistries?.[index]?.url); + if (active) setActiveEditor(index); + const changed = editSavedRegistry(value!, index, next); + change(active ? { ...changed, registryUrl: next.url } : changed); } async function save(event: FormEvent) { event.preventDefault(); if (!draft || !state.writable || pending) return; - const registryUrl = draft.value.registryUrl.trim(); - if (registryUrl) { - try { - const url = new URL(registryUrl); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error(); - } catch { - setMessage('请输入 HTTP 或 HTTPS 实例地址,不包含凭证、查询参数或片段。'); - return; + let validated: SkillHubConfig; + try { validated = validateRegistryConfig(draft.value); } + catch (error) { + setTab('source'); setMessageTone('error'); + if (error instanceof RegistryConfigError && error.savedIndex !== undefined) setEditingSource(error.savedIndex); + else { + const next = preserveCurrentRegistry(draft.value); + setDraft({ value: next, revision: draft.revision }); + setEditingSource(next.savedRegistries?.findIndex(entry => sameRegistry(entry.url, next.registryUrl)) ?? null); } + setMessage(error instanceof Error ? error.message : '请检查来源地址。'); return; } setPending(true); try { const accepted = await form.mutate([ - { op: 'set', path: ['registryUrl'], value: registryUrl }, + { op: 'set', path: ['registryUrl'], value: validated.registryUrl }, + { op: 'set', path: ['savedRegistries'], value: validated.savedRegistries }, ...modules.map(([key]) => ({ op: 'set' as const, path: ['detailTabs', key], value: draft.value.detailTabs[key] })), ], draft.revision); if (accepted) { - setDraft(undefined); - setMessage('配置已保存。'); + setDraft(undefined); setEditingSource(null); setActiveEditor(null); + setMessageTone('success'); setMessage('配置已保存。'); } else { - setMessage('配置未保存,可能已被其他页面修改。请重置修改后再试。'); + setMessageTone('error'); setMessage('配置未保存,可能已被其他页面修改。请重置修改后再试。'); } } catch { - setMessage('保存失败,请检查连接后重试。'); + setMessageTone('error'); setMessage('保存失败,请检查连接后重试。'); } finally { setPending(false); } } - return void save(event)} aria-label="SkillHub 配置"> + return void save(event)} aria-label="SkillHub 配置"> -

SkillHub 设置

管理技能来源和详情页面的展示内容。

+

SkillHub 设置

连接技能来源,调整详情页展示。

{!state.writable &&

当前 Host 配置为只读。

} -
-
技能来源{state.value?.registryUrl.trim() ? '已配置' : '未配置'}
浏览和安装实例公开的技能,无需登录。
- - change({ ...value, registryUrl: event.target.value })} /> -
- {registryUrl !== (state.value?.registryUrl.trim() ?? '') ? '测试当前输入,尚未保存' : '测试当前来源'} -
- {visibleConnection &&

{visibleConnection.message}

} -

测试不会保存地址,连接成功仅表示公开目录可读。

-

留空可断开来源。保存新地址后需重新读取技能列表。

-
- 详情标签按来源能力展示版本、审核和评论,建议使用自动检测。 + + 技能来源页面显示 +
+ +

技能来源

选择一个来源,保存后生效。

+
+
+ {sources.length === 0 &&

尚未添加来源。

} + {sources.map((entry, rowIndex) => { + const selected = sameRegistry(entry.url, value.registryUrl); + const current = sameRegistry(entry.url, state.value?.registryUrl); + const index = entry.savedIndex; + const editing = index !== null && index === editingSource; + const key = index === null ? 'legacy' : `saved-${index}`; + return
+
+ + +
+ {editing &&
+ + +
} +
; + })} + +
+

切换或移除来源不会删除已安装技能。

+ {registryUrl && } +
+ {visibleConnection &&

{visibleConnection.message}

} +
+

详情页内容

默认按来源能力自动显示。

{modules.map(([key, label, description]) =>
{label}

{description}

change({ ...value, detailTabs: { ...value.detailTabs, [key]: mode as TabMode }, })} options={[{ value: 'auto', label: '自动检测' }, { value: 'show', label: '显示' }, { value: 'hide', label: '隐藏' }]} /> -
)}

“显示”不会改变服务端访问规则;评论仅供阅读。

-
+
)}
+
- + {draft ? '有未保存的修改' : '修改后保存生效'}
- -

{message}

+ + +

{message}

; } diff --git a/plugins/dsh/src/client/config-styles.tsx b/plugins/dsh/src/client/config-styles.tsx index 691cf06ce..3bcb0b363 100644 --- a/plugins/dsh/src/client/config-styles.tsx +++ b/plugins/dsh/src/client/config-styles.tsx @@ -1,18 +1,14 @@ /** Configuration-only layout composed from the shared shadcn components and DSH tokens. */ export function ConfigStyles() { return ; } diff --git a/plugins/dsh/src/client/conversation-detail.tsx b/plugins/dsh/src/client/conversation-detail.tsx index 9bfd7330b..6d3abf7df 100644 --- a/plugins/dsh/src/client/conversation-detail.tsx +++ b/plugins/dsh/src/client/conversation-detail.tsx @@ -77,7 +77,7 @@ function ConversationSkillActions({ detail, revision, services, close, children const [install, setInstall] = useState(false); const inventory = useQuery({ queryKey: ['skillhub-installed', services.installation.sessionId, revision], retry: false, queryFn: async ({ signal }) => value(await services.installation.installed(revision, signal)) }); - const records = inventory.data?.items.filter(item => item.namespace === detail.namespace && item.slug === detail.slug) ?? []; + const records = inventory.data?.items.filter(item => item.currentSource !== false && item.namespace === detail.namespace && item.slug === detail.slug) ?? []; const verified = !!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length; return <> {records.length ? Promise> { }); const panel = ctx.inject(['remote.skillhub', 'slots', 'sessions', 'uiSession'], (ctx) => { const client = new QueryClient(); + ctx.inject(['configForms'], ctx => { + const form = ctx.configForms.get('astron-skillhub'); + let previous = form.getSnapshot().value?.registryUrl; + ctx.effect(() => form.subscribe(() => { + const current = form.getSnapshot().value?.registryUrl; + if (current === previous) return; + previous = current; + void client.cancelQueries({ predicate: query => String(query.queryKey[0]).startsWith('skillhub-') }).then(() => + client.invalidateQueries({ predicate: query => String(query.queryKey[0]).startsWith('skillhub-') })); + })); + }); ctx.slots.inject('tool.call.toolview', () => availability.enable('conversation')); const navigation = createCatalogNavigation(); const requests = new InstallationRequests(() => window.sessionStorage); @@ -68,7 +81,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { capture: (sessionId, actions) => captureSkillUse(ctx, sessionId, actions), revision: async () => { const response = await ctx.remote.skillhub.getContext(); - if (!response.ok || !response.value.configured) throw new Error('来源不可用,请检查插件设置。'); + if (!response.ok) throw new Error('来源不可用,请检查插件设置。'); return response.value.sourceRevision; }, prepare: async (sessionId, revision, itemKey, signal) => { @@ -94,6 +107,21 @@ export async function apply(ctx: Context): Promise<() => Promise> { props => )); ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'skillhub-case-choices' }, props => )); + function changesFor(sessionId: string): ChangeServices { + const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => { + const scope = ctx.sessions.scope(SessionId(sessionId)); + if (!scope) throw new Error('当前会话不可用。'); + return withSessionRemote(scope, action); + }; + return { + inspect: (...args) => call(remote => remote.skillhub.inspectLocalChanges(...args)), + diff: (...args) => call(remote => remote.skillhub.readFileChange(...args)), + preview: (...args) => call(remote => remote.skillhub.previewUpgradeChanges(...args)), + confirm: (...args) => call(remote => remote.skillhub.confirmUpgradeChanges(...args)), + prepareReset: (...args) => call(remote => remote.skillhub.prepareFileReset(...args)), + reset: (...args) => call(remote => remote.skillhub.executeFileReset(...args)), + }; + } function installationFor(sessionId: string, catalog = false): InstallationServices { let management: ManagementViewServices | undefined; const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => { @@ -101,7 +129,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); return withSessionRemote(scope, action); }; - const upgrades: UpgradeServices = { sessionId, upgradeRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(), + const upgrades: UpgradeServices = { changes: changesFor(sessionId), sessionId, upgradeRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(), prepareUpgrade: (revision, itemKey, signal) => call(remote => remote.skillhub.prepareUpgrade(revision, itemKey, signal)), prepareVersionUpgrade: (revision, itemKey, version, signal) => call(remote => remote.skillhub.prepareVersionUpgrade(revision, itemKey, version, signal)), executePlan: (input, signal) => call(remote => remote.skillhub.executePlan(input, signal)), @@ -148,10 +176,17 @@ export async function apply(ctx: Context): Promise<() => Promise> { if (!scope) throw new Error('当前会话已关闭。'); return withSessionRemote(scope, action); }; - const services: ManagementViewServices = { selectSkillUse: (revision, namespace, slug, signal) => call(remote => remote.skillhub.selectSkillUse(revision, namespace, slug, signal)), client, installation: installationFor(sessionId), assertCurrent: receipt.assertCurrent, + const services: ManagementViewServices = { openChanges: (item, upgrade, version, revision) => { + const layout = ctx.get('layout'); if (!layout) throw new Error('技能中心暂不可用。'); + const sourceRevision = revision ?? navigation.sourceRevision; + if (!sourceRevision) throw new Error('请先打开技能中心。'); + const installed = installedNavigationFor(navigation, sessionId, sourceRevision); + Object.assign(installed, { selected: item, intent: upgrade ? 'upgrade' : 'local-changes', changeVersion: version }); + navigation.selected = null; navigation.tab = 'installed'; navigation.installedOrigin = { sessionId, sourceRevision }; layout.selectPanel(panelId); + }, selectSkillUse: (revision, namespace, slug, signal) => call(remote => remote.skillhub.selectSkillUse(revision, namespace, slug, signal)), client, installation: installationFor(sessionId), assertCurrent: receipt.assertCurrent, refreshInstalled: signal => call(async remote => { const source = await ctx.remote.skillhub.getContext(); - if (!source.ok || !source.value.configured) throw new Error('当前技能来源不可用。'); + if (!source.ok) throw new Error('无法读取技能上下文。'); receipt.assertCurrent(); const installed = await remote.skillhub.listInstalled(source.value.sourceRevision, signal); if (!installed.ok) throw new Error('无法读取当前安装记录。'); @@ -255,7 +290,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { }, ({ renderSlot }) => renderSlot('skillhub.catalog', {}))); ctx.slots.inject('skillhub.catalog', () => ctx.slots.register({ name: 'skillhub.catalog', inject: sessionId => ({ installation: sessionId ? installationFor(sessionId, true) : undefined, - installed: sessionId ? { sessionId, management: managementFor(sessionId), skillUse: useSkills.catalogServices(sessionId), upgradeRequests, removalRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(), + installed: sessionId ? { changes: changesFor(sessionId), sessionId, context: installedContext(() => ({ scope: ctx.sessions.scope(SessionId(sessionId)), cwd: ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.cwd, active: !!ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.retainedBy.mainView }), listener => ctx.sessions.list.subscribe(listener)), get management() { return managementFor(sessionId); }, skillUse: useSkills.catalogServices(sessionId), upgradeRequests, removalRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(), operation: installationFor(sessionId).operation, recoverOperation: async input => { const scope = ctx.sessions.scope(SessionId(sessionId)); @@ -284,6 +319,18 @@ export async function apply(ctx: Context): Promise<() => Promise> { const scope = ctx.sessions.scope(SessionId(sessionId)); if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); return withSessionRemote(scope, remote => remote.skillhub.getInstalledDetail(revision, itemKey, signal)); + }, beginInstalledUpdateScan: async (revision, signal) => { + const scope = ctx.sessions.scope(SessionId(sessionId)); + if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); + return withSessionRemote(scope, remote => remote.skillhub.beginInstalledUpdateScan(revision, signal)); + }, checkInstalledUpdateScan: async (revision, scanId, itemKey, signal) => { + const scope = ctx.sessions.scope(SessionId(sessionId)); + if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); + return withSessionRemote(scope, remote => remote.skillhub.checkInstalledUpdateScan(revision, scanId, itemKey, signal)); + }, endInstalledUpdateScan: async (revision, scanId) => { + const scope = ctx.sessions.scope(SessionId(sessionId)); + if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); + return withSessionRemote(scope, remote => remote.skillhub.endInstalledUpdateScan(revision, scanId)); }, checkUpdate: async (revision, itemKey, signal) => { const scope = ctx.sessions.scope(SessionId(sessionId)); if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); diff --git a/plugins/dsh/src/client/install-control.tsx b/plugins/dsh/src/client/install-control.tsx index cc5b62ab7..5f72f024c 100644 --- a/plugins/dsh/src/client/install-control.tsx +++ b/plugins/dsh/src/client/install-control.tsx @@ -66,7 +66,7 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom const local = useQuery({ queryKey: ['skillhub-installed', services.sessionId, sourceRevision], retry: false, enabled: true, queryFn: async ({ signal }) => read(await services.installed(sourceRevision, signal)) }); - const current = local.data?.items.find(item => item.namespace === detail.namespace && item.slug === detail.slug && item.scope === scope); + const current = local.data?.items.find(item => item.currentSource !== false && item.namespace === detail.namespace && item.slug === detail.slug && item.scope === scope); const inventoryVerified = !local.isError && !local.isFetching && isInventoryScopeVerified(local.data, scope); const upgradeSource = useQuery({ queryKey: ['skillhub-upgrade-source', services.sessionId, sourceRevision], retry: false, enabled: !!services.upgrades && !!services.registryIdentity && current?.state === 'present' && current.version !== detail.version, @@ -75,7 +75,7 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom enabled: !!services.upgrades && !!services.checkVersionUpdate && inventoryVerified && current?.state === 'present' && current.version !== detail.version, queryFn: async ({ signal }) => read(await services.checkVersionUpdate!(sourceRevision, current!.itemKey, detail.version, signal)) }); const absent = inventoryVerified && !current; - const records = local.data?.items.filter(item => item.namespace === detail.namespace && item.slug === detail.slug) ?? []; + const records = local.data?.items.filter(item => item.currentSource !== false && item.namespace === detail.namespace && item.slug === detail.slug) ?? []; const result = operation.data ?? attempt?.operation; const completed = result?.phase === 'completed'; const progressVisible = !!attempt && !completed; @@ -162,7 +162,7 @@ class StaleTargetError extends Error {} /** Prefer the scope already installed, rather than presenting a misleading second install. */ export function preferredInstallationScope(inventory: InstalledSkillList, detail: Pick, available: readonly ('user' | 'project')[]): 'user' | 'project' | undefined { - const records = inventory.items.filter(item => item.namespace === detail.namespace && item.slug === detail.slug); + const records = inventory.items.filter(item => item.currentSource !== false && item.namespace === detail.namespace && item.slug === detail.slug); return (['project', 'user'] as const).find(scope => records.some(item => item.scope === scope)) ?? (available.includes('user') ? 'user' : undefined); } diff --git a/plugins/dsh/src/client/install-receipt-presentation.ts b/plugins/dsh/src/client/install-receipt-presentation.ts index 73038063e..70ccb6188 100644 --- a/plugins/dsh/src/client/install-receipt-presentation.ts +++ b/plugins/dsh/src/client/install-receipt-presentation.ts @@ -3,13 +3,13 @@ import type { InstalledSkillList, NativeOperation } from '../host/types.js'; import type { InstallResult } from './install-result.js'; /** A historical receipt only matches inventory in its original scope and exact version. */ -export function receiptInventoryIssue(result: Pick, +export function receiptInventoryIssue(result: Pick, inventory: InstalledSkillList | undefined, failed: boolean): string | null { if (failed) return '当前安装状态无法核验,请重新核验状态。'; if (!inventory) return null; const scope = result.targetLabel === '当前用户' ? 'user' : result.targetLabel === '当前项目' ? 'project' : undefined; if (!scope || !isInventoryScopeVerified(inventory, scope)) return '当前库存读取不完整或原安装范围无法确认。'; - const current = inventory.items.find(item => item.namespace === result.namespace && item.slug === result.slug && item.scope === scope); + const current = inventory.items.find(item => item.registry?.replace(/\/+$/, '') === result.sourceLabel.replace(/\/+$/, '') && item.namespace === result.namespace && item.slug === result.slug && item.scope === scope); if (!current) return '原安装范围内已无此技能的安装记录。'; if (current.state !== 'present') return '原安装范围的本地文件状态异常,请核验当前记录。'; if (current.version !== result.version) return `原安装范围当前为 v${current.version},已不是此次安装的版本。`; diff --git a/plugins/dsh/src/client/install-tool-view.tsx b/plugins/dsh/src/client/install-tool-view.tsx index b6c27c642..09ce2ff45 100644 --- a/plugins/dsh/src/client/install-tool-view.tsx +++ b/plugins/dsh/src/client/install-tool-view.tsx @@ -90,7 +90,7 @@ function InstallationResult({ result, services, receipt, openInstalled }: { 'receipt', result.hostInstanceId, result.operationId, result.sourceLabel, receipt.sessionGeneration], retry: false, enabled: canInspectCurrent && !!context.data && !context.isFetching && !context.isError, queryFn: ({ signal }) => readReceiptInventory(receipt, result, context.data!, signal) }); - const entries = current.data?.items.filter(item => item.namespace === result.namespace && item.slug === result.slug) ?? []; + const entries = current.data?.items.filter(item => item.registry?.replace(/\/+$/, '') === result.sourceLabel.replace(/\/+$/, '') && item.namespace === result.namespace && item.slug === result.slug) ?? []; const inventoryIssue = canInspectCurrent ? receiptInventoryIssue(result, context.isError ? undefined : current.data, context.isError || current.isError) : null; const inventoryPending = canInspectCurrent && (!context.isError && (context.isPending || context.isFetching || current.isPending || current.isFetching)); const presentation = receiptPresentation(operation, query.isError || !!mismatch, inventoryPending, inventoryIssue); @@ -101,7 +101,7 @@ function InstallationResult({ result, services, receipt, openInstalled }: {
{canInspectCurrent && operation?.phase === 'completed' && !context.isError && context.data && entries.some(item => item.state === 'present' && item.installationState !== 'disabled' && item.version === result.version && (item.scope === 'user' ? '当前用户' : '当前项目') === operation.targetLabel) && { const inventory = await readReceiptInventory(receipt, result, context.data!, signal); const scope = operation.targetLabel === '当前用户' ? 'user' : operation.targetLabel === '当前项目' ? 'project' : undefined; - return scope ? exactInstalledSkill(inventory, { ...result, scope })?.itemKey ?? '' : ''; + return scope ? exactInstalledSkill(inventory, { ...result, registry: result.sourceLabel, scope })?.itemKey ?? '' : ''; }} />} +

{selected.slug}

@{selected.namespace}/{selected.slug} · 文档版本 {selected.version}

- {actions(records(selected), '使用已安装版本')} -
{detail.isPending ?

正在读取技能说明…

+
{changeIntent && services.changes ? { setSelected(null); void query.refetch(); }} onClose={() => { setChangeIntent(undefined); void query.refetch(); void detail.refetch(); void services.management?.client.invalidateQueries({ queryKey: ['skillhub-local-summary', services.sessionId, sourceRevision] }); }} /> : <> + {detail.isPending ?

正在读取技能说明…

: detail.isError ?

{detail.error.message}

- : detail.data.content.state === 'local-only' ?

远端说明暂不可用,可在管理安装中查看本地状态。

- : { void detail.refetch(); }} cacheIdentity={JSON.stringify([services.sessionId, sourceRevision, selected.namespace, selected.slug, detail.data.content.detail.version])} onContextStale={() => { void detail.refetch(); }} />} -
+ : detail.data.content.state === 'local-only' ?

{selected.currentSource === false ? '此技能来自其他来源,可在右侧管理本地安装。' : '远端说明暂不可用,可在管理安装中查看本地状态。'}

+ : { void detail.refetch(); }} cacheIdentity={JSON.stringify([services.sessionId, sourceRevision, selected.namespace, selected.slug, detail.data.content.detail.version])} onContextStale={() => { void detail.refetch(); }} />}} +
; const term = keyword.trim().toLocaleLowerCase(); - const groups = groupManagementItems(query.data?.items ?? []).filter(items => !term || items.some(item => `@${item.namespace}/${item.slug} ${item.version}`.toLocaleLowerCase().includes(term))); + const groups = allGroups.filter(items => filter === 'all' || items.some(item => filter === 'updates' ? results[item.itemKey]?.state === 'upgradable' : updateFailed(results[item.itemKey]))).filter(items => !term || items.some(item => `@${item.namespace}/${item.slug} ${item.displayName ?? ''} ${item.version}`.toLocaleLowerCase().includes(term))); return
-
+
+
+ {([['all', '全部'], ['updates', '可更新'], ['failed', '检查失败']] as const).map(([value, label]) => )} +
+

{progress.total > 0 ? `${progress.running ? '检查中' : progress.done < progress.total ? '已停止' : '已检查'} ${progress.done}/${progress.total}${upgradable ? ` · ${upgradable} 个技能可更新` : ''}${failures ? ` · ${failures} 处检查失败` : ''}` : query.data && !query.data.items.some(item => item.currentSource !== false) ? '当前来源暂无可检查的技能' : `${groups.length} 个技能`}

+
{query.isPending ? : query.isError ?

{query.error.message}

: <> {(!query.data.complete || query.data.unavailable.length > 0) &&

部分安装记录无法读取,列表可能不完整。

} -

当前显示 {groups.length} 个技能。

- {!groups.length ?

{term ? '没有匹配的技能。' : verified ? '当前来源暂无已安装技能。' : '暂未读取到安装记录。'}

+ {!groups.length ?

{term || filter !== 'all' ? '没有匹配的技能。' : verified ? '暂无受管的已安装技能。' : '暂未读取到安装记录。'}

:
{groups.map(items => { const identity = items[0]!; - return -

{identity.slug}

@{identity.namespace}/{identity.slug}
- {skillInstallationSummary(items)} - {actions(items)} + const key = JSON.stringify([identity.registry, identity.namespace, identity.slug]); + const presentation = items.find(item => item.summary) ?? identity; + const displayName = presentation.displayName || identity.slug; + const versions = installedCardVersions(items); + const usable = verified && services.management?.selectSkillUse && items.some(item => item.state === 'present' && item.installationState !== 'disabled'); + return + + { if (menuNavigating.current) { event.preventDefault(); menuNavigating.current = false; } }}> + { + listScroll.current = scrollContainer.current?.scrollTop ?? 0; + menuNavigating.current = true; triggerKey.current = key; trigger.current = null; setSelected(identity); + }}>管理安装 + {items.some(item => item.currentSource !== false) && startCheck(false, items)}>检查更新} + + +
+
; })}
} } diff --git a/plugins/dsh/src/client/installed-styles.tsx b/plugins/dsh/src/client/installed-styles.tsx index 9b92aae67..033bc2748 100644 --- a/plugins/dsh/src/client/installed-styles.tsx +++ b/plugins/dsh/src/client/installed-styles.tsx @@ -3,17 +3,35 @@ import { NativeControlStyles } from './native-control-styles.js'; export function InstalledStyles() { return <> + + + +
+
+
+
SkillHub开源周报
+ +
+

2026 年第 44 周

+

仓库状态 · 主分支迭代 · 开源生态

+
+
+
统计范围2026-10-23 00:00—2026-10-30 00:00 · Asia/Shanghai
+
统计状态完整周期 · 上周四收口至本周四收口
+
PR 统计分支main
+
数据来源GitHub API(认证)· Actions · npm · Traffic
+
+
+ 阶段判断 +
多版本交付落地,协作流转保持活跃完整周期
+

应用、CLI 与 DSH 均有发布记录;主分支合并 23 个 PR。Actions 有效成功率为 93.1%,失败主要集中在 DSH 验证与 CLI 检查。

+
+
+
+
5,338Stars · 较上次观测 +55
+
+6 / −7Issue · 净减少 1 · 开放 26
+
+23 / 23PR 新增 / 合并 · 开放 9
+
93.1%Actions 有效成功率 · 470 / 505
+
+
+ +
+
+ + + + +
+
+ +
+
+

先看仓库规模和协作流转,再看已经交付与仍待验证的内容。

事件统计覆盖完整的 7 天周期;库存与开放队列取采集快照。

+

一、仓库关键信息

+

Issue 与 PR 事件流量均为净减少,Release 三条产品线均有交付;35 次失败仍需跟踪。

+
+

规模快照

+
Stars5,338较上次观测 +55
+
Forks815仓库库存字段
+

10 月 25 日 14:20 认证观测 5,283 Stars,本次于 10 月 30 日 14:23 观测 5,338,观测差 +55;两次观测不对齐周期边界,不能解释为周净增长。认证 Stargazer 完整分页与库存同为 5,338,其中 80 个当前仍可见到达落入本期,也不代表净增长。Fork 列表 1,167 条与库存字段 815 不一致,因此不展示 Fork 到达数。

+

协作流转

Issue 事件净减少 1,快照开放 26;main PR 事件净减少 2,快照开放 9。库存只用于当前快照,不用于倒推周期起点。

+
+

运行判断:完整周期共有 623 条 Actions,成功 470、失败 35,有效成功率 93.1%。本期 GitHub Release 为应用、CLI、DSH 各 1 个;当前开放 Issue 26 个、main PR 9 个。

+

主要风险:完整周期内有 35 次失败,其中 Check DSH Plugin 13 次、PR CLI 5 次;报告保留失败记录,不以随后成功覆盖历史失败。

+
+ +

二、功能迭代信息

+

版本发布情况:周期内共发布 3 个 GitHub Release:应用、CLI、DSH 各 1 个;npm 同期发布 CLI 1 个版本、DSH 2 个版本。

+
+ + + +
本期版本发布情况
产品线GitHub Release包仓库状态发布时间与边界
SkillHub 应用v0.2.24不适用10 月 26 日 09:43 发布。标签祖先核验包含本期 23 个合并 PR 中的 9 个;其余 14 个本期合并 PR 不归入该版本。
SkillHub CLI0.2.0@astron-team/skillhub 周期内发布 0.2.0;快照时 latest 为 0.3.0。GitHub Release 于 10 月 26 日 14:05 发布,npm 于 14:06 发布。0.3.0 于 10 月 30 日 10:17/10:20 发布,晚于周期截止,不计入本期。
SkillHub DSH0.4.0@astron-team/skillhub-dsh 同期发布 0.4.0、0.5.0,latest 为 0.5.0。GitHub Release 于 10 月 26 日 14:30 发布;npm 0.4.0、0.5.0 分别于 14:23、17:36 发布。GitHub 与 npm 版本轨道分别说明,不把 0.5.0 写成 GitHub Release。
+

版本后的功能进展

+
+ + + +
本期版本后的重点迭代
状态事项截至快照进展下一门槛
主分支待发布韩语本地化 #1021 / 套件关联卡片 #1022两项均已合并,但晚于 v0.2.24 标签。等待后续应用 Release 验证。
主分支待发布Scanner Helm 4 兼容 #1027 / 设备授权修复 #1034缺陷修复已合并,分别关闭 #1026 与 #1031。等待应用 Release 与对应运行环境验证。
主分支持续迭代DSH 安装激活 #1025 / 更新概览 #1030 / 卡片修复 #1032 / 兼容修复 #1033均在已核验 npm 0.5.0 发布时间之后合并。等待后续 DSH 包版本证明,不倒推为已经发布。
+
+ +

三、生态相关进展

+ +

作者、Fork 库存和 npm 下载是独立信号,不构成转化漏斗。CLI 在 10 月 23—28 日 6 个完整 UTC 日桶记录 1,522 次下载;Fork 到达数因来源不一致而省略。

+
+ + + + +
生态交付
类型进展证据与边界
社区贡献#1021 韩语本地化、#1027 Helm 4 Scanner 兼容 已合并。均由外部贡献者提交;只证明进入主分支。
语义属性#1010 已合并。默认被动属性进入主分支;不推断生产分析已启用。
文档#1028 刷新双语指南与套件用法。文档合并不代表相关运行时已部署。
业务事件出口#1009 与 #1029 推进默认关闭的厂商中立 OTLP Logs 出口。#1029 截至快照仍开放,不写成已经交付。
+
+ + +
+ + + + + + +
+
SkillHub Open Source Weekly · 2026-W44完整周期 · 已核验数据口径
+
+ + + diff --git a/weekly/source.json b/weekly/source.json index befc85832..d5b2d87b5 100644 --- a/weekly/source.json +++ b/weekly/source.json @@ -1,4 +1,4 @@ { "repository": "https://github.com/XiaoSeS/skillhub-weekly.git", - "commit": "81761794ab97cb03e3d85a062e4bbd76c8463c26" + "commit": "13a95bb287e341150e5bd95461a2284a7daf6576" }