Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a1c7a49b | ||
|
|
9a6e60c643 | ||
|
|
99a0952505 | ||
|
|
f30aaeba91 | ||
|
|
43798de088 | ||
|
|
c4d8987f6c | ||
|
|
657d2c25e3 | ||
|
|
9f7107b6bb | ||
|
|
bfd48b6a71 | ||
|
|
390b6115bf | ||
|
|
fc9acb9007 | ||
|
|
aa6abc5ed6 | ||
|
|
ea6fd16d11 |
@@ -61,6 +61,46 @@ jobs:
|
||||
- name: Test with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI paths
|
||||
run: |
|
||||
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,10 +18,7 @@ jobs:
|
||||
release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
runs-on: ubuntu-latest
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -40,17 +37,50 @@ jobs:
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
set -euo pipefail
|
||||
RCS_VERSION="0.29.0"
|
||||
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
|
||||
| sha256sum --check --strict -
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Prepare Apple Developer ID certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
|
||||
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
|
||||
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umask 077
|
||||
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
|
||||
password_path="$RUNNER_TEMP/dws-developer-id-password"
|
||||
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
|
||||
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
|
||||
|
||||
# Fail before packaging if the secret is corrupt or the password is wrong.
|
||||
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
|
||||
# -legacy to validate those containers even though rcodesign can read them.
|
||||
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
|
||||
|
||||
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
|
||||
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -63,12 +93,88 @@ jobs:
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
- name: Remove Apple Developer ID certificate
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id-password"
|
||||
|
||||
# GoReleaser uploads the original archives to a Draft before
|
||||
# post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed
|
||||
# file, verify the Draft digests, and keep it private for Apple validation.
|
||||
- name: Upload finalized signed assets to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DWS_PUBLISH_RELEASE: "false"
|
||||
run: ./scripts/release/finalize-github-release.sh
|
||||
|
||||
- name: Preserve finalized distribution files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
verify-darwin-signatures:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Download finalized Darwin assets from Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-darwin-amd64.tar.gz' \
|
||||
--pattern 'dws-darwin-arm64.tar.gz' \
|
||||
--clobber
|
||||
|
||||
- name: Verify finalized Darwin signatures with Apple codesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/dws-darwin-${arch}.tar.gz"
|
||||
stage="$RUNNER_TEMP/verify-darwin-${arch}"
|
||||
mkdir -p "$stage"
|
||||
tar -xzf "$archive" -C "$stage"
|
||||
test -f "$stage/dws"
|
||||
codesign --verify --strict --verbose=4 "$stage/dws"
|
||||
codesign -dvvv "$stage/dws"
|
||||
done
|
||||
|
||||
publish-release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs:
|
||||
- release
|
||||
- verify-darwin-signatures
|
||||
runs-on: ubuntu-latest
|
||||
# Mirroring every release asset to Gitee can be slow; 30 minutes previously
|
||||
# cut the fallback upload off mid-run.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Publish verified Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
|
||||
@@ -42,5 +42,8 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ release:
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
# Keep the release private until post-processing has replaced the Darwin
|
||||
# archives and verified every finalized asset digest.
|
||||
draft: true
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
@@ -8,7 +8,41 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
### Changed
|
||||
|
||||
- **`event consume` AI-subprocess contract** — aligns personal event streaming with the contract an orchestrator can drive without guessing: a fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>` (block on it, don't `sleep`); a final `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)` line with exit code 0 on controlled exit and non-zero (no `exited` line) on failure; stdin-EOF as a graceful shutdown signal, armed only for a parent-controlled pipe stdin on an unbounded run (an interactive TTY and `< /dev/null` never trigger it), with a self-explaining diagnostic when it fires; and ownership-based subscription cleanup — a subscription this run created is unsubscribed on any clean exit while a `--subscribe-id`-reused one is left intact (`--ephemeral` still forces cleanup), so `kill -9` is the only way to leak a server-side subscription. Skill docs (mono + `dingtalk-event`) document the contract; design notes in `docs/event-subprocess-contract.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws schema` for registered local commands** — `dws schema "event consume"` (or `event.consume`) now returns a machine-readable input schema synthesized from the command's cobra flags, in the same flat shape helper subtrees emit: `{description, path, source, parameters{<flag>:{type, required, description, default?}}}` plus an `arguments` array for positional inputs, with `source: "cobra"` distinguishing flag-synthesized schema from MCP-fetched (`mcp:<server>`). Intermediate nodes (`dws schema event`) list their subcommands. The mechanism is a reusable registry (`cobraSchemaRoots`); `event` is the first consumer and more command trees can opt in without further wiring. Inherited global flags and hidden internal flags are excluded so the schema describes just that command.
|
||||
- **Safe macOS Keychain → file-DEK migration** — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dev connect` forwards complex message payloads and recoverable attachments** — messages such as forwarded chat records (`chatRecord`) are no longer discarded when they do not match the CLI's built-in text, image, or file extractors; their message type and raw JSON payload are passed to every Stream-bridge backend. Attachment discovery is based on locator capabilities (`downloadCode`, `mediaId`, `fileId`, or `dentryId` + `spaceId`) rather than a message-type allowlist, so new and unknown outer, rich-text, and nested record types retain their original bytes. Nested pictures, audio, video, and files are downloaded completely, including entries degraded by DingTalk to locator-free `unknownMsgType`: the connector acknowledges the Stream callback first, then resolves their original `mediaId` / `fileId` through the message APIs. Original bytes use each backend's native attachment path where available (OpenCode file parts, Gemini inline/Files API, Codex local images, Qoder attachments) or a narrowly allowed read-only local path for CLI/custom agents. For OpenCode, large videos are converted to a full-duration 12-frame storyboard before the model call, avoiding in-process base64 expansion and out-of-memory failures while preserving the fully downloaded original for the duration of the turn.
|
||||
- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
|
||||
@@ -258,6 +258,16 @@ dws --profile <name|corpId> contact user search --query "..." # run one comman
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -406,6 +416,51 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
|
||||
|
||||
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
|
||||
|
||||
For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
| Shared connection | Consumers for the same user share one local bus and cloud connection |
|
||||
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
|
||||
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
|
||||
|
||||
|
||||
@@ -255,6 +255,16 @@ dws --profile <名称|corpId> contact user search --query "..." # 单次对指
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -403,6 +413,51 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
|
||||
|
||||
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
|
||||
|
||||
只需要 event 能力时,可以使用官方便捷安装脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
|
||||
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
|
||||
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -1,193 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -1,8 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -1,54 +0,0 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -1,143 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -1,133 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -1,58 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -0,0 +1,115 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
## Baseline (already present, no work)
|
||||
|
||||
- `--max-events N` — stop after N events (exit 0).
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
|
||||
## Improvements
|
||||
|
||||
### 1. Ready marker (standardized)
|
||||
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
|
||||
omits `event_key`).
|
||||
|
||||
**Verification**
|
||||
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
|
||||
- T1b: that line appears before the first stdout event (ordering).
|
||||
- T1c: with `--quiet`, the line is absent.
|
||||
|
||||
### 2. stdin EOF = graceful exit
|
||||
|
||||
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
|
||||
subprocess callers). To stay resident, feed a never-EOF stdin
|
||||
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
|
||||
|
||||
**Verification**
|
||||
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
|
||||
line `reason: signal` (stdin-eof classified as signal).
|
||||
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
|
||||
5s, connection intact.
|
||||
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
|
||||
via the cleanup path.
|
||||
|
||||
### 3. Exit reason contract + exit codes
|
||||
|
||||
On exit, final stderr line:
|
||||
|
||||
```
|
||||
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
|
||||
```
|
||||
|
||||
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
|
||||
or runtime failure (permissions, network, params) = non-zero, with no
|
||||
`exited` line and an `Error:` line instead.
|
||||
|
||||
**Verification**
|
||||
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
|
||||
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
|
||||
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
|
||||
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
|
||||
- Unit tests assert (reason string, exit code) for each path.
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
- If `--subscribe-id` was passed (reusing an existing subscription), the
|
||||
subscription is **left intact** — the caller owns its lifecycle.
|
||||
- `--ephemeral` remains as an explicit "always unsubscribe" override.
|
||||
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
|
||||
server-side subscription: "subscription already exists" on restart,
|
||||
duplicate delivery). Prefer SIGTERM or closing stdin.
|
||||
|
||||
**Verification**
|
||||
- T4a: start consume (self-created subscription), record subscribe_id;
|
||||
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
|
||||
and the server-side subscription is gone.
|
||||
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
|
||||
subscription is still present (reuse case preserved).
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
|
||||
down and takes consume with it (the likely cause of the observed silent
|
||||
drop). Making more drops retryable, keeping the bus alive across a
|
||||
reconnect, and emitting `reason: source_lost` only after exhausting the
|
||||
budget — tracked on its own branch, since it needs error-classification
|
||||
judgement and real flaky-network testing, and would otherwise couple clean
|
||||
contract work with resilience work.
|
||||
|
||||
## Test surface
|
||||
|
||||
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
|
||||
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
|
||||
- Integration/e2e: `--foreground` + mock source (or a short real run) for
|
||||
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
|
||||
codes.
|
||||
@@ -3,15 +3,17 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -35,7 +37,6 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -86,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -82,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
@@ -283,6 +284,7 @@ var (
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -521,6 +523,65 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate-keychain",
|
||||
Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK",
|
||||
Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。
|
||||
|
||||
迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。
|
||||
先用 --dry-run 预检,确认后加 --yes 执行。`,
|
||||
Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := cmd.Flags().GetString("to")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --to")
|
||||
}
|
||||
if strings.TrimSpace(target) != "file-dek" {
|
||||
return apperrors.NewValidation("--to 当前仅支持 file-dek")
|
||||
}
|
||||
if os.Getenv(keychain.DisableKeychainEnv) != "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
|
||||
dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run")
|
||||
yes, _ := cmd.Root().PersistentFlags().GetBool("yes")
|
||||
if !dryRun && !yes {
|
||||
return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行")
|
||||
}
|
||||
count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err))
|
||||
}
|
||||
|
||||
result := struct {
|
||||
Success bool `json:"success"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Target string `json:"target"`
|
||||
Entries int `json:"entries"`
|
||||
}{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
@@ -596,7 +657,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
@@ -1229,11 +1290,18 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsCiphertextKeyMismatch(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "ciphertext_key_mismatch",
|
||||
Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证",
|
||||
Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。",
|
||||
}
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -232,6 +233,22 @@ func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "dws auth reset") {
|
||||
t.Fatalf("hint should mention dws auth reset; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
|
||||
diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch))
|
||||
if diagnostic == nil {
|
||||
t.Fatal("authStatusDiagnosticFromError() = nil")
|
||||
}
|
||||
if diagnostic.Reason != "ciphertext_key_mismatch" {
|
||||
t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason)
|
||||
}
|
||||
if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
@@ -335,6 +352,91 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
|
||||
calls := 0
|
||||
migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) {
|
||||
calls++
|
||||
if calls == 1 && !dryRun {
|
||||
t.Fatal("first migration call should be dry-run")
|
||||
}
|
||||
if calls == 2 && dryRun {
|
||||
t.Fatal("second migration call should execute")
|
||||
}
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
newRoot := func() (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
return root, &out
|
||||
}
|
||||
|
||||
root, out := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("dry-run output = %q", out.String())
|
||||
}
|
||||
|
||||
root, out = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("migration output = %q", out.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("migration calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) {
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) {
|
||||
t.Fatal("migration backend should not be called")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
return root
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
root := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err)
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") {
|
||||
t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
)
|
||||
|
||||
func writeEventTestAppConfig(t *testing.T, dir string, cfg authpkg.AppConfig) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal app config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(authpkg.GetAppConfigPath(dir), raw, 0o600); err != nil {
|
||||
t.Fatalf("write app config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventCredentials_PortalNormalAllowsMissingClientSecret(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
dir := t.TempDir()
|
||||
|
||||
clientID, clientSecret, err := resolveEventCredentials(dir, eventStreamTicketOptions{
|
||||
Mode: source.PortalTicketModeNormal,
|
||||
SourceID: "pre_open_source",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEventCredentials: %v", err)
|
||||
}
|
||||
if clientID != "portal-ticket-normal:pre_open_source" {
|
||||
t.Fatalf("clientID = %q, want portal-ticket-normal:pre_open_source", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
t.Fatalf("clientSecret = %q, want empty", clientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventCredentials_PortalCustomStillRequiresClientSecret(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
dir := t.TempDir()
|
||||
writeEventTestAppConfig(t, dir, authpkg.AppConfig{ClientID: "ding-custom"})
|
||||
|
||||
_, _, err := resolveEventCredentials(dir, eventStreamTicketOptions{
|
||||
Mode: source.PortalTicketModeCustom,
|
||||
})
|
||||
if !errors.Is(err, authpkg.ErrClientSecretEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type commonConsumeOptions struct {
|
||||
EventTypes []string
|
||||
Filter string
|
||||
Compact bool
|
||||
FormatRaw string
|
||||
OutputDir string
|
||||
RoutesRaw []string
|
||||
MaxEvents int
|
||||
Duration time.Duration
|
||||
Quiet bool
|
||||
Force bool
|
||||
DryRun bool
|
||||
Foreground bool
|
||||
}
|
||||
|
||||
type personalConsumeOptions struct {
|
||||
Common commonConsumeOptions
|
||||
EventKey string
|
||||
DebugRawEvents bool
|
||||
SubscribeID string
|
||||
Rule string
|
||||
Name string
|
||||
FilterJSON string
|
||||
QueryCSV string
|
||||
TTL time.Duration
|
||||
Ephemeral bool
|
||||
UserID string
|
||||
GroupID string
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalListOptions struct {
|
||||
Category string
|
||||
EnabledOnly bool
|
||||
IncludePending bool
|
||||
Format string
|
||||
}
|
||||
|
||||
type personalStatusOptions struct {
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalStopOptions struct {
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalStreamSourceOptions struct {
|
||||
ConfigDir string
|
||||
Identity personal.Identity
|
||||
TicketMode string
|
||||
TicketURL string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <event_key>",
|
||||
Short: "显示事件 schema",
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as != "user" {
|
||||
return fmt.Errorf("event schema is only supported with --as user")
|
||||
}
|
||||
def, ok := personal.Lookup(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown personal event key %q", args[0])
|
||||
}
|
||||
if !def.Public {
|
||||
return personal.PublicAvailabilityError(args[0])
|
||||
}
|
||||
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
|
||||
items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending)
|
||||
if opts.Format == "json" {
|
||||
enc := json.NewEncoder(c.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(items)
|
||||
}
|
||||
tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION")
|
||||
for _, it := range items {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
it.EventKey, it.RuleType, it.Status, it.Description)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
if format != "json" {
|
||||
return fmt.Errorf("event schema only supports json output")
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(personal.BuildSchemaDocument(def))
|
||||
}
|
||||
|
||||
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
|
||||
if opts.Common.DryRun {
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return consume.Run(ctx, cfg)
|
||||
}
|
||||
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
EventKey: eventKey,
|
||||
RuleType: ruleType,
|
||||
ClientID: identity.ClientID,
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = bus.Run(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = consume.Run(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if opts.DebugRawEvents {
|
||||
cfg.EventTypes = nil
|
||||
cfg.Filter = ""
|
||||
cfg.SubscribeID = ""
|
||||
return
|
||||
}
|
||||
cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes)
|
||||
cfg.Filter = opts.Common.Filter
|
||||
cfg.SubscribeID = strings.TrimSpace(subscribeID)
|
||||
}
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
|
||||
if eventKey == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(eventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
|
||||
if ruleType == "" {
|
||||
if def, ok := personal.Lookup(eventKey); ok {
|
||||
ruleType = def.RuleType
|
||||
}
|
||||
}
|
||||
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
|
||||
return sub, eventKey, ruleType, nil
|
||||
}
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := client.CreateSubscription(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, opts.EventKey, ruleType, nil
|
||||
}
|
||||
|
||||
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
var qs busctl.EntryStatus
|
||||
if entry != nil {
|
||||
qs = busctl.QueryEntry(*entry)
|
||||
} else {
|
||||
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
|
||||
WorkDir: workDir,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
ClientIDHash: identityHash,
|
||||
IdentityHash: identityHash,
|
||||
State: busctl.BusStateNotRunning,
|
||||
Meta: &bus.Meta{
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
},
|
||||
}}
|
||||
}
|
||||
status := opts.Status
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
if opts.Format == "json" {
|
||||
enc := json.NewEncoder(c.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]any{
|
||||
"identity": redactedPersonalIdentity(identity, identityHash),
|
||||
"subscriptions": subs,
|
||||
"bus": qs,
|
||||
})
|
||||
}
|
||||
renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePublicPersonalEvent(eventKey string) error {
|
||||
eventKey = strings.TrimSpace(eventKey)
|
||||
if eventKey == "" {
|
||||
return nil
|
||||
}
|
||||
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
|
||||
return personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) {
|
||||
fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n",
|
||||
displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash)
|
||||
fmt.Fprintf(w, "Bus: %s", qs.Entry.State)
|
||||
if qs.Entry.HolderPID > 0 {
|
||||
fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID)
|
||||
}
|
||||
fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir)
|
||||
if len(subs) == 0 {
|
||||
fmt.Fprintln(w, "Subscriptions: none")
|
||||
} else {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE")
|
||||
for _, sub := range subs {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
renderPersonalConsumers(w, qs)
|
||||
}
|
||||
|
||||
func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) {
|
||||
if qs.Entry.State != busctl.BusStateRunning {
|
||||
fmt.Fprintln(w, "Consumers: none")
|
||||
return
|
||||
}
|
||||
if qs.Live == nil {
|
||||
fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)")
|
||||
return
|
||||
}
|
||||
if len(qs.Live.Consumers) == 0 {
|
||||
fmt.Fprintln(w, "Consumers: none")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Consumers:")
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED")
|
||||
for _, cs := range qs.Live.Consumers {
|
||||
eventKeys := strings.Join(cs.EventTypes, ",")
|
||||
if eventKeys == "" {
|
||||
eventKeys = "(catch-all)"
|
||||
}
|
||||
subscribeID := displayPersonalStatusValue(cs.SubscribeID)
|
||||
filter := displayPersonalStatusValue(cs.Filter)
|
||||
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n",
|
||||
cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func displayPersonalStatusValue(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "-"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
ctx := c.Context()
|
||||
explicitSubscribeID := strings.TrimSpace(opts.SubscribeID)
|
||||
isSingleTarget := explicitSubscribeID != ""
|
||||
if explicitSubscribeID != "" && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if explicitSubscribeID == "" && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := client.DeleteSubscription(ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||
}
|
||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||
}
|
||||
|
||||
remaining, err := personal.LoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running")
|
||||
return nil
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
if explicit != "" && all {
|
||||
return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if explicit != "" {
|
||||
return []string{explicit}, nil
|
||||
}
|
||||
if !all {
|
||||
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
|
||||
}
|
||||
states, err := personal.LoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(states))
|
||||
for _, st := range states {
|
||||
if st.SubscribeID != "" {
|
||||
ids = append(ids, st.SubscribeID)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error {
|
||||
targets := make(map[string]struct{}, len(subscribeIDs))
|
||||
for _, id := range subscribeIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targets[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if ipcEndpoint == "" || len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
status, err := busctl.QueryStatus(ipcEndpoint)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
signalled := make(map[int]struct{})
|
||||
for _, consumer := range status.Consumers {
|
||||
if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok {
|
||||
continue
|
||||
}
|
||||
if consumer.PID <= 0 || consumer.PID == os.Getpid() {
|
||||
continue
|
||||
}
|
||||
if _, ok := signalled[consumer.PID]; ok {
|
||||
continue
|
||||
}
|
||||
proc, err := os.FindProcess(consumer.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
signalled[consumer.PID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
|
||||
if single && len(subscribeIDs) == 1 {
|
||||
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
return personal.Identity{}, err
|
||||
}
|
||||
tokenData, _ := authpkg.LoadTokenData(configDir)
|
||||
var corpID, userID, clientID, refreshToken string
|
||||
if tokenData != nil {
|
||||
corpID = tokenData.CorpID
|
||||
userID = tokenData.UserID
|
||||
clientID = tokenData.ClientID
|
||||
refreshToken = tokenData.RefreshToken
|
||||
}
|
||||
if corpID == "" {
|
||||
corpID = resolveRuntimeDefault(ctx, "$corpId")
|
||||
}
|
||||
if userID == "" {
|
||||
userID = resolveRuntimeDefault(ctx, "$currentUserId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = authpkg.ClientID()
|
||||
}
|
||||
if clientID == "" {
|
||||
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
|
||||
clientID = id
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
|
||||
}
|
||||
sourceID := strings.TrimSpace(sourceIDOverride)
|
||||
if sourceID == "" {
|
||||
sourceID = personalEventStreamSourceID("")
|
||||
}
|
||||
localSubject := ""
|
||||
if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" {
|
||||
localSubject = personalTokenSubject("refresh", refreshToken)
|
||||
if localSubject == "" {
|
||||
localSubject = personalTokenSubject("access", accessToken)
|
||||
}
|
||||
}
|
||||
return personal.Identity{
|
||||
AccessToken: accessToken,
|
||||
LocalSubject: localSubject,
|
||||
CorpID: corpID,
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
SourceID: sourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func personalTokenSubject(kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func resolveRuntimeDefault(ctx context.Context, key string) string {
|
||||
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
|
||||
if fn := fnMap()[key]; fn != nil {
|
||||
if v, ok := fn(ctx); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
mode := strings.TrimSpace(opts.TicketMode)
|
||||
if mode == "" {
|
||||
mode = "normal"
|
||||
}
|
||||
if mode != "normal" && mode != "custom" {
|
||||
return nil, fmt.Errorf("stream ticket mode must be normal or custom")
|
||||
}
|
||||
ticketURL := strings.TrimSpace(opts.TicketURL)
|
||||
if ticketURL == "" {
|
||||
ticketURL = personalEventStreamTicketURL("", opts.ConfigDir)
|
||||
}
|
||||
clientID := opts.Identity.ClientID
|
||||
clientSecret := ""
|
||||
if mode == "custom" {
|
||||
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.ClientIDOverride != "" {
|
||||
clientID = opts.ClientIDOverride
|
||||
} else if clientID == "" {
|
||||
clientID = resolvedID
|
||||
}
|
||||
clientSecret = secret
|
||||
}
|
||||
_ = ctx
|
||||
return source.NewPersonal(source.PersonalConfig{
|
||||
AccessToken: opts.Identity.AccessToken,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
}
|
||||
if strings.TrimSpace(ticketURL) != "" {
|
||||
args = append(args, "--stream-ticket-url", ticketURL)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func personalEventTypes(eventKey string, explicit []string) []string {
|
||||
if len(explicit) > 0 {
|
||||
return explicit
|
||||
}
|
||||
if strings.TrimSpace(eventKey) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{eventKey}
|
||||
}
|
||||
|
||||
func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string {
|
||||
return map[string]string{
|
||||
"corp_id": displayIdentityPart(identity.CorpID),
|
||||
"user_id": displayIdentityPart(identity.UserID),
|
||||
"client_id": identity.ClientID,
|
||||
"source_id": identity.SourceID,
|
||||
"identity_hash": identityHash,
|
||||
}
|
||||
}
|
||||
|
||||
func displayIdentityPart(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func firstNonEmptyPersonalString(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func personalEventControlBaseURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath
|
||||
}
|
||||
|
||||
func personalEventStreamTicketURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket"
|
||||
}
|
||||
|
||||
func personalEventStreamSourceID(raw string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
|
||||
return v
|
||||
}
|
||||
return "open"
|
||||
}
|
||||
|
||||
func personalEventMCPBaseURL(configDir string) string {
|
||||
if v := configuredMCPBaseURL(configDir); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return config.DefaultMCPBaseURL
|
||||
}
|
||||
|
||||
func configuredMCPBaseURL(configDir string) string {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
configDir = defaultConfigDir()
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
opts := personalConsumeOptions{
|
||||
DebugRawEvents: true,
|
||||
Common: commonConsumeOptions{
|
||||
EventTypes: []string{"should-not-survive"},
|
||||
Filter: "^should-not-survive$",
|
||||
},
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
|
||||
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
|
||||
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
opts := personalConsumeOptions{Common: commonConsumeOptions{Filter: "^user_im_"}}
|
||||
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
|
||||
if len(cfg.EventTypes) != 1 || cfg.EventTypes[0] != "user_im_message_receive_o2o" {
|
||||
t.Fatalf("eventTypes = %#v", cfg.EventTypes)
|
||||
}
|
||||
if cfg.Filter != "^user_im_" || cfg.SubscribeID != "sub-1" {
|
||||
t.Fatalf("filter=%q subscribeID=%q", cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", "--debug-raw-events"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", personal.EventSingleChat})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
for _, name := range []string{"user", "group", "query"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("flag --%s is not registered", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"peer-user-id",
|
||||
"peer-union-id",
|
||||
"sender-user-id",
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
} {
|
||||
if cmd.Flags().Lookup(name) != nil {
|
||||
t.Fatalf("retired flag --%s is still registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer-user-id",
|
||||
"peer-union-id",
|
||||
"sender-user-id",
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--" + name, "x"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --"+name) {
|
||||
t.Fatalf("Execute() error = %v, want unknown flag", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--as", "app", "--user", "507971"},
|
||||
{"--as", "app", "--group", "cid"},
|
||||
{"--as", "app", "--query", "报警"},
|
||||
} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute(%v) error = %v, want public availability guard", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestResolvePersonalEventIdentityUsesCorpUserWhenAvailable(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
if identity.LocalSubject != "" {
|
||||
t.Fatalf("LocalSubject = %q, want empty when corp/user are available", identity.LocalSubject)
|
||||
}
|
||||
wantKey := "corp_user\x00corp-1\x00user-1\x00client-1\x00pre_open_source"
|
||||
if got := identity.Key(); got != wantKey {
|
||||
t.Fatalf("identity key = %q, want %q", got, wantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityFallsBackToRefreshTokenSubject(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
wantSubject := personalTokenSubject("refresh", "refresh-1")
|
||||
if identity.LocalSubject != wantSubject {
|
||||
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
|
||||
}
|
||||
if strings.Contains(identity.Key(), "refresh-1") || strings.Contains(identity.Key(), "access-1") {
|
||||
t.Fatalf("identity key leaked raw token: %q", identity.Key())
|
||||
}
|
||||
|
||||
body, err := json.Marshal(redactedPersonalIdentity(identity, "identity-hash-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal redacted identity: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), wantSubject) || strings.Contains(string(body), "refresh-1") || strings.Contains(string(body), "access-1") {
|
||||
t.Fatalf("redacted identity leaked local subject/token: %s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "unknown") {
|
||||
t.Fatalf("redacted identity should mark missing corp/user as unknown: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityFallsBackToAccessTokenSubject(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
wantSubject := personalTokenSubject("access", "access-1")
|
||||
if identity.LocalSubject != wantSubject {
|
||||
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, identity, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{WorkDir: "wd", State: busctl.BusStateNotRunning},
|
||||
})
|
||||
rendered := out.String()
|
||||
if !strings.Contains(rendered, "corp=unknown user=unknown") {
|
||||
t.Fatalf("status output = %q, want unknown corp/user", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, wantSubject) || strings.Contains(rendered, "access-1") {
|
||||
t.Fatalf("status output leaked local subject/token: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
if identity.SourceID != "open" {
|
||||
t.Fatalf("SourceID = %q, want open", identity.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" {
|
||||
t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" {
|
||||
t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID(""); got != "open" {
|
||||
t.Fatalf("personalEventStreamSourceID() = %q, want open", got)
|
||||
}
|
||||
if got := config.GetMCPBaseURL(); got != "https://mcp.dingtalk.com" {
|
||||
t.Fatalf("config.GetMCPBaseURL() = %q, want production MCP URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventDefaultsRespectExplicitAndMCPConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("write mcp_url: %v", err)
|
||||
}
|
||||
|
||||
if got := personalEventControlBaseURL("", dir); got != "https://custom-mcp.example.com/dws" {
|
||||
t.Fatalf("personalEventControlBaseURL() = %q, want configured control URL", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL("", dir); got != "https://custom-mcp.example.com/stream/connections/ticket" {
|
||||
t.Fatalf("personalEventStreamTicketURL() = %q, want configured ticket URL", got)
|
||||
}
|
||||
if got := personalEventControlBaseURL(" https://override.example.com/dws/ ", dir); got != "https://override.example.com/dws" {
|
||||
t.Fatalf("explicit control URL = %q, want trimmed override", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL(" https://override.example.com/ticket/ ", dir); got != "https://override.example.com/ticket" {
|
||||
t.Fatalf("explicit ticket URL = %q, want trimmed override", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
|
||||
t.Fatalf("explicit sourceID = %q, want flag_source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSourceIDPrefersEditionOverride(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{PersonalEventSourceID: "edition_source"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
if got := personalEventStreamSourceID(""); got != "edition_source" {
|
||||
t.Fatalf("personalEventStreamSourceID() = %q, want edition_source", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
|
||||
t.Fatalf("explicit sourceID = %q, want flag_source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPersonalIdentityToken(t *testing.T, data *authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal token data: %v", err)
|
||||
}
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(dir string) ([]byte, error) {
|
||||
if filepath.Clean(dir) != filepath.Clean(configDir) {
|
||||
t.Fatalf("LoadToken dir = %q, want %q", dir, configDir)
|
||||
}
|
||||
return raw, nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
return configDir
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "table", args: []string{"--as", "user"}},
|
||||
{name: "json", args: []string{"--as", "user", "--format", "json"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
assertPersonalOutputHidesSchemaIDs(t, got)
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventListDefaultsToUser(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
|
||||
t.Fatalf("list output = %s, want personal event catalog", got)
|
||||
}
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
|
||||
t.Fatalf("list default appears to use legacy application output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventPublicHelpHidesAppMode(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "consume", cmd: newEventConsumeCommand()},
|
||||
{name: "list", cmd: newEventListCommand()},
|
||||
{name: "schema", cmd: newEventSchemaCommand()},
|
||||
{name: "status", cmd: newEventStatusCommand()},
|
||||
{name: "stop", cmd: newEventStopCommand()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
tc.cmd.SetOut(&out)
|
||||
tc.cmd.SetArgs([]string{"--help"})
|
||||
if tc.name == "schema" {
|
||||
tc.cmd.SetArgs([]string{personal.EventSingleChat, "--help"})
|
||||
}
|
||||
if err := tc.cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, hidden := range []string{"--as", "user|app", "应用事件" + " Stream"} {
|
||||
if strings.Contains(got, hidden) {
|
||||
t.Fatalf("%s help leaked %q:\n%s", tc.name, hidden, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventListAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--all are not supported for personal events") {
|
||||
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAsAppRejected(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newEventListCommand(),
|
||||
newEventStatusCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventStopCommand(),
|
||||
newEventSchemaCommand(),
|
||||
} {
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app"})
|
||||
if cmd.Use == "schema <event_key>" {
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--as", "app"})
|
||||
}
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("%s Execute() error = %v, want public availability guard", cmd.Use, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStatusAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
|
||||
cmd := newEventStatusCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--all", "--fail-on-orphan"})
|
||||
err := cmd.Execute()
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "--all") ||
|
||||
!strings.Contains(err.Error(), "--fail-on-orphan") ||
|
||||
!strings.Contains(err.Error(), "not supported for personal events") {
|
||||
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "default", args: []string{personal.EventSingleChat, "--as", "user"}},
|
||||
{name: "json", args: []string{personal.EventSingleChat, "--as", "user", "--format", "json"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
assertPersonalOutputHidesSchemaIDs(t, out.String())
|
||||
if strings.Contains(out.String(), "Schemas") {
|
||||
t.Fatalf("schema output contains Schemas line: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
for _, eventKey := range []string{
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventInChat,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output for %s is not JSON: %v\n%s", eventKey, err, got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"event_key",
|
||||
"display_name",
|
||||
"description",
|
||||
"category",
|
||||
"rule_type",
|
||||
"required_params",
|
||||
"jq_root_path",
|
||||
"schema",
|
||||
"event_id",
|
||||
"timestamp",
|
||||
"subscribe_id",
|
||||
"content",
|
||||
"sender",
|
||||
"sender_open_dingtalk_id",
|
||||
"conversation_id",
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{
|
||||
"message.text",
|
||||
"chat.openConversationId",
|
||||
"sender.userId",
|
||||
"sender.unionId",
|
||||
"auth",
|
||||
"resolved_output_schema",
|
||||
"decoded_data_schema",
|
||||
"filter_schema",
|
||||
"payload_schema",
|
||||
"output_schema",
|
||||
"data_json_path",
|
||||
"headers",
|
||||
"audit",
|
||||
"tenant",
|
||||
"subject",
|
||||
"traceId",
|
||||
"msgIdMetaq",
|
||||
"at_users",
|
||||
"sender_user_id",
|
||||
} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
|
||||
}
|
||||
}
|
||||
if doc["jq_root_path"] != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
|
||||
}
|
||||
schema, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema = %#v, want object", doc["schema"])
|
||||
}
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
if _, ok := props["content"].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSchemaDefaultsToUser(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{personal.EventSingleChat})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != personal.EventSingleChat {
|
||||
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "schema",
|
||||
cmd: newEventSchemaCommand(),
|
||||
args: []string{personal.EventFromUser},
|
||||
},
|
||||
{
|
||||
name: "consume",
|
||||
cmd: newEventConsumeCommand(),
|
||||
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cmd: newEventStatusCommand(),
|
||||
args: []string{"--event", personal.EventFromUser},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.cmd.SilenceUsage = true
|
||||
tc.cmd.SilenceErrors = true
|
||||
tc.cmd.SetArgs(tc.args)
|
||||
err := tc.cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want not publicly available", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--format", "table"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event schema only supports json output") {
|
||||
t.Fatalf("Execute() error = %v, want json-only format validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAsBotRejected(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPersonalOutputHidesSchemaIDs(t *testing.T, out string) {
|
||||
t.Helper()
|
||||
for _, leaked := range []string{"SCHEMA_IDS", "schema_ids", "im_msg_23", "im_msg_29"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Fatalf("output leaked %q: %s", leaked, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func TestRenderPersonalStatusTextShowsConsumersWithoutSubscriptions(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
SourceID: "source-1",
|
||||
}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateRunning,
|
||||
HolderPID: 100,
|
||||
},
|
||||
Live: &transport.StatusResp{
|
||||
Consumers: []transport.StatusConsumer{
|
||||
{
|
||||
PID: 12345,
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "subId-1",
|
||||
Filter: "content",
|
||||
Received: 3,
|
||||
Dropped: 1,
|
||||
},
|
||||
{
|
||||
PID: 12346,
|
||||
Received: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"Subscriptions: none",
|
||||
"Consumers:",
|
||||
"PID",
|
||||
"EVENT_KEYS",
|
||||
"SUBSCRIBE_ID",
|
||||
"RECEIVED",
|
||||
"DROPPED",
|
||||
"12345",
|
||||
"user_im_message_receive_o2o",
|
||||
"subId-1",
|
||||
"content",
|
||||
"3",
|
||||
"1",
|
||||
"(catch-all)",
|
||||
"-",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("status output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPersonalStatusTextConsumersUnavailableWhenRPCFails(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateRunning,
|
||||
HolderPID: 100,
|
||||
},
|
||||
})
|
||||
if got := out.String(); !strings.Contains(got, "Consumers: unavailable (status RPC failed)") {
|
||||
t.Fatalf("status output = %q, want unavailable consumers", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPersonalStatusTextConsumersNoneWhenBusNotRunning(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateNotRunning,
|
||||
},
|
||||
})
|
||||
if got := out.String(); !strings.Contains(got, "Consumers: none") {
|
||||
t.Fatalf("status output = %q, want no consumers", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
// shape: --max-events / --duration are the lifecycle control.
|
||||
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
if shouldWatchStdinEOF(1, 0) {
|
||||
t.Error("--max-events set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(0, 5*time.Second) {
|
||||
t.Error("--duration set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(3, 2*time.Second) {
|
||||
t.Error("both bounds set should not arm stdin watcher")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the detached _bus child must receive --profile so it resolves
|
||||
// credentials for the same organization as the parent. Missing it made a
|
||||
// non-default `--profile` consume fail with "bus child reported startup
|
||||
// failure on ready pipe" (no bus.log).
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
|
||||
for _, a := range bare {
|
||||
if a == "--profile" {
|
||||
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestEventStopHelpDescribesPersonalSubscription(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"stop [subscribe_id]",
|
||||
"取消个人事件订阅并停止本地消费",
|
||||
"取消个人事件订阅并停止本地消费,清理对应本地消费状态",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, stale := range []string{"优雅停止 bus 守护进程", strings.Join([]string{"--as", "app"}, " "), "应用事件"} {
|
||||
if strings.Contains(got, stale) {
|
||||
t.Fatalf("help still contains stale public app wording %q:\n%s", stale, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopRequiresSubscribeIDOrAll(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
|
||||
t.Fatalf("Execute() error = %v, want subscribe_id requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopSubscribeIDAndAllAreMutuallyExclusive(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"subId-1", "--all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "subscribe_id and --all are mutually exclusive") {
|
||||
t.Fatalf("Execute() error = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopAsAppRejectsSubscribeID(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", "subId-1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalStopTargets(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-b"}); err != nil {
|
||||
t.Fatalf("UpsertRunState() error = %v", err)
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-a"}); err != nil {
|
||||
t.Fatalf("UpsertRunState() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := personalStopTargets(workDir, "sub-explicit", false)
|
||||
if err != nil {
|
||||
t.Fatalf("personalStopTargets(explicit) error = %v", err)
|
||||
}
|
||||
if want := []string{"sub-explicit"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("explicit targets = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got, err = personalStopTargets(workDir, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("personalStopTargets(all) error = %v", err)
|
||||
}
|
||||
if want := []string{"sub-a", "sub-b"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("all targets = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
if _, err := personalStopTargets(workDir, "", false); err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
|
||||
t.Fatalf("personalStopTargets(no target) error = %v, want required error", err)
|
||||
}
|
||||
if _, err := personalStopTargets(workDir, "sub-explicit", true); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("personalStopTargets(explicit+all) error = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPersonalStopResult(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printPersonalStopResult(&out, []string{"sub-1"}, true, "personal bus stopped")
|
||||
if got := out.String(); got != "cancelled personal subscription sub-1; personal bus stopped\n" {
|
||||
t.Fatalf("single output = %q", got)
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
printPersonalStopResult(&out, []string{"sub-1", "sub-2"}, false, "personal bus still running")
|
||||
if got := out.String(); got != "cancelled 2 personal subscription(s); personal bus still running\n" {
|
||||
t.Fatalf("multi output = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newCatalogCommand(loader),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
|
||||
@@ -216,8 +216,7 @@ func normalizeMultiSkillName(name string) string {
|
||||
return multiSkillPrefix + n
|
||||
}
|
||||
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists.
|
||||
// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists:
|
||||
//
|
||||
// - include + exclude are mutually exclusive (both → error)
|
||||
// - names accept short or full form; normalized before matching
|
||||
|
||||
@@ -445,8 +445,7 @@ func TestFilterMultiSkillNames(t *testing.T) {
|
||||
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
|
||||
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
|
||||
// touch already-installed dingtalk-* siblings (additive semantics, matches
|
||||
// lark-cli `npx skills add -s lark-calendar`).
|
||||
// touch already-installed dingtalk-* siblings (additive semantics).
|
||||
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CredentialSource identifies where a particular credential field
|
||||
// (ClientID or ClientSecret) was loaded from. It is exposed in
|
||||
// `dws event status` and the HelloAck IPC frame so users can verify which
|
||||
// credential channel is actually in use — important because env vars,
|
||||
// keychain, and config file can all coexist and silently override each
|
||||
// other (see plan §1 决策 "凭证来源拆字段").
|
||||
type CredentialSource string
|
||||
|
||||
const (
|
||||
CredentialSourceUnknown CredentialSource = "unknown"
|
||||
CredentialSourceEnv CredentialSource = "env"
|
||||
CredentialSourceAppConfig CredentialSource = "app_config" // value pulled from app config (plain or SecretRef metadata)
|
||||
CredentialSourceKeychain CredentialSource = "keychain" // SecretRef resolved through OS keychain
|
||||
CredentialSourcePlainConfig CredentialSource = "plain_config" // SecretInput stored as plaintext in config file (insecure but supported)
|
||||
)
|
||||
|
||||
// Strict resolver error sentinels. Use errors.Is to distinguish failure
|
||||
// modes; see plan §8 strict resolver decision (4 classes).
|
||||
var (
|
||||
// ErrAppConfigMissing — no app config file on disk AND no env-var
|
||||
// credentials present. Prompt the user to either `dws config init` or
|
||||
// set DWS_CLIENT_ID + DWS_CLIENT_SECRET.
|
||||
ErrAppConfigMissing = errors.New("app config missing: run `dws config init` or set DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars")
|
||||
// ErrClientIDEmpty — neither env nor config supplies a non-empty ClientID.
|
||||
ErrClientIDEmpty = errors.New("ClientID is empty")
|
||||
// ErrClientSecretEmpty — there's a ClientID but ClientSecret resolved to "".
|
||||
ErrClientSecretEmpty = errors.New("ClientSecret is empty")
|
||||
// ErrSecretResolve — the secret-resolution backend (keychain) failed
|
||||
// unrecoverably. Typically headless Linux without gnome-keyring, locked
|
||||
// macOS keychain, or CI sandboxes. Suggest the env-var fallback.
|
||||
ErrSecretResolve = errors.New("ClientSecret resolution failed (keychain unavailable?); try DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars")
|
||||
)
|
||||
|
||||
// Env var names used by the env fallback channel. Must be set as a pair —
|
||||
// any single-variable configuration is rejected so users cannot accidentally
|
||||
// "set the env half-way" and silently fall back to keychain.
|
||||
const (
|
||||
EnvClientID = "DWS_CLIENT_ID"
|
||||
EnvClientSecret = "DWS_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
// ResolveAppCredentialsStrict is the credentials channel used by the event
|
||||
// subsystem (and by future commands that need fine-grained failure
|
||||
// reporting). It distinguishes 4 failure classes and reports the source of
|
||||
// each successfully-resolved field separately.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Env var override: if BOTH DWS_CLIENT_ID and DWS_CLIENT_SECRET are
|
||||
// set non-empty, use them as a pair and skip keychain/config entirely.
|
||||
// Single-variable configuration is detected and reported via the
|
||||
// EnvHalfSet flag in the warning channel (callers MAY log a warning).
|
||||
// 2. App config from disk:
|
||||
// - ClientID from cfg.ClientID
|
||||
// - ClientSecret from ResolveSecret(cfg.ClientSecret):
|
||||
// - SecretInput.IsPlain() → CredentialSourcePlainConfig
|
||||
// - SecretRef → CredentialSourceKeychain (or whatever Ref.Source says)
|
||||
//
|
||||
// Empty returns: clientID and secret may be empty when err is non-nil;
|
||||
// callers must NOT use them in that case.
|
||||
func ResolveAppCredentialsStrict(configDir string) (
|
||||
clientID, secret string,
|
||||
clientIDSource, secretSource CredentialSource,
|
||||
err error,
|
||||
) {
|
||||
// Step 1: env var fallback (atomic pair)
|
||||
envID := os.Getenv(EnvClientID)
|
||||
envSecret := os.Getenv(EnvClientSecret)
|
||||
if envID != "" && envSecret != "" {
|
||||
return envID, envSecret, CredentialSourceEnv, CredentialSourceEnv, nil
|
||||
}
|
||||
// Note: if only one of the two is set we explicitly do NOT use it.
|
||||
// The half-set warning is surfaced via EnvHalfSet() so the CLI can
|
||||
// stderr-warn the user during preflight.
|
||||
|
||||
// Step 2: app config from disk
|
||||
cfg, loadErr := LoadAppConfig(configDir)
|
||||
if loadErr != nil {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown,
|
||||
fmt.Errorf("load app config: %w", loadErr)
|
||||
}
|
||||
if cfg == nil {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrAppConfigMissing
|
||||
}
|
||||
|
||||
if cfg.ClientID == "" {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrClientIDEmpty
|
||||
}
|
||||
clientID = cfg.ClientID
|
||||
clientIDSource = CredentialSourceAppConfig
|
||||
|
||||
// Resolve secret. Source depends on the SecretInput shape:
|
||||
// - IsPlain (no Ref) → it's stored as plaintext in the config file
|
||||
// - has Ref → it's a SecretRef pointing at keychain/file
|
||||
wasPlain := cfg.ClientSecret.IsPlain()
|
||||
resolved, resolveErr := ResolveSecret(cfg.ClientSecret)
|
||||
if resolveErr != nil {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown,
|
||||
fmt.Errorf("%w: %v", ErrSecretResolve, resolveErr)
|
||||
}
|
||||
if resolved == "" {
|
||||
return "", "", clientIDSource, CredentialSourceUnknown, ErrClientSecretEmpty
|
||||
}
|
||||
|
||||
secret = resolved
|
||||
if wasPlain {
|
||||
secretSource = CredentialSourcePlainConfig
|
||||
} else {
|
||||
// For SecretRef we map Source verbatim (keychain / file / future)
|
||||
switch cfg.ClientSecret.Ref.Source {
|
||||
case "keychain":
|
||||
secretSource = CredentialSourceKeychain
|
||||
default:
|
||||
// File-backed secrets share the "plain_config" category from
|
||||
// the consumer's perspective: stored as readable bytes outside
|
||||
// keychain. Status output renders them as "plain_config" so
|
||||
// users see "secret is not in keychain".
|
||||
secretSource = CredentialSourcePlainConfig
|
||||
}
|
||||
}
|
||||
|
||||
return clientID, secret, clientIDSource, secretSource, nil
|
||||
}
|
||||
|
||||
// EnvHalfSet reports whether exactly one of (DWS_CLIENT_ID, DWS_CLIENT_SECRET)
|
||||
// is set. Used by CLI preflight to emit a clear stderr warning of the form:
|
||||
//
|
||||
// WARN: DWS_CLIENT_ID is set but DWS_CLIENT_SECRET is not — env fallback
|
||||
// disabled; using keychain/app config. Set both or unset both to
|
||||
// avoid this warning.
|
||||
//
|
||||
// The strict resolver itself does NOT log; logging is the caller's job.
|
||||
func EnvHalfSet() bool {
|
||||
id := os.Getenv(EnvClientID) != ""
|
||||
secret := os.Getenv(EnvClientSecret) != ""
|
||||
return id != secret
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resetStrictResolverState clears caches the strict resolver shares with
|
||||
// the existing legacy resolver. Tests must call this between scenarios
|
||||
// because GetCachedAppConfig and the resolved-credential cache outlive
|
||||
// individual t.TempDir setups.
|
||||
func resetStrictResolverState(t *testing.T) {
|
||||
t.Helper()
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = nil
|
||||
cachedAppConfigMu.Unlock()
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
}
|
||||
|
||||
// writeAppConfig drops a config JSON into dir. clientSecret == "" produces
|
||||
// the legacy "no SecretInput field" shape (treated as empty).
|
||||
func writeAppConfig(t *testing.T, dir, clientID, clientSecret string) {
|
||||
t.Helper()
|
||||
cfg := AppConfig{
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if clientSecret != "" {
|
||||
cfg.ClientSecret = PlainSecret(clientSecret)
|
||||
}
|
||||
path := GetAppConfigPath(dir)
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(EnvClientID, "")
|
||||
t.Setenv(EnvClientSecret, "")
|
||||
_ = os.Unsetenv(EnvClientID)
|
||||
_ = os.Unsetenv(EnvClientSecret)
|
||||
}
|
||||
|
||||
func TestResolveStrict_AppConfigMissing(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
|
||||
if !errors.Is(err, ErrAppConfigMissing) {
|
||||
t.Fatalf("err = %v, want ErrAppConfigMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_ClientIDEmpty(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
writeAppConfig(t, dir, "", "some-secret")
|
||||
|
||||
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
|
||||
if !errors.Is(err, ErrClientIDEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientIDEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_ClientSecretEmpty(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
writeAppConfig(t, dir, "ding_abc", "")
|
||||
|
||||
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
|
||||
if !errors.Is(err, ErrClientSecretEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_PlainConfigSuccess(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
writeAppConfig(t, dir, "ding_abc", "supersecret123")
|
||||
|
||||
id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if id != "ding_abc" {
|
||||
t.Errorf("id = %q", id)
|
||||
}
|
||||
if secret != "supersecret123" {
|
||||
t.Errorf("secret = %q", secret)
|
||||
}
|
||||
if idSrc != CredentialSourceAppConfig {
|
||||
t.Errorf("idSrc = %s, want app_config", idSrc)
|
||||
}
|
||||
if secretSrc != CredentialSourcePlainConfig {
|
||||
t.Errorf("secretSrc = %s, want plain_config (PlainSecret was used)", secretSrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_SecretRefFileSuccess(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
// Write secret file
|
||||
secretPath := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(secretPath, []byte("via-file-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Write app config with file SecretRef
|
||||
cfg := AppConfig{
|
||||
ClientID: "ding_abc",
|
||||
ClientSecret: SecretInput{
|
||||
Ref: &SecretRef{Source: "file", ID: secretPath},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
b, _ := json.MarshalIndent(cfg, "", " ")
|
||||
if err := os.WriteFile(GetAppConfigPath(dir), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if id != "ding_abc" || secret != "via-file-secret" {
|
||||
t.Errorf("id/secret = %q/%q", id, secret)
|
||||
}
|
||||
if idSrc != CredentialSourceAppConfig {
|
||||
t.Errorf("idSrc = %s", idSrc)
|
||||
}
|
||||
// File-backed secrets are reported as plain_config (not in keychain).
|
||||
if secretSrc != CredentialSourcePlainConfig {
|
||||
t.Errorf("secretSrc = %s, want plain_config for file-backed secret", secretSrc)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,10 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
|
||||
// MigrateKeychainToFileDEK serializes migration with profile/token updates so
|
||||
// refresh and login cannot rewrite an entry while its DEK backend is changing.
|
||||
func MigrateKeychainToFileDEK(configDir string, dryRun bool) (int, error) {
|
||||
var migrated int
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
var err error
|
||||
migrated, err = keychain.MigrateToFileDEK(keychain.Service, dryRun)
|
||||
return err
|
||||
})
|
||||
return migrated, err
|
||||
}
|
||||
@@ -15,12 +15,14 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,6 +30,9 @@ var (
|
||||
migrationDone bool
|
||||
)
|
||||
|
||||
// ErrTokenDataNotFound means the requested keychain slot does not exist.
|
||||
var ErrTokenDataNotFound = errors.New("token data not found")
|
||||
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
@@ -86,7 +91,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
return nil, fmt.Errorf("%w in keychain account %q", ErrTokenDataNotFound, account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -96,6 +101,72 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// preflightTokenPersistence verifies that every registered token slot can be
|
||||
// read before an OAuth login or exchange can target any profile.
|
||||
// A missing slot is safe (first login or a legacy fallback); any other error
|
||||
// stops the remote operation when existing ciphertext is already known to be
|
||||
// unreadable and therefore unsafe to update.
|
||||
func preflightTokenPersistence(configDir string) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token profiles: %w", err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cfg.Profiles))
|
||||
for _, profile := range cfg.Profiles {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
if corpID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[corpID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[corpID] = struct{}{}
|
||||
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"profile token slot %q is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, remove only this profile with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
|
||||
TokenAccountForCorpID(corpID), corpID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := keychain.ValidateAuthTokenEntries(keychain.Service); err != nil {
|
||||
return fmt.Errorf(
|
||||
"auth token ciphertext inventory is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, use `dws auth reset` only when discarding all local profiles: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
if data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
|
||||
func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
|
||||
@@ -27,10 +27,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Use MCP mode if clientID is from MCP server
|
||||
if IsClientIDFromMCP() {
|
||||
return p.exchangeCodeViaMCP(ctx, code)
|
||||
|
||||
@@ -110,6 +110,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
@@ -623,6 +626,9 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if !data.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("refresh_token 已过期")
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(data); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
|
||||
@@ -67,7 +67,11 @@ func PortableAuthTargetPopulated(configDir string) bool {
|
||||
|
||||
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
|
||||
func PortableAuthSourceReady() bool {
|
||||
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
|
||||
if !portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) {
|
||||
return false
|
||||
}
|
||||
_, err := LoadTokenDataKeychain()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func portableAuthSourcePopulated(keychainDir string) bool {
|
||||
@@ -93,7 +97,7 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
|
||||
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := os.Stat(keychainDir); err != nil {
|
||||
@@ -102,6 +106,9 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if !portableAuthSourcePopulated(keychainDir) {
|
||||
return fmt.Errorf("auth token is not available for export; run dws auth login first")
|
||||
}
|
||||
if _, err := LoadTokenDataKeychain(); err != nil {
|
||||
return fmt.Errorf("auth token cannot be decrypted with the portable file DEK: %w", err)
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -187,7 +188,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
@@ -197,6 +198,8 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
|
||||
return nil, lerr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
type preflightRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setPreflightTestCredentials(t *testing.T) {
|
||||
t.Helper()
|
||||
SetClientID("preflight-client-id")
|
||||
SetClientSecret("preflight-client-secret")
|
||||
resetClientIDFromMCP()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
}
|
||||
|
||||
func profileCiphertextPathForTest(corpID string) string {
|
||||
account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_")
|
||||
return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc")
|
||||
}
|
||||
|
||||
func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_fallback", "corp_fallback", "Fallback Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil {
|
||||
t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != data.AccessToken {
|
||||
t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_preflight", "corp_preflight", "Preflight Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
err := preflightTokenPersistence(configDir)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile token slot") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
|
||||
|
||||
// Simulate interruption after the profile ciphertext rename but before
|
||||
// profiles.json is updated by saveTokenDataLocked.
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("profiles.json stat error = %v, want missing metadata", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_portable", "", "")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if !PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = false before replacing DEK")
|
||||
}
|
||||
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
if PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK")
|
||||
}
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(configDir, &bundle); err == nil {
|
||||
t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK")
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
if err := preflightTokenRefreshPersistence(dataB); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
|
||||
}
|
||||
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("Login() with valid B and unreadable A error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != dataB.AccessToken {
|
||||
t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
|
||||
setPreflightTestCredentials(t)
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.NoBrowser = true
|
||||
_, err := provider.Login(ctx, force)
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "unexpected device code request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
_, err := provider.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("device code requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_refresh", "corp_refresh", "Refresh Org")
|
||||
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
|
||||
if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected refresh request")
|
||||
})}
|
||||
_, err := provider.lockedRefresh(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("refresh HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
|
||||
)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v", err)
|
||||
}
|
||||
if data.AccessToken != "new-access" || data.UserID != "user-new" {
|
||||
t.Fatalf("ExchangeAuthCode() data = %#v", data)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,10 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
Short: "查看有限的本地 Schema(静态端点模式)",
|
||||
Long: `查看有限的本地 Schema 元数据。
|
||||
|
||||
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
|
||||
服务发现和动态 schema 已下线。静态端点模式下,schema 覆盖两类命令:
|
||||
1. helper-only 子树(如 dev):CONTENT 从其绑定的 MCP 服务实时取,source 为 mcp:<server>;
|
||||
2. 登记的本地命令(如 event):从二进制注册的 cobra flag 合成,source 为 cobra。
|
||||
其余普通产品命令和 flag 仍以当前二进制的 --help 为准。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -88,7 +91,7 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees support.
|
||||
// Helper-only subtrees: schema CONTENT fetched live from the MCP server.
|
||||
if len(args) > 0 && helperTools != nil {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
@@ -101,6 +104,19 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
}
|
||||
}
|
||||
|
||||
// Registered local subtrees (event, …): schema synthesized from cobra flags.
|
||||
if len(args) > 0 {
|
||||
payload, ok, err := renderCobraSchema(cmd.Root(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// cobraSchemaRoots are top-level command names whose subtrees answer `dws schema`
|
||||
// by SYNTHESIZING the machine-readable input schema from their cobra flags. This
|
||||
// is the local-command counterpart to helperSchemaRoots: helper subtrees (dev)
|
||||
// fetch CONTENT live from an MCP server, whereas these commands have no MCP
|
||||
// backing so the schema is built from the flags the binary actually registered.
|
||||
//
|
||||
// The output shape is the same flat object helper leaves emit — {description,
|
||||
// path, source, parameters{<flag>:{type,description,required,default?}}} — so an
|
||||
// agent gets one consistent schema format no matter the source; `source` is
|
||||
// "cobra" to mark it synthesized from flags (vs "mcp:<server>"). event is the
|
||||
// first consumer; register more command trees here as they adopt the contract.
|
||||
var cobraSchemaRoots = map[string]bool{"event": true}
|
||||
|
||||
// renderCobraSchema builds the `dws schema` payload for command subtrees listed
|
||||
// in cobraSchemaRoots. Mirrors renderHelperSchema's routing: returns
|
||||
// (payload, true) when the path targets a registered subtree so the caller
|
||||
// skips the static-mode fallback; (nil, false) otherwise.
|
||||
//
|
||||
// A runnable leaf renders the flat parameter object synthesized from its flags
|
||||
// (plus positional arguments parsed from its Use line). A group/root renders the
|
||||
// same browse listing helper groups use.
|
||||
func renderCobraSchema(root *cobra.Command, rawPath string) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !cobraSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Any non-flag leftover token means an unknown subcommand — surface it with
|
||||
// the closest group's children, same as renderHelperSchema.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
return cobraLeafSchema(target), true, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// cobraLeafSchema renders one runnable command as the flat schema object,
|
||||
// synthesizing parameters from its flags and (when present) positional
|
||||
// arguments from its Use line.
|
||||
func cobraLeafSchema(cmd *cobra.Command) map[string]any {
|
||||
out := map[string]any{
|
||||
"description": strings.TrimSpace(cmd.Short),
|
||||
"path": helperCommandPath(cmd),
|
||||
"source": "cobra",
|
||||
"parameters": cobraFlatParameters(cmd),
|
||||
}
|
||||
if args := cobraPositionalArgs(cmd); len(args) > 0 {
|
||||
out["arguments"] = args
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cobraFlatParameters projects a command's LOCAL flags into the flat
|
||||
// per-parameter object. Local (non-inherited) flags are the command-specific
|
||||
// inputs; global persistent flags inherited from the root (--profile, --verbose,
|
||||
// --jq, …) are intentionally excluded so the schema describes THIS command, not
|
||||
// the whole CLI. Hidden internal flags are skipped. Each entry is
|
||||
// {type, description, required, default?} with type mapped to a JSON-type
|
||||
// string, required read from cobra's required-flag annotation, and default only
|
||||
// when the flag has a meaningful (non-zero) default.
|
||||
func cobraFlatParameters(cmd *cobra.Command) map[string]any {
|
||||
params := map[string]any{}
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
entry := map[string]any{
|
||||
"type": pflagJSONType(f),
|
||||
"description": strings.TrimSpace(f.Usage),
|
||||
"required": flagIsRequired(f),
|
||||
}
|
||||
if def, ok := meaningfulDefault(f); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[f.Name] = entry
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
// cobraPositionalArgs parses a command's Use line into structured positional
|
||||
// arguments. Cobra has no typed metadata for positionals, so the Use string
|
||||
// ("consume [event_key]", "stop [subscribe_id]") is the source of truth:
|
||||
// tokens after the command name are positional slots. <name> is required,
|
||||
// [name] is optional, a trailing "..." marks it variadic. Returns nil when the
|
||||
// command declares no positionals (e.g. flag-only commands), so the leaf object
|
||||
// simply omits the "arguments" field.
|
||||
func cobraPositionalArgs(cmd *cobra.Command) []map[string]any {
|
||||
fields := strings.Fields(cmd.Use)
|
||||
if len(fields) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, tok := range fields[1:] {
|
||||
variadic := strings.Contains(tok, "...")
|
||||
required := strings.HasPrefix(tok, "<")
|
||||
name := strings.Trim(tok, "[]<>.")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
arg := map[string]any{
|
||||
"name": name,
|
||||
"required": required,
|
||||
}
|
||||
if variadic {
|
||||
arg["variadic"] = true
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pflagJSONType maps a pflag value type to a JSON-type string. Duration is
|
||||
// expressed as "string" because it is entered as a CLI string ("10m"); slice
|
||||
// and array types collapse to "array"; the numeric families collapse to
|
||||
// "integer"/"number". Unknown types default to "string" so the contract is
|
||||
// always populated.
|
||||
func pflagJSONType(f *pflag.Flag) string {
|
||||
switch f.Value.Type() {
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "int", "int8", "int16", "int32", "int64",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64", "count":
|
||||
return "integer"
|
||||
case "float32", "float64":
|
||||
return "number"
|
||||
case "stringSlice", "stringArray", "intSlice", "int32Slice", "int64Slice",
|
||||
"uintSlice", "float32Slice", "float64Slice", "boolSlice", "durationSlice":
|
||||
return "array"
|
||||
default:
|
||||
// string, duration, ip, and any custom Value type read as a string.
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// flagIsRequired reports whether cobra.MarkFlagRequired was applied to the flag
|
||||
// (it records the requirement in the flag's annotations under
|
||||
// cobra.BashCompOneRequiredFlag). Event's conditionally-required inputs
|
||||
// (--user / --group depend on the event key) are enforced at runtime, not via
|
||||
// this annotation, so they read as required:false here — the dependency is
|
||||
// documented in the command help, not the flag metadata.
|
||||
func flagIsRequired(f *pflag.Flag) bool {
|
||||
if f.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
vals, ok := f.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
return ok && len(vals) == 1 && vals[0] == "true"
|
||||
}
|
||||
|
||||
// meaningfulDefault returns a flag's default only when it is a real value, not
|
||||
// the zero/unset sentinel ("", "0", "0s", "false", "[]"). A zero default means
|
||||
// "no default — the value comes from you", so omitting it matches how the helper
|
||||
// renderer omits absent MCP defaults and keeps the schema free of noise.
|
||||
func meaningfulDefault(f *pflag.Flag) (string, bool) {
|
||||
def := strings.TrimSpace(f.DefValue)
|
||||
switch def {
|
||||
case "", "0", "0s", "false", "[]", "{}":
|
||||
return "", false
|
||||
default:
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildEventTestTree mirrors the shape of the real `dws event` subtree closely
|
||||
// enough to exercise the cobra-flag schema renderer: a group with a runnable
|
||||
// leaf that carries a positional arg, typed flags (string/int/duration/bool),
|
||||
// a required flag, a hidden internal flag, and a defaulted flag.
|
||||
func buildEventTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// A global persistent flag inherited by every command — must NOT appear in a
|
||||
// command's synthesized parameters (it describes the CLI, not the command).
|
||||
root.PersistentFlags().String("profile", "", "组织 profile")
|
||||
|
||||
consume := &cobra.Command{
|
||||
Use: "consume [event_key]",
|
||||
Short: "订阅事件流并输出到 stdout",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
f := consume.Flags()
|
||||
f.StringP("format", "f", "ndjson", "输出格式")
|
||||
f.String("user", "", "单聊对端 userId")
|
||||
f.String("group", "", "群 openConversationId")
|
||||
f.Int("max-events", 0, "收到 N 条后退出")
|
||||
f.Duration("duration", 0, "运行时长上限")
|
||||
f.Bool("ephemeral", false, "退出时强制退订")
|
||||
f.String("subscribe-id", "", "复用已有订阅")
|
||||
f.String("client-id", "", "内部:覆盖凭证解析")
|
||||
_ = f.MarkHidden("client-id")
|
||||
// A flag marked required via cobra — must read required:true.
|
||||
f.String("token", "", "必填令牌")
|
||||
_ = consume.MarkFlagRequired("token")
|
||||
|
||||
stop := &cobra.Command{
|
||||
Use: "stop [subscribe_id]",
|
||||
Short: "取消订阅",
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
stop.Flags().Bool("all", false, "取消全部")
|
||||
|
||||
event := &cobra.Command{Use: "event", Short: "个人消息事件"}
|
||||
// A hidden internal subcommand — must not appear in the browse listing.
|
||||
bus := &cobra.Command{Use: "_bus", Short: "内部 bus", Hidden: true, Run: func(*cobra.Command, []string) {}}
|
||||
event.AddCommand(consume, stop, bus)
|
||||
|
||||
root.AddCommand(event)
|
||||
return root
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_LeafFlatShape(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected cobra renderer to claim the event path")
|
||||
}
|
||||
|
||||
if payload["description"] != "订阅事件流并输出到 stdout" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "event consume" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "cobra" {
|
||||
t.Fatalf("source = %v, want cobra", payload["source"])
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Inherited global flag must be excluded.
|
||||
if _, present := params["profile"]; present {
|
||||
t.Fatal("inherited --profile must not appear in a command's parameters")
|
||||
}
|
||||
// Hidden internal flag must be excluded.
|
||||
if _, present := params["client-id"]; present {
|
||||
t.Fatal("hidden --client-id must not appear")
|
||||
}
|
||||
|
||||
// Type mapping.
|
||||
if got := paramField(t, params, "user", "type"); got != "string" {
|
||||
t.Errorf("user type = %v, want string", got)
|
||||
}
|
||||
if got := paramField(t, params, "max-events", "type"); got != "integer" {
|
||||
t.Errorf("max-events type = %v, want integer", got)
|
||||
}
|
||||
if got := paramField(t, params, "duration", "type"); got != "string" {
|
||||
t.Errorf("duration type = %v, want string (CLI string like 10m)", got)
|
||||
}
|
||||
if got := paramField(t, params, "ephemeral", "type"); got != "boolean" {
|
||||
t.Errorf("ephemeral type = %v, want boolean", got)
|
||||
}
|
||||
|
||||
// Meaningful default is surfaced; zero defaults are omitted.
|
||||
if got := paramField(t, params, "format", "default"); got != "ndjson" {
|
||||
t.Errorf("format default = %v, want ndjson", got)
|
||||
}
|
||||
if _, hasDefault := params["max-events"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("max-events has a zero default (0) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["ephemeral"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("ephemeral has a zero default (false) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["duration"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("duration has a zero default (0s) — must be omitted")
|
||||
}
|
||||
|
||||
// Required annotation is honored; unmarked flags read required:false.
|
||||
if got := paramField(t, params, "token", "required"); got != true {
|
||||
t.Errorf("token required = %v, want true", got)
|
||||
}
|
||||
if got := paramField(t, params, "user", "required"); got != false {
|
||||
t.Errorf("user required = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_PositionalArguments(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, _, err := renderCobraSchema(root, "event.consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
args, _ := payload["arguments"].([]map[string]any)
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("arguments = %#v, want 1 positional", payload["arguments"])
|
||||
}
|
||||
if args[0]["name"] != "event_key" {
|
||||
t.Errorf("arg name = %v, want event_key", args[0]["name"])
|
||||
}
|
||||
// [event_key] is optional syntax → required:false.
|
||||
if args[0]["required"] != false {
|
||||
t.Errorf("arg required = %v, want false", args[0]["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_DotAndSpacePathEquivalent(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
dotted, _, _ := renderCobraSchema(root, "event.consume")
|
||||
spaced, _, _ := renderCobraSchema(root, "event consume")
|
||||
if dotted["path"] != spaced["path"] || dotted["path"] != "event consume" {
|
||||
t.Fatalf("dot/space forms diverged: %v vs %v", dotted["path"], spaced["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_GroupBrowse(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
// consume + stop; hidden _bus excluded.
|
||||
if len(cmds) != 2 {
|
||||
t.Fatalf("commands = %#v, want 2 (hidden _bus excluded)", cmds)
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c["cli_path"] == "event _bus" {
|
||||
t.Fatal("hidden _bus must not be listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
payload, ok, err := renderCobraSchema(root, "event nope")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected unknown-subcommand error, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_NonRegisteredPathDeclined(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
if _, ok, _ := renderCobraSchema(root, "dev app create"); ok {
|
||||
t.Fatal("non-registered path must not be claimed by the cobra renderer")
|
||||
}
|
||||
if _, ok, _ := renderCobraSchema(root, "ding.message.send"); ok {
|
||||
t.Fatal("non-registered path must not be claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// paramField fetches params[<name>][<field>], failing the test if the param is
|
||||
// absent.
|
||||
func paramField(t *testing.T, params map[string]any, name, field string) any {
|
||||
t.Helper()
|
||||
p, _ := params[name].(map[string]any)
|
||||
if p == nil {
|
||||
t.Fatalf("missing param %q in %#v", name, params)
|
||||
}
|
||||
return p[field]
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// SourceAdapter is the interface daemon.go uses to talk to the cloud Source
|
||||
// (in practice internal/event/source.DingtalkSource). Kept abstract so the
|
||||
// bus daemon can be tested without spinning up the real Stream SDK; the
|
||||
// integration test substitutes a fake.
|
||||
type SourceAdapter interface {
|
||||
// Start opens the cloud connection and blocks until ctx is cancelled
|
||||
// or a fatal error occurs. emit is called for each incoming event.
|
||||
Start(ctx context.Context, emit dwsevent.EmitFn) error
|
||||
}
|
||||
|
||||
// Config bundles everything Run needs to start a daemon. All paths and
|
||||
// identifiers come from busctl/Source so the bus itself stays oblivious
|
||||
// to ConfigDir / edition rules.
|
||||
type Config struct {
|
||||
// WorkDir is the bus working directory:
|
||||
// <ConfigDir>/events/<edition>/<source_kind>/<identity_hash>/
|
||||
// The caller MUST mkdir this with pkg/config.DirPerm before calling Run.
|
||||
WorkDir string
|
||||
|
||||
// IPCEndpoint is the Unix socket path or Windows pipe name. Caller
|
||||
// computes this from WorkDir (Unix) or edition/clientIDHash (Windows).
|
||||
IPCEndpoint string
|
||||
|
||||
// ClientID is the human-readable identifier written into bus.meta and
|
||||
// status output. NOT used in any path.
|
||||
ClientID string
|
||||
|
||||
// SourceKind/IdentityHash/SourceID are diagnostic identity fields used by
|
||||
// list/status. Empty SourceKind is interpreted as app_stream for backward
|
||||
// compatibility.
|
||||
SourceKind dwsevent.SourceKind
|
||||
IdentityHash string
|
||||
SourceID string
|
||||
|
||||
// Edition is written into bus.meta. Comes from edition.Get().Name with
|
||||
// "open" fallback applied by the caller.
|
||||
Edition string
|
||||
|
||||
// SDKVersion is recorded in bus.meta for diagnostics.
|
||||
SDKVersion string
|
||||
|
||||
// Source is the cloud adapter. Required.
|
||||
Source SourceAdapter
|
||||
|
||||
// IdleTimeout: bus self-exits after this long with zero consumers.
|
||||
// Zero disables (bus runs until SIGTERM).
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// ConsumerBuffer overrides per-consumer sendCh capacity. Zero uses
|
||||
// DefaultSendBuffer.
|
||||
ConsumerBuffer int
|
||||
|
||||
// DedupCapacity overrides event_id LRU size. Zero uses dedup.DefaultCapacity.
|
||||
DedupCapacity int
|
||||
|
||||
// DropWarnPercent is the per-event-type drop-rate threshold (whole
|
||||
// percentage points) that triggers a slog WARN in bus.log. Zero or
|
||||
// out-of-range values fall back to DefaultDropWarnPercent. Overridable
|
||||
// via env DWS_EVENT_DROP_WARN_PCT (read by the cobra layer).
|
||||
DropWarnPercent int
|
||||
|
||||
// ReadyPipe receives a single byte ('R' on success, 'E' on failure)
|
||||
// once the bus has either come up or failed startup, so the parent
|
||||
// process forked by busctl/spawn can stop polling and either dial or
|
||||
// surface the error. nil disables (foreground mode).
|
||||
ReadyPipe *os.File
|
||||
|
||||
// Logger sink. Nil → slog.Default.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
|
||||
// 1. Acquire bus.lock (single-instance enforcement)
|
||||
// 2. Write bus.meta
|
||||
// 3. Listen IPC (so consumers can connect before SDK starts pushing)
|
||||
// 4. Signal readiness via ReadyPipe
|
||||
// 5. Start the Source (cloud SDK); concurrent with consumer accept loop
|
||||
// 6. Wait on ctx for shutdown signal
|
||||
// 7. Graceful: broadcast Bye → close listener → close source → release lock
|
||||
//
|
||||
// Run blocks until ctx is cancelled, the Source returns an error, or a fatal
|
||||
// startup error occurs.
|
||||
func Run(ctx context.Context, cfg Config) error {
|
||||
if cfg.Source == nil {
|
||||
return errors.New("bus: Source is required")
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
log := cfg.Logger.With("component", "bus", "client_id", cfg.ClientID, "edition", cfg.Edition)
|
||||
|
||||
// 1. Acquire bus.lock
|
||||
if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
|
||||
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: mkdir workdir: %w", err))
|
||||
}
|
||||
lockPath := filepath.Join(cfg.WorkDir, LockFileName)
|
||||
lock, err := Acquire(lockPath)
|
||||
if err != nil {
|
||||
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: acquire lock: %w", err))
|
||||
}
|
||||
defer lock.Close()
|
||||
|
||||
// 2. Write bus.meta
|
||||
meta := Meta{
|
||||
ClientID: cfg.ClientID,
|
||||
Edition: cfg.Edition,
|
||||
SourceKind: cfg.SourceKind,
|
||||
IdentityHash: cfg.IdentityHash,
|
||||
SourceID: cfg.SourceID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
SDKVersion: cfg.SDKVersion,
|
||||
BusPID: os.Getpid(),
|
||||
}
|
||||
if err := WriteMeta(cfg.WorkDir, meta); err != nil {
|
||||
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: write meta: %w", err))
|
||||
}
|
||||
|
||||
// 3. IPC listen
|
||||
listener, err := transport.Listen(cfg.IPCEndpoint)
|
||||
if err != nil {
|
||||
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: ipc listen: %w", err))
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
hub := NewHub(cfg.ConsumerBuffer)
|
||||
dd := dedup.NewWithCapacity(cfg.DedupCapacity)
|
||||
|
||||
d := &daemon{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
lock: lock,
|
||||
listener: listener,
|
||||
hub: hub,
|
||||
dedup: dd,
|
||||
started: time.Now().UTC(),
|
||||
idleStop: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
|
||||
// scenario where the parent thinks bus is dead but it's actually mid-
|
||||
// startup). Source.Start hasn't yet pulled events from the cloud, but
|
||||
// any consumer that connects gets queued for the first events.
|
||||
signalReady(cfg.ReadyPipe)
|
||||
|
||||
// runCtx is a child of the caller's ctx that ALL background goroutines
|
||||
// (acceptLoop / idleWatch / dropWarnWatcher / source.Start) listen on.
|
||||
// On idle-timeout shutdown the parent ctx is never cancelled, so we
|
||||
// cancel runCtx ourselves before waiting for the goroutines — otherwise
|
||||
// dropWarnWatcher (which only exits on ctx.Done) hangs forever.
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
|
||||
// 5. Start accept loop and Source concurrently. runCtx cancellation
|
||||
// propagates to both.
|
||||
acceptDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(acceptDone)
|
||||
d.acceptLoop(runCtx)
|
||||
}()
|
||||
|
||||
idleDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(idleDone)
|
||||
d.idleWatch(runCtx)
|
||||
}()
|
||||
|
||||
// Drop-rate WARN: per-event-type back-pressure monitoring. Runs in
|
||||
// the background until runCtx cancellation; emits one WARN per scan
|
||||
// when a type's drop rate first crosses the threshold (hysteresis
|
||||
// suppresses repeats unless the rate jumps further).
|
||||
dropWarnDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(dropWarnDone)
|
||||
dropWarnWatcher(runCtx, hub.Counters(), log, cfg.DropWarnPercent)
|
||||
}()
|
||||
|
||||
srcErr := make(chan error, 1)
|
||||
go func() {
|
||||
// emit is called from inside the SDK callback goroutine. It MUST
|
||||
// NOT block (plan invariant #1) — dedup + Hub.Deliver are both
|
||||
// non-blocking by construction.
|
||||
emit := func(raw *dwsevent.RawEvent) {
|
||||
if raw == nil {
|
||||
return
|
||||
}
|
||||
if dd.Seen(raw.DedupKey()) {
|
||||
return // duplicate redelivery from the cloud
|
||||
}
|
||||
hub.Deliver(raw)
|
||||
}
|
||||
srcErr <- cfg.Source.Start(runCtx, emit)
|
||||
}()
|
||||
|
||||
// 6. Wait for shutdown trigger.
|
||||
var exitErr error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info("bus: shutdown requested by ctx", "reason", ctx.Err())
|
||||
case err := <-srcErr:
|
||||
log.Error("bus: source exited", "err", err)
|
||||
exitErr = err
|
||||
case <-d.idleStop:
|
||||
log.Info("bus: idle timeout reached, shutting down")
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
// goroutines wake up, then close listener / drain consumers.
|
||||
cancelRun()
|
||||
d.shutdown()
|
||||
<-acceptDone
|
||||
<-idleDone
|
||||
<-dropWarnDone
|
||||
|
||||
return exitErr
|
||||
}
|
||||
|
||||
// daemon is the in-memory state of one bus run. Lifetime equals one Run() call.
|
||||
type daemon struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
lock *Lock
|
||||
listener transport.Listener
|
||||
hub *Hub
|
||||
dedup *dedup.LRU
|
||||
started time.Time
|
||||
|
||||
consumerWG sync.WaitGroup // tracks live connection handler goroutines
|
||||
conns sync.Map // map[net.Conn]struct{} for forced shutdown close
|
||||
shutdownMu sync.Mutex
|
||||
shuttingDown atomic.Bool
|
||||
idleStop chan struct{}
|
||||
}
|
||||
|
||||
// acceptLoop drives the IPC accept goroutine. Each accepted connection is
|
||||
// passed to handleConnection in its own goroutine; the accept loop returns
|
||||
// when the listener Close()s (typically during shutdown).
|
||||
func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
for {
|
||||
conn, err := d.listener.Accept()
|
||||
if err != nil {
|
||||
if d.shuttingDown.Load() {
|
||||
return
|
||||
}
|
||||
// Transient errors: log + continue. Non-transient (listener
|
||||
// closed) shows up as net.ErrClosed → also a clean exit.
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
d.log.Warn("bus: accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
d.consumerWG.Add(1)
|
||||
go func() {
|
||||
defer d.consumerWG.Done()
|
||||
d.handleConnection(ctx, conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnection processes one IPC connection's full lifecycle: read
|
||||
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
||||
// Always Unregisters and Closes on exit (plan invariant #5).
|
||||
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
d.conns.Store(conn, struct{}{})
|
||||
defer func() {
|
||||
d.conns.Delete(conn)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
r := transport.NewReader(conn)
|
||||
w := transport.NewWriter(conn)
|
||||
|
||||
// Expect Hello first.
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
d.log.Warn("bus: hello read failed", "err", err)
|
||||
return
|
||||
}
|
||||
if hello.Type != transport.FrameTypeHello {
|
||||
d.log.Warn("bus: first frame not hello", "type", hello.Type)
|
||||
return
|
||||
}
|
||||
|
||||
// Ad-hoc tooling (status/list/stop) — short-lived RPC, no Hub register.
|
||||
if hello.Role == transport.HelloRoleStatus {
|
||||
d.handleStatusRPC(w, r)
|
||||
return
|
||||
}
|
||||
if hello.Role == transport.HelloRoleStop {
|
||||
// Signal shutdown by cancelling our parent ctx via shutdown().
|
||||
// For now, return after acking — daemon.shutdown is wired through
|
||||
// daemon's exit path (the busctl/stop command sends SIGTERM in
|
||||
// addition to this RPC for v1).
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "stop_request"})
|
||||
go d.triggerShutdown("stop_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Regular consumer registration
|
||||
c, err := d.hub.Register(hello)
|
||||
if err != nil {
|
||||
d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID)
|
||||
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// HelloAck — credentials_source fields are filled in by the daemon
|
||||
// runner (which knows from the strict resolver) and exposed via the
|
||||
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
|
||||
// passes them through future config if the caller wishes.
|
||||
idleSecs := int(d.cfg.IdleTimeout / time.Second)
|
||||
if err := w.WriteJSON(transport.HelloAck{
|
||||
Type: transport.FrameTypeHelloAck,
|
||||
BusPID: os.Getpid(),
|
||||
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
|
||||
StateSource: "inferred",
|
||||
IdleTimeoutSecs: idleSecs,
|
||||
}); err != nil {
|
||||
d.log.Warn("bus: helloack write failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Writer goroutine pulls from SendCh and writes to the wire.
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
for frame := range c.SendCh {
|
||||
if err := w.WriteJSON(frame); err != nil {
|
||||
// Wire error: peer dead. Returning here will let the
|
||||
// reader goroutine notice EOF and Unregister.
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Reader loop: wait for Bye or EOF. Both trigger Unregister.
|
||||
for {
|
||||
raw, err := r.Read()
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
d.log.Debug("bus: consumer read error", "pid", c.PID, "err", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
typ, err := transport.PeekType(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if typ == transport.FrameTypeBye {
|
||||
break
|
||||
}
|
||||
// Heartbeat / future client→bus frames: ignored for v1.
|
||||
}
|
||||
// Order matters: Unregister first (closes SendCh), then wait for the
|
||||
// writer goroutine to drain. The reverse order would deadlock because
|
||||
// the writer loops on `range SendCh` until close, but only the Hub
|
||||
// can close that channel via Unregister.
|
||||
d.hub.Unregister(c.ID)
|
||||
<-writerDone
|
||||
_ = ctx // for future use (writer ctx-cancel propagation)
|
||||
}
|
||||
|
||||
// handleStatusRPC services a single status_req and returns. The connection
|
||||
// is closed by the caller's defer.
|
||||
func (d *daemon) handleStatusRPC(w *transport.Writer, r *transport.Reader) {
|
||||
var req transport.StatusReq
|
||||
if err := r.ReadJSON(&req); err != nil {
|
||||
return
|
||||
}
|
||||
resp := transport.StatusResp{
|
||||
Type: transport.FrameTypeStatusResp,
|
||||
Bus: transport.StatusBus{
|
||||
PID: os.Getpid(),
|
||||
UptimeSecs: int64(time.Since(d.started).Seconds()),
|
||||
IdleTimeoutSec: int(d.cfg.IdleTimeout / time.Second),
|
||||
ClientID: d.cfg.ClientID,
|
||||
Edition: d.cfg.Edition,
|
||||
SourceKind: d.cfg.SourceKind,
|
||||
IdentityHash: d.cfg.IdentityHash,
|
||||
SourceID: d.cfg.SourceID,
|
||||
},
|
||||
SourceState: transport.StatusSource{
|
||||
State: "connected", // v1: source state plumbed in P3+
|
||||
Source: "inferred",
|
||||
},
|
||||
Consumers: d.hub.Snapshot(),
|
||||
PerEventTypeCounters: d.hub.Counters().Snapshot(),
|
||||
}
|
||||
_ = w.WriteJSON(resp)
|
||||
}
|
||||
|
||||
// idleWatch fires d.idleStop when IdleTimeout passes with zero registered
|
||||
// consumers. Disabled when IdleTimeout <= 0 (returns immediately; idleStop
|
||||
// is then never closed and Run's select branch on it is effectively dead).
|
||||
//
|
||||
// Pre-condition: d.idleStop has already been allocated by Run so the parent
|
||||
// select never races on a nil channel (which would block forever).
|
||||
func (d *daemon) idleWatch(ctx context.Context) {
|
||||
if d.cfg.IdleTimeout <= 0 {
|
||||
return
|
||||
}
|
||||
tick := time.NewTicker(d.cfg.IdleTimeout / 4)
|
||||
defer tick.Stop()
|
||||
emptySince := time.Time{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if d.hub.Len() == 0 {
|
||||
if emptySince.IsZero() {
|
||||
emptySince = time.Now()
|
||||
} else if time.Since(emptySince) >= d.cfg.IdleTimeout {
|
||||
close(d.idleStop)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
emptySince = time.Time{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus.
|
||||
// It works by closing the listener (which unblocks Run's select via the
|
||||
// source error path, indirectly). For v1 a full ctx-cancellation hook is
|
||||
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
|
||||
// shutdown path.
|
||||
func (d *daemon) triggerShutdown(reason string) {
|
||||
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
|
||||
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
|
||||
// Best-effort: a future version wires a context.CancelFunc here.
|
||||
}
|
||||
|
||||
// shutdown performs the graceful tear-down sequence:
|
||||
// 1. mark shuttingDown so acceptLoop exits cleanly
|
||||
// 2. broadcast Bye to all consumers
|
||||
// 3. close listener (interrupts pending Accept)
|
||||
// 4. wait for all per-connection goroutines to drain
|
||||
// 5. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown() {
|
||||
d.shutdownMu.Lock()
|
||||
defer d.shutdownMu.Unlock()
|
||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
||||
_ = d.listener.Close()
|
||||
// Force-close all open IPC connections so any reader goroutine blocked
|
||||
// on Read() returns with a network error and exits cleanly. Without
|
||||
// this the consumerWG never drains and Run hangs forever.
|
||||
d.conns.Range(func(k, _ any) bool {
|
||||
if c, ok := k.(net.Conn); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Give consumers a brief moment to drain final frames before we tear
|
||||
// down their channels.
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
d.consumerWG.Wait()
|
||||
close(doneCh)
|
||||
}()
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
|
||||
}
|
||||
}
|
||||
|
||||
// signalReady writes a single 'R' byte to the ready pipe (if provided) and
|
||||
// closes it. The parent process (busctl/spawn) reads one byte and proceeds.
|
||||
func signalReady(p *os.File) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
_, _ = p.Write([]byte{'R'})
|
||||
_ = p.Close()
|
||||
}
|
||||
|
||||
// failReady writes 'E' to the ready pipe (if provided) and returns err.
|
||||
// Used by the startup-failure paths so the parent can distinguish "still
|
||||
// starting up" from "failed to start".
|
||||
func failReady(p *os.File, err error) error {
|
||||
if p != nil {
|
||||
_, _ = p.Write([]byte{'E'})
|
||||
_ = p.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// fakeSource is a minimal SourceAdapter that emits a configured slice of
|
||||
// events then blocks on ctx until cancel. Used by daemon integration tests
|
||||
// in place of the real Stream SDK.
|
||||
//
|
||||
// If trigger is non-nil, fakeSource waits for it to close before emitting.
|
||||
// Tests use this to register a consumer first (avoids the race where events
|
||||
// flow before Hello completes and end up dropped by Hub for lack of a
|
||||
// matching consumer).
|
||||
type fakeSource struct {
|
||||
events []dwsevent.RawEvent
|
||||
delay time.Duration // optional delay between emits to let the consumer drain
|
||||
trigger <-chan struct{} // optional gate; nil = emit immediately
|
||||
}
|
||||
|
||||
func (f *fakeSource) Start(ctx context.Context, emit dwsevent.EmitFn) error {
|
||||
if f.trigger != nil {
|
||||
select {
|
||||
case <-f.trigger:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
for i := range f.events {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
ev := f.events[i]
|
||||
ev.ReceivedAt = time.Now().UTC()
|
||||
emit(&ev)
|
||||
if f.delay > 0 {
|
||||
time.Sleep(f.delay)
|
||||
}
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func skipOnWindows(t *testing.T, reason string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("skip on windows: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// shortTempDir returns a temp dir under /tmp so the resulting unix socket
|
||||
// path stays under the macOS 104-byte sun_path limit. t.TempDir() lives in
|
||||
// $TMPDIR (/var/folders/.../T/...) which can easily exceed that.
|
||||
func shortTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("/tmp", "dws-bus-")
|
||||
if err != nil {
|
||||
t.Fatalf("mktemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// waitForFile polls until path exists or timeout elapses.
|
||||
func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("file %q did not appear within %s", path, timeout)
|
||||
}
|
||||
|
||||
func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
|
||||
skipOnWindows(t, "Unix socket path semantics differ; covered by transport_windows_test")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
src := &fakeSource{}
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: src,
|
||||
})
|
||||
}()
|
||||
waitForFile(t, sockPath, 2*time.Second)
|
||||
|
||||
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != os.Getpid() {
|
||||
t.Errorf("bus.lock pid = %d, want %d", pid, os.Getpid())
|
||||
}
|
||||
if _, err := ReadMeta(workDir); err != nil {
|
||||
t.Errorf("bus.meta missing: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run returned %v, want nil or canceled", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not return after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket dial")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
trigger := make(chan struct{})
|
||||
src := &fakeSource{
|
||||
events: []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{"text":"hi"}`},
|
||||
{EventID: "2", EventType: "approval.task", Data: `{"task":"x"}`},
|
||||
{EventID: "3", EventType: "im.message.at_v1", Data: `{"at":1}`},
|
||||
},
|
||||
delay: 5 * time.Millisecond,
|
||||
trigger: trigger,
|
||||
}
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: src,
|
||||
})
|
||||
}()
|
||||
waitForFile(t, sockPath, 2*time.Second)
|
||||
|
||||
conn, err := transport.Dial(sockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
EventTypes: []string{"im.*"},
|
||||
}); err != nil {
|
||||
t.Fatalf("write hello: %v", err)
|
||||
}
|
||||
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
t.Fatalf("read ack: %v", err)
|
||||
}
|
||||
if ack.Type != transport.FrameTypeHelloAck {
|
||||
t.Fatalf("ack type = %s", ack.Type)
|
||||
}
|
||||
|
||||
// Consumer is now registered; trigger fakeSource to emit.
|
||||
close(trigger)
|
||||
|
||||
received := 0
|
||||
deadline := time.After(3 * time.Second)
|
||||
for received < 2 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("only received %d events, want 2", received)
|
||||
default:
|
||||
}
|
||||
raw, err := r.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read frame: %v", err)
|
||||
}
|
||||
typ, _ := transport.PeekType(raw)
|
||||
if typ == transport.FrameTypeEvent {
|
||||
received++
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
<-runDone
|
||||
}
|
||||
|
||||
func TestDaemon_LockBusyOnSecondRun(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket / flock semantics")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
_ = Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
}()
|
||||
waitForFile(t, sockPath, 2*time.Second)
|
||||
|
||||
err := Run(context.Background(), Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath + ".other",
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
if !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("second Run = %v, want ErrBusy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_IdleTimeoutSelfStops(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: &fakeSource{},
|
||||
IdleTimeout: 200 * time.Millisecond,
|
||||
})
|
||||
}()
|
||||
waitForFile(t, sockPath, 2*time.Second)
|
||||
|
||||
select {
|
||||
case <-runDone:
|
||||
// success
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("bus did not idle-stop within deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_ReadyPipeSignalsR(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket + pipe")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: &fakeSource{},
|
||||
ReadyPipe: pw,
|
||||
})
|
||||
|
||||
buf := make([]byte, 1)
|
||||
pr.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := pr.Read(buf)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("read ready pipe: %v", err)
|
||||
}
|
||||
if n != 1 || buf[0] != 'R' {
|
||||
t.Fatalf("ready byte = %q n=%d, want 'R'", buf, n)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestDaemon_ConsumerEOFAutoUnregisters(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket")
|
||||
workDir := shortTempDir(t)
|
||||
sockPath := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go Run(ctx, Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sockPath,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
waitForFile(t, sockPath, 2*time.Second)
|
||||
|
||||
conn, err := transport.Dial(sockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
_ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, ConsumerPID: 12345})
|
||||
var ack transport.HelloAck
|
||||
_ = r.ReadJSON(&ack)
|
||||
|
||||
// Slam the connection shut without sending Bye.
|
||||
conn.Close()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
c2, err := transport.Dial(sockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c2.Close()
|
||||
w2 := transport.NewWriter(c2)
|
||||
r2 := transport.NewReader(c2)
|
||||
_ = w2.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleStatus})
|
||||
_ = w2.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq})
|
||||
var resp transport.StatusResp
|
||||
if err := r2.ReadJSON(&resp); err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
for _, c := range resp.Consumers {
|
||||
if c.PID == 12345 {
|
||||
t.Fatalf("dead consumer 12345 still present in status: %+v", resp.Consumers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package bus implements the daemon side of the dws event subsystem: one
|
||||
// long-lived process per ClientID that holds the single cloud connection
|
||||
// and fans out events to N local consumers over IPC.
|
||||
//
|
||||
// Files (mirroring plan §5 layout):
|
||||
//
|
||||
// daemon.go main loop: lock → meta → IPC listen → ready → source.Start
|
||||
// hub.go consumer registry, per-consumer sendCh, drop-oldest backpressure
|
||||
// metrics.go per-event-type + per-consumer received/dropped counters
|
||||
// lockfile.go single bus.lock (flock + PID content + stale recovery)
|
||||
// meta.go bus.meta JSON (clientID/edition/started_at) for list/status reverse mapping
|
||||
//
|
||||
// Lifecycle invariants (plan §4 invariants 1–7):
|
||||
// 1. emit non-blocking (drop-oldest, never block SDK callback)
|
||||
// 2. dedup on event_id (LRU) to absorb cloud-side redelivery
|
||||
// 3. single bus per ClientID (bus.lock enforces, all FS paths use clientIDHash)
|
||||
// 4. upstream always full subscription; consumer filter only affects bus→consume
|
||||
// 5. dead-consumer auto-reap on socket EOF
|
||||
// 6. startup order: lock → meta → IPC listen → ready pipe → Source.Start
|
||||
// 7. stdio detach when fork'd by busctl/spawn
|
||||
package bus
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultDropWarnPercent is the threshold above which per-event-type drop
|
||||
// rate triggers a slog WARN line in bus.log. 5% is the plan default
|
||||
// (§15 已决项); overridable via DWS_EVENT_DROP_WARN_PCT.
|
||||
//
|
||||
// We use whole-percentage granularity (int) because the counter math is
|
||||
// integer; sub-percent precision would just add noise.
|
||||
const DefaultDropWarnPercent = 5
|
||||
|
||||
// dropWarnTickInterval is how often the watcher samples counters. 30s
|
||||
// balances responsiveness ("see the warning while the burst is still
|
||||
// happening") with log noise (one warning per scan, not one per drop).
|
||||
const dropWarnTickInterval = 30 * time.Second
|
||||
|
||||
// dropWarnState memoises the last warned drop rate per event type so we
|
||||
// only emit a fresh WARN when the situation worsens by at least
|
||||
// dropWarnHysteresis percentage points. Without hysteresis a steady-state
|
||||
// burst would re-warn every tick.
|
||||
const dropWarnHysteresis = 5
|
||||
|
||||
// dropWarnWatcher periodically samples per-event-type counters; for any
|
||||
// type whose drop rate crosses the threshold (and hasn't recently been
|
||||
// warned at the same level), it emits a slog WARN. Runs as a daemon
|
||||
// goroutine spawned from bus.Run.
|
||||
//
|
||||
// Lifecycle: returns when ctx is cancelled (bus shutdown). Never holds
|
||||
// any external lock — uses the counters' own concurrency-safe Snapshot.
|
||||
func dropWarnWatcher(ctx context.Context, counters *PerTypeCounters, log *slog.Logger, threshold int) {
|
||||
if threshold <= 0 || threshold > 100 {
|
||||
threshold = DefaultDropWarnPercent
|
||||
}
|
||||
tick := time.NewTicker(dropWarnTickInterval)
|
||||
defer tick.Stop()
|
||||
lastWarnedPct := make(map[string]int)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
for _, et := range counters.SortedTypes() {
|
||||
pct := counters.DropRatePercent(et)
|
||||
if pct < threshold {
|
||||
// drop rate is healthy → forget any prior warning so
|
||||
// a future spike re-triggers a fresh WARN
|
||||
delete(lastWarnedPct, et)
|
||||
continue
|
||||
}
|
||||
prev, warned := lastWarnedPct[et]
|
||||
if warned && pct < prev+dropWarnHysteresis {
|
||||
continue // not significantly worse; suppress
|
||||
}
|
||||
log.Warn("bus: event type backpressure",
|
||||
"event_type", et,
|
||||
"drop_pct", pct,
|
||||
"threshold_pct", threshold,
|
||||
)
|
||||
lastWarnedPct[et] = pct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dropWarnWatcher is exercised end-to-end by daemon integration tests
|
||||
// (it runs as a goroutine inside bus.Run). At the unit level we just
|
||||
// verify the threshold-clamping behaviour in isolation so a misconfigured
|
||||
// env var doesn't disable the safety net silently.
|
||||
func TestDropWarnWatcher_ThresholdClamp(t *testing.T) {
|
||||
// The function applies its own clamp before using the threshold;
|
||||
// we re-derive the expected effective value through the same path
|
||||
// (calling helper-style code below).
|
||||
for _, in := range []int{-1, 0, 101, 1000} {
|
||||
got := clampDropWarnPctForTest(in)
|
||||
if got != DefaultDropWarnPercent {
|
||||
t.Errorf("invalid threshold %d should clamp to %d, got %d",
|
||||
in, DefaultDropWarnPercent, got)
|
||||
}
|
||||
}
|
||||
for _, in := range []int{1, 5, 10, 50, 100} {
|
||||
if got := clampDropWarnPctForTest(in); got != in {
|
||||
t.Errorf("valid threshold %d should pass through, got %d", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clampDropWarnPctForTest mirrors the clamp logic in dropWarnWatcher.
|
||||
// Kept separate so the public Watcher signature does not have to expose
|
||||
// internal validation as a method — the watcher reads threshold from
|
||||
// closure, and tests want to assert on the validation contract.
|
||||
func clampDropWarnPctForTest(threshold int) int {
|
||||
if threshold <= 0 || threshold > 100 {
|
||||
return DefaultDropWarnPercent
|
||||
}
|
||||
return threshold
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// DefaultSendBuffer is the per-consumer channel capacity used when the Hub
|
||||
// is constructed via NewHub. Sized to absorb a short event burst (~100ms at
|
||||
// ~1k evt/s) without backpressure. Overridable via DWS_EVENT_CONSUMER_BUFFER
|
||||
// at daemon start (plan §15 已决但暴露方式).
|
||||
const DefaultSendBuffer = 100
|
||||
|
||||
// Consumer represents one registered IPC connection to the bus. Wire-side
|
||||
// reader/writer goroutines are owned by daemon.go; the Hub holds the
|
||||
// metadata + sendCh.
|
||||
type Consumer struct {
|
||||
ID int // monotonic, assigned by Hub
|
||||
PID int // from Hello.ConsumerPID
|
||||
EventTypes []string // raw wildcard patterns from Hello
|
||||
Filter string // raw regex from Hello (for status display)
|
||||
SubscribeID string // optional personal subscription label and local isolation key
|
||||
SubscribedAt time.Time
|
||||
SendCh chan any // bus → consume frames (Event/SourceState/Heartbeat/Bye)
|
||||
matcher consumerMatcher
|
||||
sendMu sync.Mutex // serialises Deliver/Broadcast with SendCh close
|
||||
closed bool // guarded by sendMu
|
||||
seq atomic.Uint64 // monotonic per-consumer sequence, starts at 1
|
||||
received atomic.Uint64
|
||||
dropped atomic.Uint64
|
||||
}
|
||||
|
||||
// consumerMatcher pre-compiles EventTypes wildcard patterns and the optional
|
||||
// Filter regex into a fast checker invoked once per delivered event per
|
||||
// consumer. Empty EventTypes means catch-all (everything matches except
|
||||
// what Filter excludes). Non-empty SubscribeID is an additional exact-match
|
||||
// constraint used by personal_stream consumers so same event_type subscriptions
|
||||
// do not fan out to each other.
|
||||
type consumerMatcher struct {
|
||||
catchAll bool
|
||||
exact map[string]struct{} // patterns without '*'
|
||||
prefixes []string // patterns ending in ".*" or "*" — store the prefix only
|
||||
filter *regexp.Regexp // nil if no filter
|
||||
subscribeID string // empty = no subscribe_id filtering
|
||||
}
|
||||
|
||||
func compileMatcher(eventTypes []string, filter string, subscribeID string) (consumerMatcher, error) {
|
||||
m := consumerMatcher{
|
||||
exact: make(map[string]struct{}),
|
||||
subscribeID: strings.TrimSpace(subscribeID),
|
||||
}
|
||||
if len(eventTypes) == 0 {
|
||||
m.catchAll = true
|
||||
}
|
||||
for _, raw := range eventTypes {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
// Treat trailing '*' or '.*' as a prefix wildcard (e.g. "im.*",
|
||||
// "im.message.*"). Middle/leading wildcards are rare for event_type
|
||||
// strings — if a real use case appears we can switch to regex
|
||||
// compilation here without breaking the wire format.
|
||||
switch {
|
||||
case raw == "*":
|
||||
m.catchAll = true
|
||||
case strings.HasSuffix(raw, ".*"):
|
||||
m.prefixes = append(m.prefixes, raw[:len(raw)-1]) // keep the dot, drop the *
|
||||
case strings.HasSuffix(raw, "*"):
|
||||
m.prefixes = append(m.prefixes, raw[:len(raw)-1])
|
||||
default:
|
||||
m.exact[raw] = struct{}{}
|
||||
}
|
||||
}
|
||||
if filter != "" {
|
||||
re, err := regexp.Compile(filter)
|
||||
if err != nil {
|
||||
return consumerMatcher{}, err
|
||||
}
|
||||
m.filter = re
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *consumerMatcher) matches(raw *dwsevent.RawEvent) bool {
|
||||
if raw == nil {
|
||||
return false
|
||||
}
|
||||
if m.subscribeID != "" && raw.SubscribeID != m.subscribeID {
|
||||
return false
|
||||
}
|
||||
eventType := raw.EventType
|
||||
// First the include rules: catchAll OR exact-list OR prefix-list.
|
||||
included := m.catchAll
|
||||
if !included {
|
||||
if _, ok := m.exact[eventType]; ok {
|
||||
included = true
|
||||
}
|
||||
}
|
||||
if !included {
|
||||
for _, p := range m.prefixes {
|
||||
if strings.HasPrefix(eventType, p) {
|
||||
included = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !included {
|
||||
return false
|
||||
}
|
||||
// Filter is an additional AND constraint (regex on the event_type
|
||||
// string). Empty filter is no-op.
|
||||
if m.filter != nil && !m.filter.MatchString(eventType) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Hub is the bus's fan-out engine. It owns the set of registered consumers
|
||||
// and the bus-wide per-event-type counters. The Hub is concurrency-safe
|
||||
// across Register/Unregister/Deliver/Snapshot; Deliver is the hot path and
|
||||
// is RLock-only.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
consumers map[int]*Consumer
|
||||
nextID int
|
||||
bufferSize int
|
||||
counters *PerTypeCounters
|
||||
}
|
||||
|
||||
// NewHub returns a Hub with the given per-consumer channel buffer size.
|
||||
// Zero or negative uses DefaultSendBuffer.
|
||||
func NewHub(bufferSize int) *Hub {
|
||||
if bufferSize <= 0 {
|
||||
bufferSize = DefaultSendBuffer
|
||||
}
|
||||
return &Hub{
|
||||
consumers: make(map[int]*Consumer),
|
||||
bufferSize: bufferSize,
|
||||
counters: NewPerTypeCounters(),
|
||||
}
|
||||
}
|
||||
|
||||
// Counters exposes the bus-wide per-event-type counter set for daemon-side
|
||||
// rendering (status RPC, drop-rate warning).
|
||||
func (h *Hub) Counters() *PerTypeCounters { return h.counters }
|
||||
|
||||
// RegisterError wraps the matcher compile error so the daemon can refuse
|
||||
// the Hello and return a clean error to the consume client (instead of
|
||||
// silently accepting a bad filter regex).
|
||||
type RegisterError struct{ Err error }
|
||||
|
||||
func (e *RegisterError) Error() string { return "bus: register consumer: " + e.Err.Error() }
|
||||
func (e *RegisterError) Unwrap() error { return e.Err }
|
||||
|
||||
// Register adds a consumer derived from a Hello frame. Returns a new
|
||||
// Consumer with the populated ID + sendCh ready to use, or a RegisterError
|
||||
// if the Hello's Filter regex is invalid.
|
||||
func (h *Hub) Register(hello transport.Hello) (*Consumer, error) {
|
||||
m, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID)
|
||||
if err != nil {
|
||||
return nil, &RegisterError{Err: err}
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.nextID++
|
||||
c := &Consumer{
|
||||
ID: h.nextID,
|
||||
PID: hello.ConsumerPID,
|
||||
EventTypes: append([]string(nil), hello.EventTypes...),
|
||||
Filter: hello.Filter,
|
||||
SubscribeID: strings.TrimSpace(hello.SubscribeID),
|
||||
SubscribedAt: time.Now().UTC(),
|
||||
SendCh: make(chan any, h.bufferSize),
|
||||
matcher: m,
|
||||
}
|
||||
h.consumers[c.ID] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Unregister removes a consumer by ID and closes its sendCh. Idempotent —
|
||||
// calling twice or on an unknown ID is a no-op. closeSend shares the same
|
||||
// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send
|
||||
// to the channel after it has been closed.
|
||||
func (h *Hub) Unregister(id int) {
|
||||
h.mu.Lock()
|
||||
c, ok := h.consumers[id]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(h.consumers, id)
|
||||
h.mu.Unlock()
|
||||
c.closeSend()
|
||||
}
|
||||
|
||||
// Deliver fans the raw event out to every matching consumer. Updates
|
||||
// bus-wide and per-consumer counters. Always non-blocking — drops the
|
||||
// oldest entry in any full sendCh (plan invariant #1).
|
||||
//
|
||||
// Called from the bus daemon's main loop after dedup; safe for concurrent
|
||||
// callers (Hub uses an RLock, Consumer drop-oldest is single-producer-safe
|
||||
// because the daemon serialises Deliver per event).
|
||||
func (h *Hub) Deliver(raw *dwsevent.RawEvent) {
|
||||
if raw == nil {
|
||||
return
|
||||
}
|
||||
h.counters.AddReceived(raw.EventType)
|
||||
|
||||
h.mu.RLock()
|
||||
matched := make([]*Consumer, 0, len(h.consumers))
|
||||
for _, c := range h.consumers {
|
||||
if c.matcher.matches(raw) {
|
||||
matched = append(matched, c)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, c := range matched {
|
||||
c.deliver(raw, h.counters)
|
||||
}
|
||||
}
|
||||
|
||||
// deliver builds the per-consumer Event frame (assigning seq) and pushes
|
||||
// it onto sendCh with drop-oldest semantics. Updates per-consumer and
|
||||
// bus-wide drop counters.
|
||||
func (c *Consumer) deliver(raw *dwsevent.RawEvent, hubCounters *PerTypeCounters) {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
|
||||
seq := c.seq.Add(1)
|
||||
frame := transport.Event{
|
||||
Type: transport.FrameTypeEvent,
|
||||
Seq: seq,
|
||||
EventID: raw.EventID,
|
||||
EventBornTime: raw.EventBornTime,
|
||||
EventCorpID: raw.EventCorpID,
|
||||
EventType: raw.EventType,
|
||||
EventUnifiedAppID: raw.EventUnifiedAppID,
|
||||
EventScope: raw.EventScope,
|
||||
SubscribeID: raw.SubscribeID,
|
||||
SourceID: raw.SourceID,
|
||||
RuleType: raw.RuleType,
|
||||
Data: raw.Data,
|
||||
Headers: raw.Headers,
|
||||
ReceivedAtUnixMS: raw.ReceivedAt.UnixMilli(),
|
||||
}
|
||||
success, evicted := c.tryPushOrDropOldestLocked(frame)
|
||||
if evicted {
|
||||
// An older event we had previously enqueued is gone.
|
||||
c.dropped.Add(1)
|
||||
c.received.Add(^uint64(0)) // -1
|
||||
hubCounters.AddDropped(raw.EventType)
|
||||
}
|
||||
if success {
|
||||
c.received.Add(1)
|
||||
} else {
|
||||
// New event also didn't make it (rare: lost the race after eviction)
|
||||
c.dropped.Add(1)
|
||||
if !evicted {
|
||||
// No eviction happened but push still failed → unexpected; only
|
||||
// reached via concurrent reader-then-something. Count the bus-wide
|
||||
// drop too so the metric matches per-consumer drops.
|
||||
hubCounters.AddDropped(raw.EventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryPushOrDropOldestLocked tries to push frame to SendCh non-blockingly. If
|
||||
// full, it pops the oldest entry to make room and tries once more. Caller must
|
||||
// hold c.sendMu, which makes drop-oldest a true single-producer operation even
|
||||
// when Deliver and Broadcast run concurrently.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// success: true if the new frame is now in the channel
|
||||
// evicted: true if we removed an older queued frame to make room
|
||||
//
|
||||
// "received" semantics in deliver() interpret these:
|
||||
//
|
||||
// success=true, evicted=false → +1 received (normal push)
|
||||
// success=true, evicted=true → net 0 (lost 1, gained 1); +1 dropped
|
||||
// success=false, evicted=true → -1 received, +2 dropped (both lost; rare)
|
||||
// success=false, evicted=false → only possible if reader drained between
|
||||
// try and we still missed (extremely rare); +1 dropped only.
|
||||
//
|
||||
// The per-consumer send lock makes this block the sole SendCh producer, so the
|
||||
// second push cannot race another producer. The transport writer may receive
|
||||
// between our two operations, which only makes more room — never less.
|
||||
func (c *Consumer) tryPushOrDropOldestLocked(frame any) (success bool, evicted bool) {
|
||||
select {
|
||||
case c.SendCh <- frame:
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
// Drop oldest, then retry.
|
||||
select {
|
||||
case <-c.SendCh:
|
||||
evicted = true
|
||||
default:
|
||||
// Reader took it, room is back.
|
||||
}
|
||||
select {
|
||||
case c.SendCh <- frame:
|
||||
return true, evicted
|
||||
default:
|
||||
return false, evicted
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) enqueue(frame any) bool {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if c.closed {
|
||||
return false
|
||||
}
|
||||
success, _ := c.tryPushOrDropOldestLocked(frame)
|
||||
return success
|
||||
}
|
||||
|
||||
func (c *Consumer) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
close(c.SendCh)
|
||||
}
|
||||
|
||||
// Broadcast sends the same frame (e.g. SourceState change, Bye on shutdown)
|
||||
// to every consumer using drop-oldest semantics. Returns the number of
|
||||
// consumers the frame was successfully enqueued for.
|
||||
func (h *Hub) Broadcast(frame any) int {
|
||||
h.mu.RLock()
|
||||
cs := make([]*Consumer, 0, len(h.consumers))
|
||||
for _, c := range h.consumers {
|
||||
cs = append(cs, c)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
ok := 0
|
||||
for _, c := range cs {
|
||||
if c.enqueue(frame) {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Snapshot returns a deterministic StatusConsumer slice (sorted by PID)
|
||||
// for status RPC encoding. Caller MUST NOT mutate the returned slice.
|
||||
func (h *Hub) Snapshot() []transport.StatusConsumer {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
out := make([]transport.StatusConsumer, 0, len(h.consumers))
|
||||
for _, c := range h.consumers {
|
||||
out = append(out, transport.StatusConsumer{
|
||||
PID: c.PID,
|
||||
EventTypes: append([]string(nil), c.EventTypes...),
|
||||
Filter: c.Filter,
|
||||
SubscribeID: c.SubscribeID,
|
||||
SubscribedAtMS: c.SubscribedAt.UnixMilli(),
|
||||
Received: c.received.Load(),
|
||||
Dropped: c.dropped.Load(),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the current number of registered consumers.
|
||||
func (h *Hub) Len() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.consumers)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func mkEvent(typ, id string) *dwsevent.RawEvent {
|
||||
return &dwsevent.RawEvent{
|
||||
EventID: id,
|
||||
EventType: typ,
|
||||
Data: `{}`,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func drain(c *Consumer, n int, t *testing.T) []*transport.Event {
|
||||
t.Helper()
|
||||
out := make([]*transport.Event, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case f := <-c.SendCh:
|
||||
ev, ok := f.(transport.Event)
|
||||
if !ok {
|
||||
t.Fatalf("frame %d is not Event: %T", i, f)
|
||||
}
|
||||
out = append(out, &ev)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for event %d", i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNoEvent(c *Consumer, t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case f := <-c.SendCh:
|
||||
t.Fatalf("unexpected frame: %#v", f)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_RegisterAssignsMonotonicID(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c1, err := h.Register(transport.Hello{ConsumerPID: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2, err := h.Register(transport.Hello{ConsumerPID: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c1.ID >= c2.ID {
|
||||
t.Fatalf("IDs not monotonic: %d, %d", c1.ID, c2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliverMatchesPrefix(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, err := h.Register(transport.Hello{EventTypes: []string{"im.message.*"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Deliver(mkEvent("im.message.receive_v1", "1"))
|
||||
h.Deliver(mkEvent("approval.task", "2")) // no match
|
||||
h.Deliver(mkEvent("im.message.at_v1", "3"))
|
||||
|
||||
got := drain(c, 2, t)
|
||||
if got[0].EventID != "1" || got[1].EventID != "3" {
|
||||
t.Fatalf("expected 1,3 got %s,%s", got[0].EventID, got[1].EventID)
|
||||
}
|
||||
if got[0].Seq != 1 || got[1].Seq != 2 {
|
||||
t.Fatalf("seq mismatch: %d %d", got[0].Seq, got[1].Seq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliverCatchAll(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, _ := h.Register(transport.Hello{}) // empty == catch-all
|
||||
h.Deliver(mkEvent("im.message.receive_v1", "1"))
|
||||
h.Deliver(mkEvent("approval.task", "2"))
|
||||
h.Deliver(mkEvent("foo.bar", "3"))
|
||||
got := drain(c, 3, t)
|
||||
if got[0].EventID != "1" || got[1].EventID != "2" || got[2].EventID != "3" {
|
||||
t.Fatalf("catch-all missed events: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliverFilterRegex(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, err := h.Register(transport.Hello{Filter: `^im\.`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Deliver(mkEvent("im.message.receive_v1", "1"))
|
||||
h.Deliver(mkEvent("approval.task", "2")) // filtered out
|
||||
h.Deliver(mkEvent("im.chat.member.bot.added_v1", "3"))
|
||||
got := drain(c, 2, t)
|
||||
if got[0].EventID != "1" || got[1].EventID != "3" {
|
||||
t.Fatalf("filter regex missed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliverFiltersBySubscribeID(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "sub-b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wrong := mkEvent("user_im_message_receive_o2o", "1")
|
||||
wrong.SubscribeID = "sub-c"
|
||||
h.Deliver(wrong)
|
||||
assertNoEvent(c, t)
|
||||
|
||||
right := mkEvent("user_im_message_receive_o2o", "2")
|
||||
right.SubscribeID = "sub-b"
|
||||
h.Deliver(right)
|
||||
|
||||
got := drain(c, 1, t)
|
||||
if got[0].EventID != "2" || got[0].SubscribeID != "sub-b" {
|
||||
t.Fatalf("event = %#v, want sub-b event 2", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliverSubscribeIDSeparatesSameEventType(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
b, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "sub-b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "sub-c",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
evB := mkEvent("user_im_message_receive_o2o", "b-msg")
|
||||
evB.SubscribeID = "sub-b"
|
||||
evC := mkEvent("user_im_message_receive_o2o", "c-msg")
|
||||
evC.SubscribeID = "sub-c"
|
||||
h.Deliver(evB)
|
||||
h.Deliver(evC)
|
||||
|
||||
gotB := drain(b, 1, t)
|
||||
gotC := drain(c, 1, t)
|
||||
if gotB[0].EventID != "b-msg" || gotB[0].SubscribeID != "sub-b" {
|
||||
t.Fatalf("B consumer got %#v", gotB[0])
|
||||
}
|
||||
if gotC[0].EventID != "c-msg" || gotC[0].SubscribeID != "sub-c" {
|
||||
t.Fatalf("C consumer got %#v", gotC[0])
|
||||
}
|
||||
assertNoEvent(b, t)
|
||||
assertNoEvent(c, t)
|
||||
}
|
||||
|
||||
func TestHub_DeliverDropsMissingSubscribeIDForSpecificConsumer(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{"user_im_message_receive_group"},
|
||||
SubscribeID: "sub-group",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.Deliver(mkEvent("user_im_message_receive_group", "missing-sub"))
|
||||
|
||||
assertNoEvent(c, t)
|
||||
}
|
||||
|
||||
func TestHub_DeliverEmptyConsumerSubscribeIDReceivesAnySubscribeID(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := mkEvent("user_im_message_receive_o2o", "1")
|
||||
ev.SubscribeID = "sub-any"
|
||||
h.Deliver(ev)
|
||||
|
||||
got := drain(c, 1, t)
|
||||
if got[0].SubscribeID != "sub-any" {
|
||||
t.Fatalf("event subscribe_id = %q, want sub-any", got[0].SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliverEventTypeMismatchEvenWithSubscribeID(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, err := h.Register(transport.Hello{
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "sub-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := mkEvent("user_im_message_receive_group", "1")
|
||||
ev.SubscribeID = "sub-1"
|
||||
|
||||
h.Deliver(ev)
|
||||
|
||||
assertNoEvent(c, t)
|
||||
}
|
||||
|
||||
func TestHub_RegisterRejectsBadFilterRegex(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
_, err := h.Register(transport.Hello{Filter: `(unclosed`})
|
||||
if err == nil {
|
||||
t.Fatal("expected RegisterError for bad regex")
|
||||
}
|
||||
var re *RegisterError
|
||||
if !errors.As(err, &re) {
|
||||
t.Fatalf("err = %v, want *RegisterError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DropOldestOnFullChannel(t *testing.T) {
|
||||
h := NewHub(2) // small buffer
|
||||
c, _ := h.Register(transport.Hello{})
|
||||
|
||||
// Push 5 events without draining → 2 stay, 3 dropped
|
||||
for i := 0; i < 5; i++ {
|
||||
h.Deliver(mkEvent("foo", string(rune('0'+i))))
|
||||
}
|
||||
if c.received.Load() != 2 {
|
||||
t.Fatalf("received = %d, want 2", c.received.Load())
|
||||
}
|
||||
if c.dropped.Load() != 3 {
|
||||
t.Fatalf("dropped = %d, want 3", c.dropped.Load())
|
||||
}
|
||||
// Bus counters reflect it too
|
||||
snap := h.Counters().Snapshot()
|
||||
if snap["foo"].Dropped != 3 {
|
||||
t.Fatalf("hub dropped = %d, want 3", snap["foo"].Dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterClosesChannel(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, _ := h.Register(transport.Hello{})
|
||||
h.Unregister(c.ID)
|
||||
|
||||
// Channel should be closed; receive returns zero value with ok=false
|
||||
_, ok := <-c.SendCh
|
||||
if ok {
|
||||
t.Fatal("SendCh should be closed after Unregister")
|
||||
}
|
||||
|
||||
// Further Deliver must not panic (closed flag prevents send)
|
||||
h.Deliver(mkEvent("foo", "x"))
|
||||
if h.Len() != 0 {
|
||||
t.Fatalf("Len after Unregister = %d, want 0", h.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterIdempotent(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, _ := h.Register(transport.Hello{})
|
||||
h.Unregister(c.ID)
|
||||
h.Unregister(c.ID) // must not panic
|
||||
h.Unregister(9999) // unknown ID
|
||||
}
|
||||
|
||||
func TestHub_ConcurrentDeliverBroadcastUnregister(t *testing.T) {
|
||||
for iteration := 0; iteration < 200; iteration++ {
|
||||
h := NewHub(4)
|
||||
c, err := h.Register(transport.Hello{ConsumerPID: iteration + 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for producer := 0; producer < 4; producer++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for i := 0; i < 25; i++ {
|
||||
h.Deliver(mkEvent("foo", "event"))
|
||||
h.Broadcast(transport.SourceState{Type: transport.FrameTypeSourceState, State: "connected"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
h.Unregister(c.ID)
|
||||
}()
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-c.SendCh:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("SendCh was not closed after concurrent unregister")
|
||||
}
|
||||
}
|
||||
|
||||
seq := c.seq.Load()
|
||||
received := c.received.Load()
|
||||
dropped := c.dropped.Load()
|
||||
h.Deliver(mkEvent("foo", "after-close"))
|
||||
h.Broadcast(transport.Bye{Type: transport.FrameTypeBye})
|
||||
if c.seq.Load() != seq || c.received.Load() != received || c.dropped.Load() != dropped {
|
||||
t.Fatal("closed consumer counters changed after unregister")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BroadcastReachesAllConsumers(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
a, _ := h.Register(transport.Hello{ConsumerPID: 1})
|
||||
b, _ := h.Register(transport.Hello{ConsumerPID: 2})
|
||||
|
||||
bye := transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}
|
||||
if got := h.Broadcast(bye); got != 2 {
|
||||
t.Fatalf("Broadcast delivered to %d, want 2", got)
|
||||
}
|
||||
|
||||
for _, c := range []*Consumer{a, b} {
|
||||
select {
|
||||
case f := <-c.SendCh:
|
||||
if _, ok := f.(transport.Bye); !ok {
|
||||
t.Errorf("PID %d got %T, want Bye", c.PID, f)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("PID %d did not receive broadcast", c.PID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Snapshot_SortedByPID(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
for _, pid := range []int{30, 10, 20} {
|
||||
_, _ = h.Register(transport.Hello{ConsumerPID: pid, EventTypes: []string{"a"}})
|
||||
}
|
||||
snap := h.Snapshot()
|
||||
if len(snap) != 3 || snap[0].PID != 10 || snap[1].PID != 20 || snap[2].PID != 30 {
|
||||
t.Fatalf("snapshot not sorted: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_PerConsumerSeqRestartsAtOne(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
a, _ := h.Register(transport.Hello{ConsumerPID: 1})
|
||||
b, _ := h.Register(transport.Hello{ConsumerPID: 2})
|
||||
|
||||
h.Deliver(mkEvent("foo", "x"))
|
||||
h.Deliver(mkEvent("foo", "y"))
|
||||
|
||||
ea := drain(a, 2, t)
|
||||
eb := drain(b, 2, t)
|
||||
|
||||
for i, ev := range ea {
|
||||
if ev.Seq != uint64(i+1) {
|
||||
t.Errorf("consumer a seq[%d] = %d, want %d", i, ev.Seq, i+1)
|
||||
}
|
||||
}
|
||||
for i, ev := range eb {
|
||||
if ev.Seq != uint64(i+1) {
|
||||
t.Errorf("consumer b seq[%d] = %d, want %d", i, ev.Seq, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_NilEventNoOp(t *testing.T) {
|
||||
h := NewHub(10)
|
||||
c, _ := h.Register(transport.Hello{})
|
||||
h.Deliver(nil)
|
||||
if c.received.Load() != 0 {
|
||||
t.Fatal("nil event should not increment")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
||||
)
|
||||
|
||||
// LockFileName is the on-disk name of the bus single-instance lock. It lives
|
||||
// inside the bus working directory
|
||||
// (<ConfigDir>/events/<edition>/<source_kind>/<identity_hash>/).
|
||||
const LockFileName = "bus.lock"
|
||||
|
||||
// ErrBusy is re-exported from lock for callers that only depend on bus.
|
||||
var ErrBusy = lock.ErrBusy
|
||||
|
||||
// ErrStaleOwnerAlive indicates the PID stored in bus.lock points at a
|
||||
// live process — there is already a bus running for this ClientID and we
|
||||
// must not start another one. (This case is hit when the holder is still
|
||||
// alive but its flock was somehow released; in practice flock + PID always
|
||||
// agree, so this is mostly defensive.)
|
||||
var ErrStaleOwnerAlive = errors.New("bus: lock file PID is alive but flock was released; assuming live owner")
|
||||
|
||||
// Lock represents a held bus.lock. Close releases the flock and removes the
|
||||
// PID file, so a subsequent bus can acquire cleanly. A zero Lock is unusable.
|
||||
type Lock struct {
|
||||
inner *lock.File
|
||||
}
|
||||
|
||||
// Acquire takes the bus lock at path and writes our PID into the file body.
|
||||
//
|
||||
// If the file already has a PID written by a previous run:
|
||||
// 1. Try the flock first — if another process holds it, return ErrBusy
|
||||
// (a live bus is running, abort).
|
||||
// 2. flock acquired but file contains a PID → check if that PID is
|
||||
// alive via process.Alive(). If alive → return ErrStaleOwnerAlive
|
||||
// (defensive; release our flock first). If dead → take over (orphan
|
||||
// cleanup) and overwrite PID with our own.
|
||||
//
|
||||
// On success the returned Lock owns an exclusive flock and a file body
|
||||
// containing our PID. Concurrent competing processes will get ErrBusy.
|
||||
func Acquire(path string) (*Lock, error) {
|
||||
l, err := lock.TryAcquire(path)
|
||||
if err != nil {
|
||||
return nil, err // already wraps ErrBusy when busy
|
||||
}
|
||||
|
||||
// flock acquired. Read existing PID (if any).
|
||||
f := l.File()
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
_ = l.Close()
|
||||
return nil, fmt.Errorf("bus: seek lock: %w", err)
|
||||
}
|
||||
old, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
_ = l.Close()
|
||||
return nil, fmt.Errorf("bus: read lock: %w", err)
|
||||
}
|
||||
|
||||
if pid := parsePID(old); pid > 0 && process.Alive(pid) {
|
||||
// Defensive: flock returned us the lock, but the stored PID is
|
||||
// alive. This shouldn't normally happen (the live process holds
|
||||
// the flock), but it's possible across odd kernel/FS edge cases
|
||||
// (NFS, container restarts). Release and refuse to start.
|
||||
_ = l.Close()
|
||||
return nil, ErrStaleOwnerAlive
|
||||
}
|
||||
|
||||
// Orphan or first-ever acquisition. Rewrite the file with our PID.
|
||||
if err := truncateAndWritePID(f, os.Getpid()); err != nil {
|
||||
_ = l.Close()
|
||||
return nil, fmt.Errorf("bus: write PID: %w", err)
|
||||
}
|
||||
return &Lock{inner: l}, nil
|
||||
}
|
||||
|
||||
// ReadHolderPID returns the PID stored in path, or 0 if the file is missing
|
||||
// or unreadable. Does NOT attempt to acquire the lock — useful for `event
|
||||
// status` to display the holder without contention.
|
||||
func ReadHolderPID(path string) int {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsePID(b)
|
||||
}
|
||||
|
||||
// Close releases the flock and best-effort blanks the PID body so a stale
|
||||
// reader (e.g. `event status` racing our shutdown) does not see our
|
||||
// long-dead PID and try to signal it. The lock file itself is NOT removed
|
||||
// — keeping it on disk avoids a race where a competing bus could acquire
|
||||
// inode-on-create faster than our truncate.
|
||||
func (l *Lock) Close() error {
|
||||
if l == nil || l.inner == nil {
|
||||
return nil
|
||||
}
|
||||
// Blank the body before releasing the lock.
|
||||
f := l.inner.File()
|
||||
_ = truncateAndWritePID(f, 0)
|
||||
err := l.inner.Close()
|
||||
l.inner = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// HoldsPath returns the path the lock is held on.
|
||||
func (l *Lock) HoldsPath() string {
|
||||
if l == nil || l.inner == nil {
|
||||
return ""
|
||||
}
|
||||
return l.inner.Path()
|
||||
}
|
||||
|
||||
func truncateAndWritePID(f *os.File, pid int) error {
|
||||
if err := f.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if pid > 0 {
|
||||
if _, err := fmt.Fprintf(f, "%d\n", pid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
func parsePID(b []byte) int {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcquire_WritesOurPID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
got := ReadHolderPID(path)
|
||||
if got != os.Getpid() {
|
||||
t.Fatalf("ReadHolderPID = %d, want %d", got, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_SecondCallerGetsBusy(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l1, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first Acquire: %v", err)
|
||||
}
|
||||
defer l1.Close()
|
||||
|
||||
_, err = Acquire(path)
|
||||
if !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("second Acquire = %v, want ErrBusy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_StaleOrphanIsReclaimed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
// Pre-populate file with a definitely-dead PID (max int32 is unlikely to be alive).
|
||||
if err := os.WriteFile(path, []byte("2147483646\n"), 0o600); err != nil {
|
||||
t.Fatalf("pre-populate: %v", err)
|
||||
}
|
||||
l, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire on stale orphan: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
if got := ReadHolderPID(path); got != os.Getpid() {
|
||||
t.Fatalf("after orphan reclaim, ReadHolderPID = %d, want %d", got, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_EmptyExistingFileWorks(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("pre-create: %v", err)
|
||||
}
|
||||
l, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire on empty file: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
if got := ReadHolderPID(path); got != os.Getpid() {
|
||||
t.Fatalf("ReadHolderPID = %d, want %d", got, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_BlanksPID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Body should be empty (or at least not our PID anymore).
|
||||
if got := ReadHolderPID(path); got != 0 {
|
||||
t.Fatalf("after Close, ReadHolderPID = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHolderPID_MissingFileReturnsZero(t *testing.T) {
|
||||
if got := ReadHolderPID(filepath.Join(t.TempDir(), "does-not-exist")); got != 0 {
|
||||
t.Fatalf("ReadHolderPID(missing) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "junk.lock")
|
||||
if err := os.WriteFile(path, []byte("not-a-pid"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if got := ReadHolderPID(path); got != 0 {
|
||||
t.Fatalf("ReadHolderPID(malformed) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l1, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if err := l1.Close(); err != nil {
|
||||
t.Fatalf("close first: %v", err)
|
||||
}
|
||||
l2, err := Acquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reclaim after Close: %v", err)
|
||||
}
|
||||
defer l2.Close()
|
||||
}
|
||||
|
||||
// Sanity check that PID round-trips through truncateAndWritePID.
|
||||
func TestTruncateAndWritePID_Roundtrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "x")
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := truncateAndWritePID(f, 9999); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
want := fmt.Sprintf("%d\n", 9999)
|
||||
if string(b) != want {
|
||||
t.Fatalf("body = %q, want %q", b, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// MetaFileName is the on-disk name of the bus metadata file. It lives
|
||||
// alongside bus.lock and bus.sock inside the bus working directory.
|
||||
const MetaFileName = "bus.meta"
|
||||
|
||||
// Meta is the JSON document written once at bus startup. Its primary
|
||||
// purpose is to let `dws event list/status --all` reverse-map directory
|
||||
// names (clientIDHash hex) back to the human-readable ClientID. It also
|
||||
// records bus identity for protocol-compatibility diagnostics (a future
|
||||
// consume client built against bus_version="v2" can refuse to dial a
|
||||
// bus_version="v1" bus, etc.).
|
||||
//
|
||||
// The file is overwritten on each bus startup (so a previous bus's stale
|
||||
// meta does not persist past a fresh boot) and intentionally NOT deleted
|
||||
// on Close — keeping it on disk helps `event status` diagnose an orphan
|
||||
// (bus.lock empty + bus.meta present + PID dead = clean orphan).
|
||||
type Meta struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Edition string `json:"edition"`
|
||||
SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"`
|
||||
IdentityHash string `json:"identity_hash,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
SDKVersion string `json:"sdk_version,omitempty"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
BusPID int `json:"bus_pid"`
|
||||
}
|
||||
|
||||
// CurrentBusVersion identifies the bus wire/storage compatibility level.
|
||||
// Bumped only on breaking changes (IPC protocol, lockfile shape, meta
|
||||
// schema). v1 is the initial value; the field is parsed defensively by
|
||||
// readers (older readers tolerate unknown fields via encoding/json).
|
||||
const CurrentBusVersion = "v1"
|
||||
|
||||
// WriteMeta atomically writes m to <dir>/bus.meta. Atomic via tmp-file +
|
||||
// rename. Directory permissions are not changed; caller must mkdir the
|
||||
// containing directory beforehand with pkg/config.DirPerm.
|
||||
func WriteMeta(dir string, m Meta) error {
|
||||
if m.BusVersion == "" {
|
||||
m.BusVersion = CurrentBusVersion
|
||||
}
|
||||
if m.BusPID == 0 {
|
||||
m.BusPID = os.Getpid()
|
||||
}
|
||||
if m.StartedAt.IsZero() {
|
||||
m.StartedAt = time.Now().UTC()
|
||||
}
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("bus: marshal meta: %w", err)
|
||||
}
|
||||
final := filepath.Join(dir, MetaFileName)
|
||||
tmp := final + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, config.FilePerm); err != nil {
|
||||
return fmt.Errorf("bus: write tmp meta: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("bus: rename meta: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadMeta loads and parses <dir>/bus.meta. Returns (nil, error) when the
|
||||
// file is missing or malformed. Used by `event list/status --all` to
|
||||
// resolve clientIDHash → original ClientID.
|
||||
func ReadMeta(dir string) (*Meta, error) {
|
||||
path := filepath.Join(dir, MetaFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Meta
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, fmt.Errorf("bus: parse meta %s: %w", path, err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteRead_Roundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := Meta{
|
||||
ClientID: "ding_xyz",
|
||||
Edition: "open",
|
||||
StartedAt: time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC),
|
||||
SDKVersion: "v0.9.1",
|
||||
BusPID: 12345,
|
||||
}
|
||||
if err := WriteMeta(dir, m); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadMeta(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMeta: %v", err)
|
||||
}
|
||||
if got.ClientID != m.ClientID || got.Edition != m.Edition || got.BusPID != m.BusPID {
|
||||
t.Errorf("roundtrip mismatch: got %+v want %+v", got, m)
|
||||
}
|
||||
if got.BusVersion != CurrentBusVersion {
|
||||
t.Errorf("BusVersion default not applied: %q", got.BusVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMeta_DefaultsPID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := WriteMeta(dir, Meta{ClientID: "x", Edition: "open"}); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
}
|
||||
got, err := ReadMeta(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMeta: %v", err)
|
||||
}
|
||||
if got.BusPID != os.Getpid() {
|
||||
t.Errorf("BusPID default = %d, want %d", got.BusPID, os.Getpid())
|
||||
}
|
||||
if got.StartedAt.IsZero() {
|
||||
t.Error("StartedAt default not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMeta_AtomicNoTmpLeft(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := WriteMeta(dir, Meta{ClientID: "x", Edition: "open"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// .tmp file must not remain after successful rename
|
||||
if _, err := os.Stat(filepath.Join(dir, MetaFileName+".tmp")); err == nil {
|
||||
t.Fatal(".tmp file leaked after WriteMeta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMeta_MissingFileErrors(t *testing.T) {
|
||||
if _, err := ReadMeta(t.TempDir()); err == nil {
|
||||
t.Fatal("ReadMeta on missing file should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMeta_MalformedErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, MetaFileName), []byte("{garbage"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadMeta(dir); err == nil {
|
||||
t.Fatal("ReadMeta on malformed JSON should error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// PerTypeCounters is bus-wide and tracks how many events of each event_type
|
||||
// the bus delivered (or dropped due to backpressure) since startup. Surfaced
|
||||
// via `dws event status`. Concurrent-safe.
|
||||
//
|
||||
// Implementation note: a map of *atomic uint64 pairs lets us avoid taking
|
||||
// the mu in the hot Add() path; mu is only held when inserting a new
|
||||
// event_type key.
|
||||
type PerTypeCounters struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]*typeRow
|
||||
}
|
||||
|
||||
type typeRow struct {
|
||||
received atomic.Uint64
|
||||
dropped atomic.Uint64
|
||||
}
|
||||
|
||||
// NewPerTypeCounters returns an empty counter set.
|
||||
func NewPerTypeCounters() *PerTypeCounters {
|
||||
return &PerTypeCounters{m: make(map[string]*typeRow)}
|
||||
}
|
||||
|
||||
// AddReceived increments the received counter for eventType (allocating the
|
||||
// row on first sight of a new type).
|
||||
func (c *PerTypeCounters) AddReceived(eventType string) {
|
||||
c.row(eventType).received.Add(1)
|
||||
}
|
||||
|
||||
// AddDropped increments the dropped counter for eventType.
|
||||
func (c *PerTypeCounters) AddDropped(eventType string) {
|
||||
c.row(eventType).dropped.Add(1)
|
||||
}
|
||||
|
||||
// Snapshot returns a deterministic point-in-time view, sorted by
|
||||
// event_type. Used by status RPC encoding.
|
||||
func (c *PerTypeCounters) Snapshot() map[string]transport.Counters {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make(map[string]transport.Counters, len(c.m))
|
||||
for k, row := range c.m {
|
||||
out[k] = transport.Counters{
|
||||
Received: row.received.Load(),
|
||||
Dropped: row.dropped.Load(),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SortedTypes returns the known event_types in deterministic order. Useful
|
||||
// for human-readable formatting (status table).
|
||||
func (c *PerTypeCounters) SortedTypes() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]string, 0, len(c.m))
|
||||
for k := range c.m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DropRatePercent returns rounded (dropped / (received + dropped)) * 100
|
||||
// for the given event_type, or -1 if the type was never observed. Used by
|
||||
// the drop-rate stderr warning when crossing a configurable threshold.
|
||||
func (c *PerTypeCounters) DropRatePercent(eventType string) int {
|
||||
c.mu.RLock()
|
||||
row, ok := c.m[eventType]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
r := row.received.Load()
|
||||
d := row.dropped.Load()
|
||||
total := r + d
|
||||
if total == 0 {
|
||||
return -1
|
||||
}
|
||||
return int((d * 100) / total)
|
||||
}
|
||||
|
||||
func (c *PerTypeCounters) row(eventType string) *typeRow {
|
||||
c.mu.RLock()
|
||||
row, ok := c.m[eventType]
|
||||
c.mu.RUnlock()
|
||||
if ok {
|
||||
return row
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// double-check after acquiring write lock
|
||||
if row, ok = c.m[eventType]; ok {
|
||||
return row
|
||||
}
|
||||
row = &typeRow{}
|
||||
c.m[eventType] = row
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPerTypeCounters_BasicAdd(t *testing.T) {
|
||||
c := NewPerTypeCounters()
|
||||
c.AddReceived("im.message.receive_v1")
|
||||
c.AddReceived("im.message.receive_v1")
|
||||
c.AddDropped("im.message.receive_v1")
|
||||
c.AddReceived("approval.task")
|
||||
|
||||
snap := c.Snapshot()
|
||||
if snap["im.message.receive_v1"].Received != 2 {
|
||||
t.Errorf("im received = %d, want 2", snap["im.message.receive_v1"].Received)
|
||||
}
|
||||
if snap["im.message.receive_v1"].Dropped != 1 {
|
||||
t.Errorf("im dropped = %d, want 1", snap["im.message.receive_v1"].Dropped)
|
||||
}
|
||||
if snap["approval.task"].Received != 1 {
|
||||
t.Errorf("approval received = %d, want 1", snap["approval.task"].Received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerTypeCounters_SortedTypes(t *testing.T) {
|
||||
c := NewPerTypeCounters()
|
||||
for _, k := range []string{"z", "a", "m", "b"} {
|
||||
c.AddReceived(k)
|
||||
}
|
||||
got := c.SortedTypes()
|
||||
want := []string{"a", "b", "m", "z"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("[%d] %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerTypeCounters_DropRatePercent(t *testing.T) {
|
||||
c := NewPerTypeCounters()
|
||||
for i := 0; i < 95; i++ {
|
||||
c.AddReceived("a")
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
c.AddDropped("a")
|
||||
}
|
||||
if got := c.DropRatePercent("a"); got != 5 {
|
||||
t.Errorf("DropRatePercent = %d, want 5", got)
|
||||
}
|
||||
if got := c.DropRatePercent("never-seen"); got != -1 {
|
||||
t.Errorf("unseen DropRatePercent = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerTypeCounters_ConcurrentAddSameType(t *testing.T) {
|
||||
c := NewPerTypeCounters()
|
||||
const N = 1000
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N * 2)
|
||||
for i := 0; i < N; i++ {
|
||||
go func() { defer wg.Done(); c.AddReceived("hot") }()
|
||||
go func() { defer wg.Done(); c.AddDropped("hot") }()
|
||||
}
|
||||
wg.Wait()
|
||||
snap := c.Snapshot()
|
||||
if snap["hot"].Received != N || snap["hot"].Dropped != N {
|
||||
t.Fatalf("got %+v, want received=%d dropped=%d", snap["hot"], N, N)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerTypeCounters_ConcurrentNewTypes(t *testing.T) {
|
||||
c := NewPerTypeCounters()
|
||||
const N = 500
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
i := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.AddReceived(string(rune('a'+(i%26))) + "_" + string(rune('a'+((i/26)%26))))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// Each goroutine adds exactly 1; total received across all types must be N.
|
||||
var total uint64
|
||||
for _, v := range c.Snapshot() {
|
||||
total += v.Received
|
||||
}
|
||||
if total != N {
|
||||
t.Fatalf("total received = %d, want %d", total, N)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tunable env vars (plan §15 已决项 — surfaced for operators without
|
||||
// requiring a new CLI flag for each knob). Each lookup is read-once at
|
||||
// bus startup; runtime changes require a bus restart.
|
||||
const (
|
||||
EnvIdleTimeout = "DWS_EVENT_BUS_IDLE_TIMEOUT" // Go duration, e.g. "10m"
|
||||
EnvConsumerBuffer = "DWS_EVENT_CONSUMER_BUFFER" // integer
|
||||
EnvDedupLRU = "DWS_EVENT_DEDUP_LRU" // integer
|
||||
EnvDropWarnPct = "DWS_EVENT_DROP_WARN_PCT" // integer 1-100
|
||||
)
|
||||
|
||||
// ApplyEnvTuning fills in Config defaults from the env vars listed above
|
||||
// for any fields the caller left at zero. The cobra layer calls this after
|
||||
// constructing Config so explicit flag values still win.
|
||||
//
|
||||
// Defaults (when env is absent or invalid):
|
||||
//
|
||||
// IdleTimeout → 5m
|
||||
// ConsumerBuffer → DefaultSendBuffer
|
||||
// DedupCapacity → 0 (let dedup package's DefaultCapacity apply)
|
||||
// DropWarnPercent → DefaultDropWarnPercent
|
||||
//
|
||||
// Invalid env values (non-parseable, out of range) are silently ignored
|
||||
// and the default is used. We deliberately do NOT fail the bus on bad
|
||||
// env input — operators shouldn't lose a daemon over a typo'd env var.
|
||||
func ApplyEnvTuning(cfg *Config) {
|
||||
if cfg.IdleTimeout == 0 {
|
||||
if v := os.Getenv(EnvIdleTimeout); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.IdleTimeout = d
|
||||
}
|
||||
}
|
||||
if cfg.IdleTimeout == 0 {
|
||||
cfg.IdleTimeout = 5 * time.Minute
|
||||
}
|
||||
}
|
||||
if cfg.ConsumerBuffer == 0 {
|
||||
if v := os.Getenv(EnvConsumerBuffer); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
cfg.ConsumerBuffer = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.DedupCapacity == 0 {
|
||||
if v := os.Getenv(EnvDedupLRU); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
cfg.DedupCapacity = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.DropWarnPercent == 0 {
|
||||
if v := os.Getenv(EnvDropWarnPct); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
|
||||
cfg.DropWarnPercent = n
|
||||
}
|
||||
}
|
||||
if cfg.DropWarnPercent == 0 {
|
||||
cfg.DropWarnPercent = DefaultDropWarnPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyEnvTuning_DefaultsWhenAllUnset(t *testing.T) {
|
||||
t.Setenv(EnvIdleTimeout, "")
|
||||
t.Setenv(EnvConsumerBuffer, "")
|
||||
t.Setenv(EnvDedupLRU, "")
|
||||
t.Setenv(EnvDropWarnPct, "")
|
||||
|
||||
cfg := Config{}
|
||||
ApplyEnvTuning(&cfg)
|
||||
|
||||
if cfg.IdleTimeout != 5*time.Minute {
|
||||
t.Errorf("IdleTimeout default = %s, want 5m", cfg.IdleTimeout)
|
||||
}
|
||||
if cfg.DropWarnPercent != DefaultDropWarnPercent {
|
||||
t.Errorf("DropWarnPercent default = %d, want %d",
|
||||
cfg.DropWarnPercent, DefaultDropWarnPercent)
|
||||
}
|
||||
// ConsumerBuffer / DedupCapacity intentionally left zero — the Hub
|
||||
// / dedup packages apply their own defaults from that signal.
|
||||
if cfg.ConsumerBuffer != 0 {
|
||||
t.Errorf("ConsumerBuffer should remain 0 (Hub picks default), got %d", cfg.ConsumerBuffer)
|
||||
}
|
||||
if cfg.DedupCapacity != 0 {
|
||||
t.Errorf("DedupCapacity should remain 0 (dedup picks default), got %d", cfg.DedupCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvTuning_ReadsValidEnv(t *testing.T) {
|
||||
t.Setenv(EnvIdleTimeout, "10m")
|
||||
t.Setenv(EnvConsumerBuffer, "200")
|
||||
t.Setenv(EnvDedupLRU, "16384")
|
||||
t.Setenv(EnvDropWarnPct, "10")
|
||||
|
||||
cfg := Config{}
|
||||
ApplyEnvTuning(&cfg)
|
||||
|
||||
if cfg.IdleTimeout != 10*time.Minute {
|
||||
t.Errorf("IdleTimeout = %s, want 10m", cfg.IdleTimeout)
|
||||
}
|
||||
if cfg.ConsumerBuffer != 200 {
|
||||
t.Errorf("ConsumerBuffer = %d, want 200", cfg.ConsumerBuffer)
|
||||
}
|
||||
if cfg.DedupCapacity != 16384 {
|
||||
t.Errorf("DedupCapacity = %d, want 16384", cfg.DedupCapacity)
|
||||
}
|
||||
if cfg.DropWarnPercent != 10 {
|
||||
t.Errorf("DropWarnPercent = %d, want 10", cfg.DropWarnPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvTuning_ExplicitConfigWins(t *testing.T) {
|
||||
t.Setenv(EnvIdleTimeout, "10m")
|
||||
t.Setenv(EnvDropWarnPct, "20")
|
||||
|
||||
cfg := Config{
|
||||
IdleTimeout: 3 * time.Minute,
|
||||
DropWarnPercent: 7,
|
||||
}
|
||||
ApplyEnvTuning(&cfg)
|
||||
|
||||
if cfg.IdleTimeout != 3*time.Minute {
|
||||
t.Errorf("explicit IdleTimeout overridden by env: %s", cfg.IdleTimeout)
|
||||
}
|
||||
if cfg.DropWarnPercent != 7 {
|
||||
t.Errorf("explicit DropWarnPercent overridden by env: %d", cfg.DropWarnPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvTuning_IgnoresInvalidValues(t *testing.T) {
|
||||
t.Setenv(EnvIdleTimeout, "not-a-duration")
|
||||
t.Setenv(EnvConsumerBuffer, "-50")
|
||||
t.Setenv(EnvDedupLRU, "notanumber")
|
||||
t.Setenv(EnvDropWarnPct, "150") // out of 1..100
|
||||
|
||||
cfg := Config{}
|
||||
ApplyEnvTuning(&cfg)
|
||||
|
||||
if cfg.IdleTimeout != 5*time.Minute {
|
||||
t.Errorf("invalid duration → default; got %s", cfg.IdleTimeout)
|
||||
}
|
||||
if cfg.ConsumerBuffer != 0 {
|
||||
t.Errorf("negative buffer ignored → 0; got %d", cfg.ConsumerBuffer)
|
||||
}
|
||||
if cfg.DedupCapacity != 0 {
|
||||
t.Errorf("non-numeric LRU ignored → 0; got %d", cfg.DedupCapacity)
|
||||
}
|
||||
if cfg.DropWarnPercent != DefaultDropWarnPercent {
|
||||
t.Errorf("out-of-range pct → default; got %d", cfg.DropWarnPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvTuning_IdleTimeoutZeroEnv(t *testing.T) {
|
||||
t.Setenv(EnvIdleTimeout, "0")
|
||||
cfg := Config{}
|
||||
ApplyEnvTuning(&cfg)
|
||||
// "0" is parseable but <= 0 → use default 5m, not 0 (which would mean
|
||||
// "disabled" in the daemon — too dangerous as an env-driven default).
|
||||
if cfg.IdleTimeout != 5*time.Minute {
|
||||
t.Errorf("zero env should fall through to 5m default, got %s", cfg.IdleTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// SpawnFunc abstracts the fork-bus operation so tests can inject a fake
|
||||
// instead of execing a real binary. Production callers pass busctl.Spawn.
|
||||
type SpawnFunc func(SpawnConfig) (pid int, err error)
|
||||
|
||||
// DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and
|
||||
// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path
|
||||
// fallback when WorkDir is too deep; the caller must mkdir it with
|
||||
// pkg/config.DirPerm beforehand.
|
||||
type DiscoverConfig struct {
|
||||
WorkDir string
|
||||
IPCEndpoint string
|
||||
ClientID string
|
||||
// Spawn is the fork-bus callback. Default busctl.Spawn.
|
||||
Spawn SpawnFunc
|
||||
// SpawnExtraArgs is forwarded to Spawn (for tests).
|
||||
SpawnExtraArgs []string
|
||||
// DialBackoff: initial sleep between retry dials when another process
|
||||
// is spawning. Doubled each attempt up to DialMaxBackoff.
|
||||
DialBackoff time.Duration
|
||||
// DialMaxBackoff caps backoff.
|
||||
DialMaxBackoff time.Duration
|
||||
// DialDeadline caps total wall-clock time spent discovering.
|
||||
DialDeadline time.Duration
|
||||
}
|
||||
|
||||
// Default knobs. Conservative — dial is local and cheap, so retry is cheap.
|
||||
const (
|
||||
defaultDialBackoff = 25 * time.Millisecond
|
||||
defaultDialMaxBackoff = 250 * time.Millisecond
|
||||
defaultDialDeadline = 5 * time.Second
|
||||
)
|
||||
|
||||
// Discover returns a connected net.Conn to the bus for cfg.ClientID. If the
|
||||
// bus is not running, Discover forks a new one and waits for it to come up.
|
||||
//
|
||||
// Race-free three-step (plan §12 P3):
|
||||
//
|
||||
// 1. try dial IPC endpoint → success → done
|
||||
// 2. failed → call Spawn (fork _bus); Spawn blocks until ready pipe says
|
||||
// 'R' (or returns ErrSpawnFailed if another process won the race and
|
||||
// our bus startup hit ErrBusy on the lock)
|
||||
// 3. dial again — should succeed; retry with backoff up to DialDeadline
|
||||
// in case Spawn succeeded but socket bind has tiny latency
|
||||
//
|
||||
// On concurrent Discover by N processes: only one Spawn succeeds (the
|
||||
// others get ErrBusy via the bus daemon's own lock acquisition). Losers
|
||||
// fall through to the retry-dial loop in step 3 and connect to the bus
|
||||
// the winner brought up.
|
||||
func Discover(cfg DiscoverConfig) (net.Conn, error) {
|
||||
if cfg.WorkDir == "" {
|
||||
return nil, errors.New("busctl: WorkDir is required")
|
||||
}
|
||||
if cfg.IPCEndpoint == "" {
|
||||
return nil, errors.New("busctl: IPCEndpoint is required")
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return nil, errors.New("busctl: ClientID is required")
|
||||
}
|
||||
if cfg.Spawn == nil {
|
||||
cfg.Spawn = Spawn
|
||||
}
|
||||
if cfg.DialBackoff == 0 {
|
||||
cfg.DialBackoff = defaultDialBackoff
|
||||
}
|
||||
if cfg.DialMaxBackoff == 0 {
|
||||
cfg.DialMaxBackoff = defaultDialMaxBackoff
|
||||
}
|
||||
if cfg.DialDeadline == 0 {
|
||||
cfg.DialDeadline = defaultDialDeadline
|
||||
}
|
||||
|
||||
// Step 1: try dial.
|
||||
if conn, err := transport.Dial(cfg.IPCEndpoint); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Step 2: ensure WorkDir, then spawn _bus.
|
||||
if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
|
||||
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
|
||||
}
|
||||
_, spawnErr := cfg.Spawn(SpawnConfig{
|
||||
ClientID: cfg.ClientID,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
})
|
||||
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
|
||||
// Hard error (couldn't even exec the child). Stop here — no bus
|
||||
// will come up.
|
||||
return nil, fmt.Errorf("busctl: spawn bus: %w", spawnErr)
|
||||
}
|
||||
// spawnErr == ErrSpawnFailed → child reported it can't start, most
|
||||
// commonly because another process already holds the lock. Either way
|
||||
// we fall through to retry-dial — if someone else's bus IS up we'll
|
||||
// connect to it.
|
||||
|
||||
// Step 3: retry dial until DialDeadline.
|
||||
deadline := time.Now().Add(cfg.DialDeadline)
|
||||
backoff := cfg.DialBackoff
|
||||
var lastDialErr error
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := transport.Dial(cfg.IPCEndpoint)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastDialErr = err
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
if backoff > cfg.DialMaxBackoff {
|
||||
backoff = cfg.DialMaxBackoff
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("busctl: discover deadline exceeded; last dial error: %w (spawn error: %v)", lastDialErr, spawnErr)
|
||||
}
|
||||
|
||||
// LockPath returns the canonical bus.lock path for the given working dir.
|
||||
// Centralised so consume / status / stop all agree on the location.
|
||||
func LockPath(workDir string) string {
|
||||
return filepath.Join(workDir, "bus.lock")
|
||||
}
|
||||
|
||||
// MetaPath returns the canonical bus.meta path.
|
||||
func MetaPath(workDir string) string {
|
||||
return filepath.Join(workDir, "bus.meta")
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func skipOnWindows(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix socket; Windows transport covered by transport_windows_test.go")
|
||||
}
|
||||
}
|
||||
|
||||
func shortTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("/tmp", "dws-busctl-")
|
||||
if err != nil {
|
||||
t.Fatalf("mktemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// startStubBus brings up a transport.Listener at sockPath and accepts
|
||||
// connections in a goroutine, discarding the data. Returns a closer.
|
||||
// Used as a stand-in for the real bus daemon in discover unit tests.
|
||||
func startStubBus(t *testing.T, sockPath string) func() {
|
||||
t.Helper()
|
||||
l, err := transport.Listen(sockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("startStubBus listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
if _, err := c.Read(buf); err != nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
_ = l.Close()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_BusAlreadyRunning_DirectDial(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
closer := startStubBus(t, sock)
|
||||
defer closer()
|
||||
|
||||
var spawnCalled atomic.Bool
|
||||
fakeSpawn := func(SpawnConfig) (int, error) {
|
||||
spawnCalled.Store(true)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
conn, err := Discover(DiscoverConfig{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_x",
|
||||
Spawn: fakeSpawn,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if spawnCalled.Load() {
|
||||
t.Fatal("Spawn must not be called when bus is already running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
|
||||
// fakeSpawn launches the stub bus *during* the spawn call to mirror
|
||||
// the real flow (bus is up by the time spawn returns).
|
||||
var closer func()
|
||||
t.Cleanup(func() {
|
||||
if closer != nil {
|
||||
closer()
|
||||
}
|
||||
})
|
||||
fakeSpawn := func(SpawnConfig) (int, error) {
|
||||
closer = startStubBus(t, sock)
|
||||
return 12345, nil
|
||||
}
|
||||
|
||||
conn, err := Discover(DiscoverConfig{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_x",
|
||||
Spawn: fakeSpawn,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
}
|
||||
|
||||
func TestDiscover_SpawnHardErrorFails(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
|
||||
hardErr := errors.New("exec failed: not found")
|
||||
fakeSpawn := func(SpawnConfig) (int, error) { return 0, hardErr }
|
||||
|
||||
_, err := Discover(DiscoverConfig{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_x",
|
||||
Spawn: fakeSpawn,
|
||||
DialDeadline: 100 * time.Millisecond,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Discover should fail when Spawn returns non-ErrSpawnFailed error")
|
||||
}
|
||||
if !errors.Is(err, hardErr) {
|
||||
t.Fatalf("err = %v, want wrap of %v", err, hardErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_SpawnReportsFailButPeerBusComesUp(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
|
||||
// Simulates the race: our Spawn loses (returns ErrSpawnFailed) but
|
||||
// during retry-dial a peer bus shows up.
|
||||
go func() {
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
closer := startStubBus(t, sock)
|
||||
t.Cleanup(closer)
|
||||
}()
|
||||
|
||||
fakeSpawn := func(SpawnConfig) (int, error) { return 0, ErrSpawnFailed }
|
||||
|
||||
conn, err := Discover(DiscoverConfig{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_x",
|
||||
Spawn: fakeSpawn,
|
||||
DialDeadline: 2 * time.Second,
|
||||
DialBackoff: 20 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Discover should retry-dial after ErrSpawnFailed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
}
|
||||
|
||||
func TestDiscover_DeadlineExceeded(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock") // never created
|
||||
|
||||
fakeSpawn := func(SpawnConfig) (int, error) { return 0, ErrSpawnFailed }
|
||||
start := time.Now()
|
||||
_, err := Discover(DiscoverConfig{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_x",
|
||||
Spawn: fakeSpawn,
|
||||
DialDeadline: 150 * time.Millisecond,
|
||||
DialBackoff: 20 * time.Millisecond,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Discover should fail when bus never comes up within deadline")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 100*time.Millisecond || elapsed > 1*time.Second {
|
||||
t.Errorf("deadline-driven exit took %s, expected ~150ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_ConcurrentCallersOnlyOneSpawn(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
|
||||
// Use a mutex-guarded one-shot Spawn that actually starts the stub bus
|
||||
// on first call. Concurrent callers may race: the first wins (returns
|
||||
// success), the rest see ErrSpawnFailed but still retry-dial successfully.
|
||||
var spawnMu sync.Mutex
|
||||
var spawnCount atomic.Int32
|
||||
var closer func()
|
||||
t.Cleanup(func() {
|
||||
if closer != nil {
|
||||
closer()
|
||||
}
|
||||
})
|
||||
fakeSpawn := func(SpawnConfig) (int, error) {
|
||||
spawnMu.Lock()
|
||||
defer spawnMu.Unlock()
|
||||
spawnCount.Add(1)
|
||||
if closer == nil {
|
||||
closer = startStubBus(t, sock)
|
||||
return 99, nil
|
||||
}
|
||||
return 0, ErrSpawnFailed
|
||||
}
|
||||
|
||||
const N = 5
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, N)
|
||||
conns := make([]net.Conn, 0, N)
|
||||
connsMu := sync.Mutex{}
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := Discover(DiscoverConfig{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_x",
|
||||
Spawn: fakeSpawn,
|
||||
DialDeadline: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
connsMu.Lock()
|
||||
conns = append(conns, conn)
|
||||
connsMu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Errorf("Discover concurrent caller: %v", err)
|
||||
}
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
// Note: spawnCount can be 1..N because all goroutines fail dial first
|
||||
// and call Spawn. The point is they all SUCCESSFULLY connected to the
|
||||
// single bus that the first Spawn brought up.
|
||||
if len(conns) != N {
|
||||
t.Fatalf("only %d/%d callers got a conn", len(conns), N)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package busctl glues the consume client to the bus daemon. It encapsulates
|
||||
// the three operations a consumer needs at startup and shutdown:
|
||||
//
|
||||
// discover: find the running bus or fork a fresh one (race-free, plan §12
|
||||
// P3 "try dial → try fork lock → retry dial")
|
||||
// spawn: exec `dws event _bus --client-id <id>` as a detached
|
||||
// background process (stdio detach, setsid, ready pipe handshake)
|
||||
// stop: gracefully terminate the bus daemon (SIGTERM + IPC fallback)
|
||||
//
|
||||
// All three operations are short-lived helpers — they own no long-lived
|
||||
// goroutines and return whole errors to their caller.
|
||||
package busctl
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReadyFDEnv is the env var the spawned `event _bus` child inspects to find
|
||||
// the ready-pipe write end. The parent passes the FD number; child opens it
|
||||
// via os.NewFile(fd, "ready") and writes 'R' on success or 'E' on failure.
|
||||
// 3 is the first FD slot beyond stdio in cmd.ExtraFiles.
|
||||
const ReadyFDEnv = "DWS_EVENT_BUS_READY_FD"
|
||||
|
||||
// ReadyTimeout caps how long Spawn waits for the child to signal readiness.
|
||||
// 10s is generous — bus startup is local-only work (file I/O + socket bind),
|
||||
// so 1s would normally suffice; the extra headroom covers cold-start
|
||||
// keychain prompts and slow CI machines.
|
||||
const ReadyTimeout = 10 * time.Second
|
||||
|
||||
// ErrSpawnFailed is returned when the child reports startup failure via
|
||||
// the ready pipe ('E' byte). The child's exit error / log file holds the
|
||||
// actual cause; this sentinel just lets the caller distinguish "ready
|
||||
// pipe said no" from "ready pipe timed out / closed early".
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
|
||||
|
||||
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
|
||||
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
|
||||
|
||||
// SpawnConfig describes one spawn attempt. ClientID is the only field
|
||||
// inspected by the child; the rest govern process attributes the parent
|
||||
// applies before exec.
|
||||
type SpawnConfig struct {
|
||||
// ExecPath is the dws binary to exec. Default os.Executable().
|
||||
ExecPath string
|
||||
|
||||
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
|
||||
ClientID string
|
||||
|
||||
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
|
||||
// pass `--extra-flag-for-test` etc.
|
||||
ExtraArgs []string
|
||||
|
||||
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
|
||||
// entry is appended automatically.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
|
||||
// waits for it to signal readiness via the ready pipe. Returns the child's
|
||||
// PID on success — the caller can then dial the bus IPC endpoint.
|
||||
//
|
||||
// stdio detach (plan invariant #7):
|
||||
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
|
||||
// pollute the parent's NDJSON stream
|
||||
// - Setsid on Unix so the child survives parent SIGHUP / parent exit
|
||||
// - CREATE_NEW_PROCESS_GROUP on Windows (set in spawn_windows.go)
|
||||
//
|
||||
// Child startup (handled by the eventcmd._bus handler, P6):
|
||||
// - Opens os.NewFile(<DWS_EVENT_BUS_READY_FD>, "ready")
|
||||
// - On startup success → writes 'R' and closes
|
||||
// - On startup failure → writes 'E' and closes (child exits)
|
||||
//
|
||||
// Parent (this function):
|
||||
// - Holds the read end open until either 1 byte is read or ReadyTimeout
|
||||
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
if cfg.ClientID == "" {
|
||||
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
if cfg.ExecPath == "" {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("busctl: locate executable: %w", err)
|
||||
}
|
||||
cfg.ExecPath = execPath
|
||||
}
|
||||
if cfg.Env == nil {
|
||||
cfg.Env = os.Environ()
|
||||
}
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("busctl: pipe: %w", err)
|
||||
}
|
||||
defer pr.Close()
|
||||
// pw is passed to the child; close in parent after Start so only the
|
||||
// child holds the write end (so reads return EOF if child dies before
|
||||
// signalling, helping us distinguish death from slow startup).
|
||||
|
||||
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
|
||||
cmd := exec.Command(cfg.ExecPath, args...)
|
||||
cmd.Env = append(cfg.Env, ReadyFDEnv+"=3")
|
||||
cmd.ExtraFiles = []*os.File{pw} // child sees fd 3 = pw
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
applyDetach(cmd) // platform-specific Setsid / new process group
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = pw.Close()
|
||||
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
|
||||
}
|
||||
pid = cmd.Process.Pid
|
||||
|
||||
// Close parent's copy of the write end immediately. Now only the child
|
||||
// holds it; reading on pr will return EOF when the child exits without
|
||||
// signalling, instead of blocking forever.
|
||||
_ = pw.Close()
|
||||
|
||||
// The detached child owns its own process group. Wait in the background to
|
||||
// release its process resources; the caller only retains the numeric PID.
|
||||
go func() { _ = cmd.Wait() }()
|
||||
|
||||
// Wait for ready byte.
|
||||
if err := waitReady(pr); err != nil {
|
||||
return pid, err
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// waitReady reads the child's ready signal within ReadyTimeout. The first
|
||||
// byte is 'R' (ready) or 'E' (error); on 'E' the child may write its real
|
||||
// error text after the byte and close the pipe, which we surface so the
|
||||
// caller sees WHY the bus failed to start (instead of an opaque
|
||||
// ErrSpawnFailed). pr is closed by the caller on return.
|
||||
func waitReady(pr *os.File) error {
|
||||
type result struct {
|
||||
b byte
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 1)
|
||||
n, err := io.ReadFull(pr, buf)
|
||||
if err != nil {
|
||||
done <- result{err: err}
|
||||
return
|
||||
}
|
||||
if n != 1 {
|
||||
done <- result{err: io.ErrUnexpectedEOF}
|
||||
return
|
||||
}
|
||||
if buf[0] == 'E' {
|
||||
// Failure: read the trailing error text (bounded), which the
|
||||
// child writes right after 'E' before closing.
|
||||
rest, _ := io.ReadAll(io.LimitReader(pr, 4096))
|
||||
done <- result{b: 'E', msg: strings.TrimSpace(string(rest))}
|
||||
return
|
||||
}
|
||||
done <- result{b: buf[0]}
|
||||
}()
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
if errors.Is(res.err, io.EOF) || errors.Is(res.err, io.ErrUnexpectedEOF) {
|
||||
return ErrSpawnFailed // child closed pipe without writing
|
||||
}
|
||||
return fmt.Errorf("busctl: read ready pipe: %w", res.err)
|
||||
}
|
||||
switch res.b {
|
||||
case 'R':
|
||||
return nil
|
||||
case 'E':
|
||||
if res.msg != "" {
|
||||
return fmt.Errorf("%w: %s", ErrSpawnFailed, res.msg)
|
||||
}
|
||||
return ErrSpawnFailed
|
||||
default:
|
||||
return fmt.Errorf("busctl: unexpected ready byte %q", res.b)
|
||||
}
|
||||
case <-time.After(ReadyTimeout):
|
||||
return ErrSpawnTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// ReadyFDFromEnv returns the inherited ready pipe (or nil if not set). The
|
||||
// `event _bus` command handler calls this at startup, passes the returned
|
||||
// *os.File to bus.Run as Config.ReadyPipe, and the bus signals readiness
|
||||
// through it.
|
||||
func ReadyFDFromEnv() *os.File {
|
||||
v := os.Getenv(ReadyFDEnv)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
fd, err := strconv.Atoi(v)
|
||||
if err != nil || fd < 3 {
|
||||
return nil
|
||||
}
|
||||
return os.NewFile(uintptr(fd), "dws-bus-ready")
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
|
||||
// acts as a fake "dws event _bus" child. It reads ReadyFDFromEnv, writes
|
||||
// the byte specified by the env var, and either exits or sleeps based on
|
||||
// the second env var. Used by the Spawn tests below to exercise the
|
||||
// real fork path without requiring a separate test-helper binary.
|
||||
//
|
||||
// We piggy-back on the test binary because building a separate helper
|
||||
// would require either build tags or a `TestMain` two-phase exec; the
|
||||
// env-marker pattern is what Go's own os/exec tests use and stays
|
||||
// confined to this file.
|
||||
const (
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
// values:
|
||||
// "ready" — write 'R' then sleep 30s (parent should see ready)
|
||||
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
|
||||
// "silent" — exit without writing (parent should see ErrSpawnFailed via EOF)
|
||||
// "stall" — sleep without writing (parent should see ErrSpawnTimeout)
|
||||
// "write-stdout" — write 'R' to ready FD AND to stdout (parent verifies stdout was detached)
|
||||
)
|
||||
|
||||
// TestMain detects the child mode marker and executes the corresponding
|
||||
// behaviour before delegating to the normal test runner. Production
|
||||
// invocations never have this env var set so the dispatch is a no-op.
|
||||
func TestMain(m *testing.M) {
|
||||
switch os.Getenv(childEnvMarker) {
|
||||
case "ready":
|
||||
writeReady('R')
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "fail":
|
||||
writeReady('E')
|
||||
os.Exit(1)
|
||||
case "silent":
|
||||
// don't open the ready FD at all — let the pipe close on exec exit
|
||||
os.Exit(2)
|
||||
case "stall":
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "write-stdout":
|
||||
// Write to stdout BEFORE signalling ready. Parent's Spawn should
|
||||
// have detached stdout to /dev/null, so the parent's stdout
|
||||
// buffer (captured separately in the test) must NOT see this.
|
||||
_, _ = os.Stdout.Write([]byte("POLLUTION-FROM-CHILD\n"))
|
||||
writeReady('R')
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func writeReady(b byte) {
|
||||
pipe := ReadyFDFromEnv()
|
||||
if pipe == nil {
|
||||
return
|
||||
}
|
||||
_, _ = pipe.Write([]byte{b})
|
||||
_ = pipe.Close()
|
||||
}
|
||||
|
||||
// spawnWithMarker invokes Spawn against the current test binary with a
|
||||
// child-mode env var set. Returns the child's PID + the Spawn error.
|
||||
func spawnWithMarker(t *testing.T, marker string, opts ...func(*SpawnConfig)) (int, error) {
|
||||
t.Helper()
|
||||
cfg := SpawnConfig{
|
||||
ExecPath: os.Args[0],
|
||||
ClientID: "ding_spawn_test",
|
||||
Env: append(os.Environ(),
|
||||
childEnvMarker+"="+marker,
|
||||
),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return Spawn(cfg)
|
||||
}
|
||||
|
||||
func TestSpawn_ReadySuccess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Spawn uses Unix Setsid; Windows path covered separately")
|
||||
}
|
||||
pid, err := spawnWithMarker(t, "ready")
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn ready: %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Errorf("Spawn returned non-positive pid %d", pid)
|
||||
}
|
||||
// Reap the child so it doesn't outlive the test.
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_ReadyFailReportsErrSpawnFailed(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Spawn uses Unix Setsid")
|
||||
}
|
||||
pid, err := spawnWithMarker(t, "fail")
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("err = %v, want ErrSpawnFailed", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Errorf("pid should still be reported even on fail, got %d", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_ChildSilentExitReportsErrSpawnFailed(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Spawn uses Unix Setsid")
|
||||
}
|
||||
_, err := spawnWithMarker(t, "silent")
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("err = %v, want ErrSpawnFailed (EOF on pipe)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_StallReportsTimeout(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Spawn uses Unix Setsid")
|
||||
}
|
||||
// Temporarily shorten ReadyTimeout via a local Spawn variant. The
|
||||
// production timeout is 10s — too long for a unit test. We exec
|
||||
// manually with a tiny io-wait wrapper to verify the behaviour.
|
||||
//
|
||||
// We can't change the package-level const, so we re-implement the
|
||||
// timeout part directly using the same primitives the production
|
||||
// code uses.
|
||||
cmd := exec.Command(os.Args[0])
|
||||
cmd.Env = append(os.Environ(), childEnvMarker+"=stall", ReadyFDEnv+"=3")
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.ExtraFiles = []*os.File{pw}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
_ = pw.Close()
|
||||
|
||||
// Replicate waitReady with a tiny timeout.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
b := make([]byte, 1)
|
||||
_, err := pr.Read(b)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("child should have stalled; got data on ready pipe")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
// expected — child is stalling, no ready byte arrived
|
||||
}
|
||||
_ = pr.Close()
|
||||
}
|
||||
|
||||
func TestSpawn_StdioDetached(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Spawn uses Unix Setsid; Windows stdio handling differs")
|
||||
}
|
||||
// Capture this process's stdout for the duration of the child run.
|
||||
// If applyDetach is broken and cmd.Stdout would otherwise inherit,
|
||||
// the child's "POLLUTION-FROM-CHILD" line would land in our pipe.
|
||||
origStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
defer func() {
|
||||
os.Stdout = origStdout
|
||||
_ = w.Close()
|
||||
_ = r.Close()
|
||||
}()
|
||||
|
||||
pid, err := spawnWithMarker(t, "write-stdout")
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
}
|
||||
}()
|
||||
// Close the write end on the parent side so reading r will EOF if
|
||||
// nothing arrived. Give the child a beat to attempt the write.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_ = w.Close()
|
||||
|
||||
buf := make([]byte, 256)
|
||||
r.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, _ := r.Read(buf)
|
||||
if n > 0 {
|
||||
t.Fatalf("child wrote %q to parent's stdout — stdio not detached!", buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyFDFromEnv_NoEnvReturnsNil(t *testing.T) {
|
||||
t.Setenv(ReadyFDEnv, "")
|
||||
if f := ReadyFDFromEnv(); f != nil {
|
||||
t.Errorf("ReadyFDFromEnv with empty env should be nil, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyFDFromEnv_InvalidIntReturnsNil(t *testing.T) {
|
||||
t.Setenv(ReadyFDEnv, "not-an-int")
|
||||
if f := ReadyFDFromEnv(); f != nil {
|
||||
t.Errorf("ReadyFDFromEnv with bad value should be nil, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyFDFromEnv_LowFDRejected(t *testing.T) {
|
||||
// fd 0/1/2 are stdio — refusing them defends against accidental
|
||||
// stdin/stdout/stderr corruption if someone misconfigures.
|
||||
t.Setenv(ReadyFDEnv, "1")
|
||||
if f := ReadyFDFromEnv(); f != nil {
|
||||
t.Errorf("ReadyFDFromEnv should reject stdio fds, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// waitReady must surface the child's real startup error (written after the
|
||||
// 'E' byte) so consume shows WHY the bus failed, not an opaque message.
|
||||
func TestWaitReady_FailureSurfacesChildError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
const reason = "newPersonalStreamSource: token expired for corp dinga626"
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte{'E'})
|
||||
_, _ = io.WriteString(pw, reason)
|
||||
_ = pw.Close()
|
||||
}()
|
||||
err = waitReady(pr)
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reason) {
|
||||
t.Errorf("error must surface the child's real reason; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A bare 'E' (no trailing text) still reports ErrSpawnFailed.
|
||||
func TestWaitReady_BareFailureByte(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
go func() { _, _ = pw.Write([]byte{'E'}); _ = pw.Close() }()
|
||||
if err := waitReady(pr); !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyDetach configures the child to live past parent death and not share
|
||||
// the parent's controlling terminal. Setsid puts the child in a new
|
||||
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
|
||||
// does not propagate. Setpgid is implied by Setsid.
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
|
||||
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
|
||||
const createNewProcessGroup = 0x00000200
|
||||
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: createNewProcessGroup,
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// BusEntryState classifies a discovered bus directory's runtime state.
|
||||
// Used by `dws event status/list` to render the table and by
|
||||
// --fail-on-orphan to drive exit code.
|
||||
type BusEntryState string
|
||||
|
||||
const (
|
||||
// BusStateRunning: bus.lock holds an alive PID — the daemon is up.
|
||||
BusStateRunning BusEntryState = "running"
|
||||
// BusStateOrphan: bus.meta exists but bus.lock PID is dead. The user
|
||||
// should `dws event stop --client-id <id>` (which detects the dead
|
||||
// PID and unblocks fresh starts) or rm -rf the working directory.
|
||||
BusStateOrphan BusEntryState = "orphan"
|
||||
// BusStateNotRunning: directory exists (e.g. bus.meta retained for
|
||||
// historic reasons) but bus.lock is missing or empty. Clean state.
|
||||
BusStateNotRunning BusEntryState = "not_running"
|
||||
)
|
||||
|
||||
// BusEntry is one bus working directory found on disk plus its detected
|
||||
// lifecycle state. EnumerateBuses produces these; the cobra layer joins
|
||||
// them with QueryStatus output to render the full status view.
|
||||
type BusEntry struct {
|
||||
WorkDir string `json:"workdir"`
|
||||
Edition string `json:"edition"`
|
||||
SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"`
|
||||
ClientIDHash string `json:"client_id_hash"`
|
||||
IdentityHash string `json:"identity_hash,omitempty"`
|
||||
HolderPID int `json:"holder_pid"`
|
||||
State BusEntryState `json:"state"`
|
||||
// Meta, if non-nil, lets list/status display the original ClientID
|
||||
// (reverse-mapped from the hash) and the bus start time.
|
||||
Meta *bus.Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// IPCEndpoint returns the IPC endpoint for this entry. Delegates to
|
||||
// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the
|
||||
// bus daemon bound (including the short-path fallback when WorkDir is too
|
||||
// deep for sun_path).
|
||||
func (e BusEntry) IPCEndpoint() string {
|
||||
hash := e.ClientIDHash
|
||||
if e.IdentityHash != "" {
|
||||
hash = e.IdentityHash
|
||||
}
|
||||
return dwsevent.IPCEndpoint(e.WorkDir, e.Edition, e.SourceKind, hash)
|
||||
}
|
||||
|
||||
// EnumerateBuses scans <configDir>/events/<editionFilter>/*/ for bus
|
||||
// working directories. An empty editionFilter scans every edition
|
||||
// directory found under events/.
|
||||
//
|
||||
// Returns a deterministic slice sorted by (edition, source_kind, identity_hash).
|
||||
// Missing/inaccessible directories are skipped silently — list/status
|
||||
// commands should still succeed when only some editions have ever run a
|
||||
// bus.
|
||||
func EnumerateBuses(configDir string, editionFilter string) ([]BusEntry, error) {
|
||||
root := filepath.Join(configDir, "events")
|
||||
editions, err := listSubdirs(root)
|
||||
if err != nil {
|
||||
// events/ might not exist if no bus ever ran — that's fine,
|
||||
// surface an empty list rather than an error.
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("busctl: scan events dir: %w", err)
|
||||
}
|
||||
|
||||
var out []BusEntry
|
||||
for _, ed := range editions {
|
||||
if editionFilter != "" && ed != editionFilter {
|
||||
continue
|
||||
}
|
||||
editionDir := filepath.Join(root, ed)
|
||||
hashDirs, err := listSubdirs(editionDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, h := range hashDirs {
|
||||
candidate := filepath.Join(editionDir, h)
|
||||
if isSourceKindDir(h) {
|
||||
identityDirs, err := listSubdirs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ih := range identityDirs {
|
||||
workDir := filepath.Join(candidate, ih)
|
||||
out = append(out, inspectEntry(workDir, ed, h, ih))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Legacy v1 app-stream layout: events/<edition>/<client_hash>.
|
||||
out = append(out, inspectEntry(candidate, ed, "", h))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Edition != out[j].Edition {
|
||||
return out[i].Edition < out[j].Edition
|
||||
}
|
||||
if out[i].SourceKind != out[j].SourceKind {
|
||||
return out[i].SourceKind < out[j].SourceKind
|
||||
}
|
||||
return out[i].IdentityHash < out[j].IdentityHash
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FindBusByClientID is the "current ClientID" lookup used by `event status`
|
||||
// (no --all). Returns the entry for the given (edition, clientIDHash) pair
|
||||
// or nil if no bus has ever started for it. The caller derives the hash
|
||||
// using event.ClientIDHash.
|
||||
func FindBusByClientID(configDir, editionName, clientIDHash string) *BusEntry {
|
||||
workDir := filepath.Join(configDir, "events", editionName, string(dwsevent.SourceKindAppStream), clientIDHash)
|
||||
if _, err := os.Stat(workDir); err != nil {
|
||||
legacy := filepath.Join(configDir, "events", editionName, clientIDHash)
|
||||
if _, legacyErr := os.Stat(legacy); legacyErr != nil {
|
||||
return nil
|
||||
}
|
||||
workDir = legacy
|
||||
}
|
||||
e := inspectEntry(workDir, editionName, string(dwsevent.SourceKindAppStream), clientIDHash)
|
||||
return &e
|
||||
}
|
||||
|
||||
// FindBusByIdentity looks up a bus in the source-kind-aware layout.
|
||||
func FindBusByIdentity(configDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) *BusEntry {
|
||||
if sourceKind == "" {
|
||||
sourceKind = dwsevent.SourceKindAppStream
|
||||
}
|
||||
workDir := filepath.Join(configDir, "events", editionName, string(sourceKind), identityHash)
|
||||
if _, err := os.Stat(workDir); err != nil {
|
||||
return nil
|
||||
}
|
||||
e := inspectEntry(workDir, editionName, string(sourceKind), identityHash)
|
||||
return &e
|
||||
}
|
||||
|
||||
// listSubdirs returns immediate subdirectories of path, by basename. Ignores
|
||||
// regular files. Returns the err from ReadDir unchanged (callers handle
|
||||
// os.IsNotExist).
|
||||
func listSubdirs(path string) ([]string, error) {
|
||||
ents, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(ents))
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inspectEntry reads bus.meta + bus.lock and derives the lifecycle state.
|
||||
// Never returns an error: any read failure folds into BusStateNotRunning.
|
||||
func inspectEntry(workDir, editionName, sourceKindRaw, identityHash string) BusEntry {
|
||||
sourceKind := dwsevent.SourceKind(sourceKindRaw)
|
||||
if sourceKind == "" {
|
||||
sourceKind = dwsevent.SourceKindAppStream
|
||||
}
|
||||
e := BusEntry{
|
||||
WorkDir: workDir,
|
||||
Edition: editionName,
|
||||
SourceKind: sourceKind,
|
||||
ClientIDHash: identityHash,
|
||||
IdentityHash: identityHash,
|
||||
State: BusStateNotRunning,
|
||||
}
|
||||
if m, err := bus.ReadMeta(workDir); err == nil {
|
||||
e.Meta = m
|
||||
if m.SourceKind != "" {
|
||||
e.SourceKind = m.SourceKind
|
||||
}
|
||||
if m.IdentityHash != "" {
|
||||
e.IdentityHash = m.IdentityHash
|
||||
e.ClientIDHash = m.IdentityHash
|
||||
}
|
||||
}
|
||||
pid := bus.ReadHolderPID(filepath.Join(workDir, bus.LockFileName))
|
||||
e.HolderPID = pid
|
||||
switch {
|
||||
case pid > 0 && process.Alive(pid):
|
||||
e.State = BusStateRunning
|
||||
case pid > 0 && !process.Alive(pid):
|
||||
e.State = BusStateOrphan
|
||||
case pid == 0 && e.Meta != nil:
|
||||
// meta retained but lock cleared — bus exited cleanly. Render as
|
||||
// not_running (with the historical meta visible if the user asked
|
||||
// for --format json).
|
||||
e.State = BusStateNotRunning
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func isSourceKindDir(name string) bool {
|
||||
return name == string(dwsevent.SourceKindAppStream) || name == string(dwsevent.SourceKindPersonalStream)
|
||||
}
|
||||
|
||||
// DefaultStatusRPCTimeout caps how long QueryStatus waits for the bus to
|
||||
// reply. 2s is generous — the bus's status_resp is a synchronous
|
||||
// in-memory snapshot, sub-millisecond in practice; the timeout exists
|
||||
// only to bound pathological cases (bus stuck in shutdown).
|
||||
const DefaultStatusRPCTimeout = 2 * time.Second
|
||||
|
||||
// QueryStatus dials the bus IPC, sends Hello with Role=status, sends a
|
||||
// StatusReq, reads exactly one StatusResp, and closes. Returns the
|
||||
// decoded response or an error if any step fails.
|
||||
//
|
||||
// Used by `dws event status` and `dws event list` to fetch live
|
||||
// per-consumer / per-event-type counters. The bus's handleStatusRPC
|
||||
// path (see internal/event/bus/daemon.go) handles this connection
|
||||
// without registering with the Hub — ad-hoc tooling does not count as
|
||||
// a consumer in `status.active_consumers`.
|
||||
func QueryStatus(endpoint string) (*transport.StatusResp, error) {
|
||||
conn, err := transport.Dial(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("busctl: dial bus for status: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
|
||||
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
Role: transport.HelloRoleStatus,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("busctl: write hello: %w", err)
|
||||
}
|
||||
if err := w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq}); err != nil {
|
||||
return nil, fmt.Errorf("busctl: write status_req: %w", err)
|
||||
}
|
||||
var resp transport.StatusResp
|
||||
if err := r.ReadJSON(&resp); err != nil {
|
||||
return nil, fmt.Errorf("busctl: read status_resp: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// EntryStatus combines static FS info (BusEntry) with the live RPC
|
||||
// snapshot (StatusResp). For not_running / orphan entries Live is nil.
|
||||
type EntryStatus struct {
|
||||
Entry BusEntry `json:"entry"`
|
||||
Live *transport.StatusResp `json:"live,omitempty"`
|
||||
}
|
||||
|
||||
// QueryEntry fetches the live status for one BusEntry. Returns the entry
|
||||
// wrapped with a nil Live when state != running (or when the dial fails).
|
||||
// Errors from QueryStatus are folded into Live=nil so the caller's table
|
||||
// rendering does not need to surface per-bus dial failures (they are
|
||||
// already conveyed by State).
|
||||
func QueryEntry(entry BusEntry) EntryStatus {
|
||||
out := EntryStatus{Entry: entry}
|
||||
if entry.State != BusStateRunning {
|
||||
return out
|
||||
}
|
||||
live, err := QueryStatus(entry.IPCEndpoint())
|
||||
if err == nil {
|
||||
out.Live = live
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
)
|
||||
|
||||
func TestEnumerateBuses_EmptyConfigDir(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
got, err := EnumerateBuses(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for empty configDir, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// makeBusDir creates events/<edition>/<hash>/ with optional meta + lock
|
||||
// content. Returns the path.
|
||||
func makeBusDir(t *testing.T, configDir, ed, hash string, withMeta bool, lockPID int) string {
|
||||
t.Helper()
|
||||
workDir := filepath.Join(configDir, "events", ed, hash)
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if withMeta {
|
||||
shortHash := hash
|
||||
if len(shortHash) > 8 {
|
||||
shortHash = shortHash[:8]
|
||||
}
|
||||
if err := bus.WriteMeta(workDir, bus.Meta{
|
||||
ClientID: "ding_" + shortHash,
|
||||
Edition: ed,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if lockPID != 0 {
|
||||
if err := os.WriteFile(filepath.Join(workDir, bus.LockFileName),
|
||||
[]byte(strconv.Itoa(lockPID)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return workDir
|
||||
}
|
||||
|
||||
func TestEnumerateBuses_DetectsRunningOrphanNotRunning(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
makeBusDir(t, dir, "open", "aaaa1111", true, os.Getpid()) // running (self pid)
|
||||
makeBusDir(t, dir, "open", "bbbb2222", true, 2147483646) // orphan (dead pid)
|
||||
makeBusDir(t, dir, "open", "cccc3333", true, 0) // not_running (meta only, no lock content)
|
||||
makeBusDir(t, dir, "wukong", "dddd4444", true, os.Getpid()) // running, different edition
|
||||
|
||||
all, err := EnumerateBuses(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnumerateBuses all: %v", err)
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Fatalf("expected 4 entries, got %d: %+v", len(all), all)
|
||||
}
|
||||
|
||||
// Filter by edition.
|
||||
openOnly, err := EnumerateBuses(dir, "open")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(openOnly) != 3 {
|
||||
t.Fatalf("expected 3 open-edition entries, got %d", len(openOnly))
|
||||
}
|
||||
for _, e := range openOnly {
|
||||
if e.Edition != "open" {
|
||||
t.Errorf("editionFilter leak: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// State classification (in the all-editions slice the entries are
|
||||
// sorted by edition,hash so we can index deterministically).
|
||||
byHash := map[string]BusEntry{}
|
||||
for _, e := range all {
|
||||
byHash[e.ClientIDHash] = e
|
||||
}
|
||||
if got := byHash["aaaa1111"].State; got != BusStateRunning {
|
||||
t.Errorf("aaaa1111 state = %s, want running", got)
|
||||
}
|
||||
if got := byHash["bbbb2222"].State; got != BusStateOrphan {
|
||||
t.Errorf("bbbb2222 state = %s, want orphan", got)
|
||||
}
|
||||
if got := byHash["cccc3333"].State; got != BusStateNotRunning {
|
||||
t.Errorf("cccc3333 state = %s, want not_running", got)
|
||||
}
|
||||
if got := byHash["dddd4444"].State; got != BusStateRunning {
|
||||
t.Errorf("dddd4444 state = %s, want running", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnumerateBuses_SortedDeterministic(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
makeBusDir(t, dir, "open", "zzzz", true, 0)
|
||||
makeBusDir(t, dir, "open", "aaaa", true, 0)
|
||||
makeBusDir(t, dir, "wukong", "bbbb", true, 0)
|
||||
got, _ := EnumerateBuses(dir, "")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d entries", len(got))
|
||||
}
|
||||
// expected order: open/aaaa, open/zzzz, wukong/bbbb
|
||||
if got[0].ClientIDHash != "aaaa" || got[1].ClientIDHash != "zzzz" || got[2].ClientIDHash != "bbbb" {
|
||||
t.Fatalf("sort order wrong:\n %+v\n %+v\n %+v", got[0], got[1], got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBusByClientID(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
makeBusDir(t, dir, "open", "aaaa", true, os.Getpid())
|
||||
if e := FindBusByClientID(dir, "open", "aaaa"); e == nil {
|
||||
t.Fatal("FindBusByClientID returned nil for existing entry")
|
||||
} else if e.State != BusStateRunning {
|
||||
t.Errorf("State = %s, want running", e.State)
|
||||
}
|
||||
if e := FindBusByClientID(dir, "open", "missing"); e != nil {
|
||||
t.Errorf("missing entry should return nil, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryStatus_RealBusE2E(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
// Bring up a real bus daemon, then query it.
|
||||
workDir := shortTempDir(t)
|
||||
sock := filepath.Join(workDir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- bus.Run(ctx, bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test_query",
|
||||
Edition: "open",
|
||||
Source: &fakeSrc{},
|
||||
})
|
||||
}()
|
||||
defer func() { cancel(); <-runDone }()
|
||||
waitForSocket(t, sock, 2*time.Second)
|
||||
|
||||
resp, err := QueryStatus(sock)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryStatus: %v", err)
|
||||
}
|
||||
if resp.Bus.ClientID != "ding_test_query" {
|
||||
t.Errorf("ClientID round-trip = %q", resp.Bus.ClientID)
|
||||
}
|
||||
if resp.Bus.Edition != "open" {
|
||||
t.Errorf("Edition = %q", resp.Bus.Edition)
|
||||
}
|
||||
if resp.Bus.PID != os.Getpid() {
|
||||
t.Errorf("Bus.PID = %d, want %d", resp.Bus.PID, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSrc is a no-op SourceAdapter used by QueryStatus E2E. It just
|
||||
// blocks on ctx so the bus daemon stays up long enough for the test to
|
||||
// dial it.
|
||||
type fakeSrc struct{}
|
||||
|
||||
func (fakeSrc) Start(ctx context.Context, _ dwsevent.EmitFn) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// waitForSocket polls for the unix socket file. Reused by tests that
|
||||
// need to dial a freshly-spawned bus.
|
||||
func waitForSocket(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("socket %q did not appear within %s", path, timeout)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
||||
)
|
||||
|
||||
// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to
|
||||
// exit after the signal is sent. 5s covers the bus's own graceful tear-down
|
||||
// (broadcast Bye → consumer goroutines drain → cleanup) with margin.
|
||||
const DefaultStopTimeout = 5 * time.Second
|
||||
|
||||
// ErrNotRunning indicates bus.lock either does not exist or its recorded
|
||||
// PID is not alive. Stop returns this as a sentinel so the caller can
|
||||
// distinguish "nothing to stop" from "failed to stop".
|
||||
var ErrNotRunning = errors.New("busctl: bus is not running")
|
||||
|
||||
// StopConfig identifies the target bus and tunes timing.
|
||||
type StopConfig struct {
|
||||
// WorkDir holds bus.lock; Stop reads the PID from there.
|
||||
WorkDir string
|
||||
// Timeout is the total wall-clock budget for graceful exit. After this,
|
||||
// Stop returns an error; it does NOT escalate to SIGKILL — leave that
|
||||
// to the operator.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Stop signals the bus daemon for cfg.WorkDir to exit gracefully and waits
|
||||
// for the process to actually die. Returns ErrNotRunning if no bus is
|
||||
// running for that work dir.
|
||||
//
|
||||
// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop
|
||||
// watches its parent ctx for cancellation; the cobra `event _bus` command
|
||||
// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful
|
||||
// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which
|
||||
// the Go runtime maps to TerminateProcess for processes outside our
|
||||
// console group; for v1 that's acceptable (Windows graceful shutdown is
|
||||
// future work — plan §16 v2).
|
||||
func Stop(cfg StopConfig) error {
|
||||
if cfg.WorkDir == "" {
|
||||
return errors.New("busctl: StopConfig.WorkDir is required")
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = DefaultStopTimeout
|
||||
}
|
||||
|
||||
pid := bus.ReadHolderPID(LockPath(cfg.WorkDir))
|
||||
if pid <= 0 {
|
||||
return ErrNotRunning
|
||||
}
|
||||
if !process.Alive(pid) {
|
||||
return ErrNotRunning
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: find process %d: %w", pid, err)
|
||||
}
|
||||
if err := proc.Signal(stopSignal()); err != nil {
|
||||
// On many Unix platforms Signal returns "process already finished"
|
||||
// when the bus has just exited on its own — treat that as success.
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Poll for actual exit.
|
||||
deadline := time.Now().Add(cfg.Timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !process.Alive(pid) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
)
|
||||
|
||||
func TestStop_NotRunningWhenLockMissing(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
err := Stop(StopConfig{WorkDir: dir})
|
||||
if !errors.Is(err, ErrNotRunning) {
|
||||
t.Fatalf("Stop on missing lock = %v, want ErrNotRunning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStop_NotRunningWhenPIDDead(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
// Write a definitely-dead PID into bus.lock.
|
||||
if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := Stop(StopConfig{WorkDir: dir})
|
||||
if !errors.Is(err, ErrNotRunning) {
|
||||
t.Fatalf("Stop on dead PID = %v, want ErrNotRunning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStop_SignalsLiveProcess(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
|
||||
// Spawn `sleep 10` to act as the "bus daemon".
|
||||
cmd := exec.CommandContext(context.Background(), "sh", "-c", "sleep 10")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start sleep child: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
// Best-effort cleanup if test fails.
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
}()
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Reap the child in background so Wait doesn't leave a zombie.
|
||||
waited := make(chan error, 1)
|
||||
go func() { waited <- cmd.Wait() }()
|
||||
|
||||
// Write PID into bus.lock.
|
||||
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stop should signal SIGTERM and observe the process exit.
|
||||
start := time.Now()
|
||||
if err := Stop(StopConfig{WorkDir: dir, Timeout: 3 * time.Second}); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// sleep should react to SIGTERM almost immediately.
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("Stop took %s, expected <2s for SIGTERM-responsive child", elapsed)
|
||||
}
|
||||
|
||||
// Confirm the child actually exited.
|
||||
select {
|
||||
case err := <-waited:
|
||||
// sh -c "sleep 10" exits with non-zero on signal; either is fine.
|
||||
_ = err
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("child did not exit after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_TimeoutWhenChildIgnoresSignal ensures Stop honours its deadline
|
||||
// and returns a useful error instead of hanging forever.
|
||||
func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
|
||||
// shell that traps SIGTERM and ignores it for a long time
|
||||
cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start trap child: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := Stop(StopConfig{WorkDir: dir, Timeout: 250 * time.Millisecond})
|
||||
if err == nil {
|
||||
t.Fatal("Stop should error when child ignores SIGTERM")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_RealBusGracefulShutdown is the integration sanity check: bring
|
||||
// up a real bus.Run instance, set bus.lock content to its PID, call Stop,
|
||||
// and verify Run returned cleanly (via ctx done propagation in the test).
|
||||
//
|
||||
// NOTE: bus.Run installs its own ctx handler from the caller's ctx; here
|
||||
// we don't have signal.NotifyContext (we're running in-process), so Stop's
|
||||
// SIGTERM won't reach bus.Run unless we install a signal handler. Instead,
|
||||
// we test the underlying primitives: PID-read, signal-send, alive-poll.
|
||||
func TestStop_BusLockPathHelper(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
got := LockPath(dir)
|
||||
want := filepath.Join(dir, bus.LockFileName)
|
||||
if got != want {
|
||||
t.Fatalf("LockPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// stopSignal returns the graceful-shutdown signal for Unix (SIGTERM). The
|
||||
// `event _bus` command wires signal.NotifyContext on SIGINT/SIGTERM so
|
||||
// the daemon's Run sees ctx.Done() and runs its shutdown sequence.
|
||||
func stopSignal() os.Signal { return syscall.SIGTERM }
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package busctl
|
||||
|
||||
import "os"
|
||||
|
||||
// stopSignal returns the graceful-shutdown signal for Windows. The Go
|
||||
// runtime maps os.Interrupt to TerminateProcess for non-console-group
|
||||
// processes — not truly graceful, but acceptable for v1 (Windows graceful
|
||||
// shutdown via Ctrl+Break is in the v2 backlog, plan §16).
|
||||
func stopSignal() os.Signal { return os.Interrupt }
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package consume implements the consumer-side process of `dws event
|
||||
// consume`: dial the bus, send Hello, read Event frames, format them, and
|
||||
// write them out (stdout / file / dir). v1 (P3) implements the minimal
|
||||
// path — NDJSON to stdout. P4 adds filter/format/route/compact pipeline.
|
||||
package consume
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
)
|
||||
|
||||
// TestRun_DurationExitsCleanly verifies that --duration triggers a
|
||||
// graceful exit (nil return, no error surfaced) and does so within a
|
||||
// small multiple of the requested duration. The contract: --duration
|
||||
// is a wall-clock budget, not an "abort" — Run wraps the caller's ctx
|
||||
// with WithTimeout and returns nil rather than DeadlineExceeded so the
|
||||
// exit code stays 0.
|
||||
func TestRun_DurationExitsCleanly(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
close(trigger) // no events to fire — Run will exit on duration alone
|
||||
|
||||
duration := 200 * time.Millisecond
|
||||
start := time.Now()
|
||||
err := Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
Duration: duration,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run with --duration should exit nil, got %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < duration {
|
||||
t.Errorf("Run returned before duration elapsed: %s < %s", elapsed, duration)
|
||||
}
|
||||
if elapsed > duration+2*time.Second {
|
||||
t.Errorf("Run took %s, much longer than duration %s", elapsed, duration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_DurationZeroMeansUnlimited verifies the documented "0 = no
|
||||
// limit" semantic of --duration. We start with a small parent-ctx
|
||||
// timeout to bound the test runtime; Run should respect that ctx
|
||||
// instead of having its own (zero) duration trigger.
|
||||
func TestRun_DurationZeroMeansUnlimited(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
close(trigger)
|
||||
|
||||
ctx, ctxCancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer ctxCancel()
|
||||
start := time.Now()
|
||||
err := Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
Duration: 0, // explicitly unlimited
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run with --duration=0 should exit nil (ctx cancel), got %v", err)
|
||||
}
|
||||
// Run should respect the parent ctx — not Duration. So it returns
|
||||
// at roughly the ctx deadline.
|
||||
if elapsed := time.Since(start); elapsed < 100*time.Millisecond {
|
||||
t.Errorf("Run returned too quickly (%s); --duration=0 should defer to parent ctx", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_DryRunPrintsConfigAndExits verifies --dry-run is end-to-end
|
||||
// observable: Run never dials the bus (so even with a bogus IPC endpoint
|
||||
// it returns nil) and writes the config block to Stderr.
|
||||
func TestRun_DryRunDoesNotDial(t *testing.T) {
|
||||
// We deliberately give a non-existent endpoint to prove Run does
|
||||
// not try to dial.
|
||||
dir := shortTempDir(t)
|
||||
bogusSock := filepath.Join(dir, "no-such.sock")
|
||||
err := Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: bogusSock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun should bypass dial and return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check that bus.ApplyEnvTuning is wired the same way Run reads
|
||||
// from Config.Duration — both should be additive and not interfere.
|
||||
func TestApplyEnvTuning_DoesNotTouchDuration(t *testing.T) {
|
||||
// Duration is a consume.Config field, not bus.Config — but we still
|
||||
// want a smoke test that ApplyEnvTuning doesn't accidentally reach
|
||||
// into the consume layer.
|
||||
cfg := bus.Config{}
|
||||
bus.ApplyEnvTuning(&cfg)
|
||||
// (no Duration field on bus.Config; this test compiles only if the
|
||||
// invariant holds — caught by reviewer if someone adds one)
|
||||
_ = cfg
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Format identifies the wire shape `dws event consume` writes per event.
|
||||
// Values mirror dws's global -f/--format flag vocabulary (defined in
|
||||
// internal/output) with the subset that makes sense for streaming.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
// FormatNDJSON is the default: one compact JSON object per line.
|
||||
// Pipe-friendly; one event per `read` line. Recommended for agents.
|
||||
FormatNDJSON Format = "ndjson"
|
||||
// FormatJSON pretty-prints each event as multi-line JSON. NOT a
|
||||
// JSON array — still NDJSON-style (one document per output unit),
|
||||
// just with indentation. See plan §3.1 输出约束 note about why we
|
||||
// do not emit a JSON array for an unbounded stream.
|
||||
FormatJSON Format = "json"
|
||||
// FormatPretty is the same as FormatJSON in v1; reserved for future
|
||||
// human-friendly colorisation. Kept distinct so we never silently
|
||||
// degrade `--format pretty` to compact-ndjson.
|
||||
FormatPretty Format = "pretty"
|
||||
// FormatRaw writes only the SDK's original Data string (one per
|
||||
// event, newline-terminated). Useful when piping into jq / a tool
|
||||
// that wants the cloud payload verbatim without our envelope.
|
||||
FormatRaw Format = "raw"
|
||||
// FormatCompact runs the per-event-type compact processor (see
|
||||
// registry.LookupProcessor) and emits one flattened JSON line.
|
||||
FormatCompact Format = "compact"
|
||||
)
|
||||
|
||||
// NormalizeFormat maps a raw flag value to a supported Format. Values
|
||||
// outside the event command's supported set fall back to NDJSON with the
|
||||
// fallback flag set true — callers SHOULD warn on stderr when fallback is
|
||||
// true and the original value was non-empty (e.g. user passed
|
||||
// --format table which has no meaning for an event stream).
|
||||
//
|
||||
// Empty input maps to NDJSON without a fallback warning.
|
||||
func NormalizeFormat(raw string) (f Format, fellback bool) {
|
||||
switch raw {
|
||||
case "":
|
||||
return FormatNDJSON, false
|
||||
case string(FormatNDJSON):
|
||||
return FormatNDJSON, false
|
||||
case string(FormatJSON):
|
||||
return FormatJSON, false
|
||||
case string(FormatPretty):
|
||||
return FormatPretty, false
|
||||
case string(FormatRaw):
|
||||
return FormatRaw, false
|
||||
case string(FormatCompact):
|
||||
return FormatCompact, false
|
||||
default:
|
||||
// Includes table/csv from the global -f vocabulary, plus any
|
||||
// typo. Fall back to ndjson (the safe stream default) and let
|
||||
// the caller stderr-WARN.
|
||||
return FormatNDJSON, true
|
||||
}
|
||||
}
|
||||
|
||||
// Formatter renders a transport.Event into the byte stream the sink writes
|
||||
// out. Implementations append their own line terminator when appropriate
|
||||
// (NDJSON / Raw add '\n'; Pretty/JSON embed newlines in the JSON itself).
|
||||
type Formatter interface {
|
||||
Render(ev transport.Event) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewFormatter returns a Formatter for the given Format. Compact wraps
|
||||
// registry.LookupProcessor so adding a new specialised compactor is just
|
||||
// a registry-side change. Returns an error only if format is an internally
|
||||
// unsupported value (defensive — NormalizeFormat guarantees the input is
|
||||
// one of the constants).
|
||||
func NewFormatter(format Format) (Formatter, error) {
|
||||
switch format {
|
||||
case FormatNDJSON:
|
||||
return &ndjsonFormatter{}, nil
|
||||
case FormatJSON, FormatPretty:
|
||||
return &prettyFormatter{}, nil
|
||||
case FormatRaw:
|
||||
return &rawFormatter{}, nil
|
||||
case FormatCompact:
|
||||
return &compactFormatter{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("consume: unsupported format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
// ndjsonFormatter encodes each Event as one compact JSON line + '\n'.
|
||||
type ndjsonFormatter struct{}
|
||||
|
||||
func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
// prettyFormatter encodes each Event as multi-line indented JSON + '\n'.
|
||||
// json.MarshalIndent does not append a trailing newline; we add one so
|
||||
// successive events are visually separated in the output.
|
||||
type prettyFormatter struct{}
|
||||
|
||||
func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
b, err := json.MarshalIndent(ev, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
|
||||
// rawFormatter writes ev.Data verbatim. If Data is already JSON it stays
|
||||
// JSON; if it's some other string format it stays that. A trailing newline
|
||||
// is appended so successive raw events are separable.
|
||||
type rawFormatter struct{}
|
||||
|
||||
func (rawFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
out := make([]byte, 0, len(ev.Data)+1)
|
||||
out = append(out, ev.Data...)
|
||||
if len(ev.Data) == 0 || ev.Data[len(ev.Data)-1] != '\n' {
|
||||
out = append(out, '\n')
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compactFormatter dispatches to the registry per event_type and writes
|
||||
// the flattened map as one compact JSON line + '\n'.
|
||||
type compactFormatter struct{}
|
||||
|
||||
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
|
||||
p := registry.LookupProcessor(ev.EventType)
|
||||
v := p(ev)
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(b, '\n'), nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func sampleEvent() transport.Event {
|
||||
return transport.Event{
|
||||
Type: transport.FrameTypeEvent,
|
||||
Seq: 42,
|
||||
EventID: "ev_abc",
|
||||
EventBornTime: 1700000000123,
|
||||
EventType: "im.message.receive_v1",
|
||||
EventCorpID: "corp_x",
|
||||
EventUnifiedAppID: "app_y",
|
||||
Data: `{"message":{"message_id":"om_x","chat_id":"oc_y","content":"hi"},"sender":{"sender_id":{"open_id":"ou_z"}}}`,
|
||||
ReceivedAtUnixMS: 1700000000999,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Format
|
||||
fellback bool
|
||||
}{
|
||||
{"", FormatNDJSON, false},
|
||||
{"ndjson", FormatNDJSON, false},
|
||||
{"json", FormatJSON, false},
|
||||
{"pretty", FormatPretty, false},
|
||||
{"raw", FormatRaw, false},
|
||||
{"compact", FormatCompact, false},
|
||||
{"table", FormatNDJSON, true},
|
||||
{"csv", FormatNDJSON, true},
|
||||
{"yaml", FormatNDJSON, true}, // typo / unsupported → fallback
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, fb := NormalizeFormat(c.in)
|
||||
if got != c.want || fb != c.fellback {
|
||||
t.Errorf("NormalizeFormat(%q) = (%s, %v), want (%s, %v)", c.in, got, fb, c.want, c.fellback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDJSONFormatter_OneLinePerEvent(t *testing.T) {
|
||||
f, _ := NewFormatter(FormatNDJSON)
|
||||
out, err := f.Render(sampleEvent())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(string(out), "\n") {
|
||||
t.Fatal("ndjson output must end with \\n")
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("ndjson must be exactly one line, got %d: %s", len(lines), out)
|
||||
}
|
||||
// Must be valid JSON
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal([]byte(lines[0]), &ev); err != nil {
|
||||
t.Fatalf("not valid JSON: %v", err)
|
||||
}
|
||||
if ev.EventID != "ev_abc" {
|
||||
t.Errorf("round-trip lost EventID: %q", ev.EventID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyFormatter_MultilineIndented(t *testing.T) {
|
||||
f, _ := NewFormatter(FormatPretty)
|
||||
out, err := f.Render(sampleEvent())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if !strings.Contains(s, "\n ") {
|
||||
t.Fatal("pretty output should have 2-space indentation")
|
||||
}
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
t.Fatal("pretty output should end with \\n")
|
||||
}
|
||||
// Strip trailing newline and ensure round-trip works (still valid JSON).
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal([]byte(strings.TrimRight(s, "\n")), &ev); err != nil {
|
||||
t.Fatalf("pretty not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawFormatter_WritesDataVerbatim(t *testing.T) {
|
||||
f, _ := NewFormatter(FormatRaw)
|
||||
out, err := f.Render(transport.Event{Data: `{"foo":"bar"}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(out) != "{\"foo\":\"bar\"}\n" {
|
||||
t.Errorf("raw output = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawFormatter_PreservesExistingTrailingNewline(t *testing.T) {
|
||||
f, _ := NewFormatter(FormatRaw)
|
||||
out, _ := f.Render(transport.Event{Data: "already-ends-newline\n"})
|
||||
if string(out) != "already-ends-newline\n" {
|
||||
t.Errorf("raw should not double the trailing \\n, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawFormatter_EmptyDataYieldsBareNewline(t *testing.T) {
|
||||
f, _ := NewFormatter(FormatRaw)
|
||||
out, _ := f.Render(transport.Event{})
|
||||
if string(out) != "\n" {
|
||||
t.Errorf("empty Data → %q, want bare \\n", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactFormatter_DispatchesPerEventType(t *testing.T) {
|
||||
f, _ := NewFormatter(FormatCompact)
|
||||
out, err := f.Render(sampleEvent())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimRight(string(out), "\n")), &got); err != nil {
|
||||
t.Fatalf("compact not valid JSON: %v", err)
|
||||
}
|
||||
// IM message processor should have lifted message_id/chat_id/etc.
|
||||
if got["message_id"] != "om_x" || got["chat_id"] != "oc_y" {
|
||||
t.Fatalf("compact output missing lifted fields: %+v", got)
|
||||
}
|
||||
// Header field `type` must equal event_type.
|
||||
if got["type"] != "im.message.receive_v1" {
|
||||
t.Errorf("type = %v", got["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFormatter_RejectsUnknown(t *testing.T) {
|
||||
if _, err := NewFormatter(Format("nope")); err == nil {
|
||||
t.Fatal("expected error for unknown format")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time
|
||||
// event_types pushdown contract (plan §4 unsung superpower): a consumer
|
||||
// subscribing to "im.*" must NOT receive "approval.*" events even when
|
||||
// bus and source are flowing both. The filter happens at the Hub layer,
|
||||
// not at the consumer pipeline — saves IPC bytes for narrow consumers.
|
||||
func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
events := []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
{EventID: "2", EventType: "approval.task", Data: `{}`},
|
||||
{EventID: "3", EventType: "im.message.at_v1", Data: `{}`},
|
||||
{EventID: "4", EventType: "approval.instance.status_changed", Data: `{}`},
|
||||
{EventID: "5", EventType: "im.chat.member.user.added_v1", Data: `{}`},
|
||||
}
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, events)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var imBuf, approvalBuf bytes.Buffer
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// Consumer A: im.* only
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &imBuf,
|
||||
Stderr: io.Discard,
|
||||
EventTypes: []string{"im.*"},
|
||||
MaxEvents: 3, // 3 im events expected
|
||||
})
|
||||
}()
|
||||
// Consumer B: approval.* only
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &approvalBuf,
|
||||
Stderr: io.Discard,
|
||||
EventTypes: []string{"approval.*"},
|
||||
MaxEvents: 2, // 2 approval events expected
|
||||
})
|
||||
}()
|
||||
|
||||
// Give both consumers time to Hello + register.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
close(trigger)
|
||||
wg.Wait()
|
||||
|
||||
// Verify consumer A got exactly the 3 im.* events.
|
||||
imLines := nonEmptyLines(imBuf.String())
|
||||
if len(imLines) != 3 {
|
||||
t.Fatalf("im consumer got %d events, want 3:\n%s", len(imLines), imBuf.String())
|
||||
}
|
||||
for i, line := range imLines {
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Fatalf("im[%d] not valid JSON: %v", i, err)
|
||||
}
|
||||
if !strings.HasPrefix(ev.EventType, "im.") {
|
||||
t.Errorf("im consumer got non-im event: %s", ev.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify consumer B got exactly the 2 approval.* events.
|
||||
apprLines := nonEmptyLines(approvalBuf.String())
|
||||
if len(apprLines) != 2 {
|
||||
t.Fatalf("approval consumer got %d events, want 2:\n%s", len(apprLines), approvalBuf.String())
|
||||
}
|
||||
for i, line := range apprLines {
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Fatalf("approval[%d] not valid JSON: %v", i, err)
|
||||
}
|
||||
if !strings.HasPrefix(ev.EventType, "approval.") {
|
||||
t.Errorf("approval consumer got non-approval event: %s", ev.EventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_FilterRegexInAdditionToEventTypes verifies the
|
||||
// regex --filter is applied on top of --event-types (logical AND).
|
||||
// Both narrow the stream; the test confirms only events matching BOTH
|
||||
// surface to the consumer.
|
||||
func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
events := []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
{EventID: "2", EventType: "im.message.at_v1", Data: `{}`},
|
||||
{EventID: "3", EventType: "im.chat.member.user.added_v1", Data: `{}`},
|
||||
}
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, events)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
consumeDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(consumeDone)
|
||||
_ = Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &buf,
|
||||
Stderr: io.Discard,
|
||||
EventTypes: []string{"im.*"},
|
||||
Filter: `\.at_v1$`, // only at_v1 events
|
||||
MaxEvents: 1,
|
||||
})
|
||||
}()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(trigger)
|
||||
<-consumeDone
|
||||
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 event after im.* + .at_v1 regex, got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
var ev transport.Event
|
||||
_ = json.Unmarshal([]byte(lines[0]), &ev)
|
||||
if ev.EventType != "im.message.at_v1" {
|
||||
t.Errorf("got %q, want im.message.at_v1", ev.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_BusRestartConsumerReconnects verifies bus death + restart
|
||||
// scenario: a consumer dialing after the first bus died and was replaced
|
||||
// should connect to the fresh bus and receive new events. This proves
|
||||
// stale lock cleanup + fresh-bus startup flow work correctly.
|
||||
func TestIntegration_BusRestartConsumerReconnects(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||
run1Done := make(chan error, 1)
|
||||
go func() {
|
||||
run1Done <- bus.Run(ctx1, bus.Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
}()
|
||||
waitForSock(t, sock, 2*time.Second)
|
||||
// Verify first bus is up by dialing it briefly.
|
||||
conn, err := transport.Dial(sock)
|
||||
if err != nil {
|
||||
t.Fatalf("dial first bus: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Kill the first bus.
|
||||
cancel1()
|
||||
if err := <-run1Done; err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("first bus exited with: %v", err)
|
||||
}
|
||||
|
||||
// Start second bus on same workdir (stale lock should be reclaimed).
|
||||
ctx2, cancel2 := context.WithCancel(context.Background())
|
||||
defer cancel2()
|
||||
run2Done := make(chan error, 1)
|
||||
events := []dwsevent.RawEvent{
|
||||
{EventID: "post_restart", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
}
|
||||
trigger := make(chan struct{})
|
||||
src := &fakeSource{events: events, trigger: trigger}
|
||||
go func() {
|
||||
run2Done <- bus.Run(ctx2, bus.Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: src,
|
||||
})
|
||||
}()
|
||||
waitForSock(t, sock, 2*time.Second)
|
||||
|
||||
// Consumer dials the second bus and receives the new event.
|
||||
var buf bytes.Buffer
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
consumeDone <- Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &buf,
|
||||
Stderr: io.Discard,
|
||||
MaxEvents: 1,
|
||||
})
|
||||
}()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(trigger)
|
||||
if err := <-consumeDone; err != nil {
|
||||
t.Fatalf("consume on restarted bus: %v", err)
|
||||
}
|
||||
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 event after restart, got %d", len(lines))
|
||||
}
|
||||
var ev transport.Event
|
||||
_ = json.Unmarshal([]byte(lines[0]), &ev)
|
||||
if ev.EventID != "post_restart" {
|
||||
t.Errorf("EventID = %q, want post_restart", ev.EventID)
|
||||
}
|
||||
|
||||
cancel2()
|
||||
<-run2Done
|
||||
}
|
||||
|
||||
// TestIntegration_PipelineWithRouteAndOutputDir is the end-to-end version
|
||||
// of the unit pipeline tests: a real bus + a real consume.Run process
|
||||
// configured with --route and --output-dir. Verifies that matched
|
||||
// events land in route dirs and unmatched events in the fallback dir.
|
||||
func TestIntegration_PipelineWithRouteAndOutputDir(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
events := []dwsevent.RawEvent{
|
||||
{EventID: "im1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
{EventID: "ap1", EventType: "approval.task", Data: `{}`},
|
||||
{EventID: "im2", EventType: "im.message.at_v1", Data: `{}`},
|
||||
}
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, events)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
outputRoot := shortTempDir(t)
|
||||
imDir := filepath.Join(outputRoot, "im")
|
||||
defaultDir := filepath.Join(outputRoot, "default")
|
||||
routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir})
|
||||
|
||||
consumeDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(consumeDone)
|
||||
_ = Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
OutputDir: defaultDir,
|
||||
Routes: routes,
|
||||
MaxEvents: 3,
|
||||
})
|
||||
}()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(trigger)
|
||||
<-consumeDone
|
||||
|
||||
if entries, _ := os.ReadDir(imDir); len(entries) != 2 {
|
||||
t.Errorf("imDir got %d files, want 2 (im1+im2)", len(entries))
|
||||
}
|
||||
if entries, _ := os.ReadDir(defaultDir); len(entries) != 1 {
|
||||
t.Errorf("defaultDir got %d files, want 1 (ap1)", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyLines(s string) []string {
|
||||
parts := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitForSock polls for the unix socket file. Used by bus-restart tests
|
||||
// that bring up bus.Run twice in the same workdir.
|
||||
func waitForSock(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("socket %q did not appear within %s", path, timeout)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Pipeline is the consumer-side delivery chain: format → route → sink.
|
||||
// Each delivered event goes through formatting once; the routed sink then
|
||||
// dispatches the formatted bytes to either a route-specific directory or
|
||||
// the fallback (stdout/file).
|
||||
//
|
||||
// A Pipeline is bound to a single Config snapshot. Reconfiguring (changing
|
||||
// format / routes mid-stream) is out of scope for v1.
|
||||
type Pipeline struct {
|
||||
formatter Formatter
|
||||
sink Sink
|
||||
}
|
||||
|
||||
// NewPipeline builds a Pipeline for the given formatter and sink.
|
||||
func NewPipeline(formatter Formatter, sink Sink) *Pipeline {
|
||||
return &Pipeline{formatter: formatter, sink: sink}
|
||||
}
|
||||
|
||||
// Deliver renders ev with the configured formatter and hands the result
|
||||
// to the sink. Returns ErrPipeClosed (re-raised from the sink) when the
|
||||
// downstream stdout pipe closed; otherwise returns whatever formatting or
|
||||
// sink error surfaced.
|
||||
func (p *Pipeline) Deliver(ev transport.Event) error {
|
||||
body, err := p.formatter.Render(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.sink.Write(ev, body)
|
||||
}
|
||||
|
||||
// Close releases sink resources. Safe to call multiple times because
|
||||
// underlying Sink Close methods are idempotent.
|
||||
func (p *Pipeline) Close() error { return p.sink.Close() }
|
||||
|
||||
// BuildPipeline constructs a Pipeline from the cobra-side flag bundle. The
|
||||
// cobra command first parses --format / --output-dir / --route into the
|
||||
// derived inputs here so this function stays free of cobra dependencies.
|
||||
//
|
||||
// Sink selection rules (plan §3.1 输出约束):
|
||||
// - --route present → routed sink with per-rule dirs;
|
||||
// fallback is --output-dir if set, else stdout
|
||||
// - --output-dir only → file-per-event sink at the dir
|
||||
// - neither → stdout sink with stdoutW
|
||||
//
|
||||
// stdoutW is injected for tests (os.Stdout in production). When nil it
|
||||
// defaults to io.Discard so a misconfigured pipeline never writes to
|
||||
// the host process's actual stdout.
|
||||
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer) (*Pipeline, error) {
|
||||
fmter, err := NewFormatter(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stdoutW == nil {
|
||||
stdoutW = io.Discard
|
||||
}
|
||||
var fallback Sink
|
||||
if outputDir != "" {
|
||||
fallback = NewFileDirSink(outputDir)
|
||||
} else {
|
||||
fallback = NewStdoutSink(stdoutW)
|
||||
}
|
||||
if len(routes) > 0 {
|
||||
return NewPipeline(fmter, NewRoutedSink(NewRouter(routes), fallback)), nil
|
||||
}
|
||||
return NewPipeline(fmter, fallback), nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func TestPipeline_FormatNDJSONToStdout(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p, err := BuildPipeline(FormatNDJSON, "", nil, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = p.Deliver(transport.Event{Type: transport.FrameTypeEvent, EventID: "x", EventType: "y", Data: "{}"})
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 NDJSON lines, got %d", len(lines))
|
||||
}
|
||||
for _, line := range lines {
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Errorf("not valid JSON: %v\n%s", err, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_OutputDirFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p, err := BuildPipeline(FormatNDJSON, dir, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
_ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", ReceivedAtUnixMS: 100})
|
||||
_ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev2", ReceivedAtUnixMS: 200})
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 files in %s, got %d", dir, len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_RouteWithStdoutFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
imDir := filepath.Join(root, "im")
|
||||
var stdoutBuf bytes.Buffer
|
||||
routes, err := ParseRoutes([]string{`^im\.=dir:` + imDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := BuildPipeline(FormatNDJSON, "", routes, &stdoutBuf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
_ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", ReceivedAtUnixMS: 100})
|
||||
_ = p.Deliver(transport.Event{EventType: "approval.task", EventID: "ev2", ReceivedAtUnixMS: 200})
|
||||
|
||||
// im event routed to imDir
|
||||
if entries, _ := os.ReadDir(imDir); len(entries) != 1 {
|
||||
t.Errorf("expected 1 file in im dir, got %d", len(entries))
|
||||
}
|
||||
// approval event fell through to stdout
|
||||
if !strings.Contains(stdoutBuf.String(), `"event_id":"ev2"`) {
|
||||
t.Errorf("approval event missing from stdout:\n%s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_RouteWithOutputDirFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
imDir := filepath.Join(root, "im")
|
||||
defaultDir := filepath.Join(root, "default")
|
||||
routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir})
|
||||
p, _ := BuildPipeline(FormatNDJSON, defaultDir, routes, nil)
|
||||
defer p.Close()
|
||||
|
||||
_ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", ReceivedAtUnixMS: 100})
|
||||
_ = p.Deliver(transport.Event{EventType: "approval.task", EventID: "ev2", ReceivedAtUnixMS: 200})
|
||||
|
||||
if entries, _ := os.ReadDir(imDir); len(entries) != 1 {
|
||||
t.Errorf("im events should go to imDir, got %d files", len(entries))
|
||||
}
|
||||
if entries, _ := os.ReadDir(defaultDir); len(entries) != 1 {
|
||||
t.Errorf("unmatched events should go to default dir, got %d files", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_CompactFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p, _ := BuildPipeline(FormatCompact, "", nil, &buf)
|
||||
defer p.Close()
|
||||
_ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", Data: `{"message":{"chat_id":"oc_x","message_id":"om_y","content":"hi"}}`})
|
||||
line := strings.TrimRight(buf.String(), "\n")
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &out); err != nil {
|
||||
t.Fatalf("compact output not valid JSON: %v", err)
|
||||
}
|
||||
if out["chat_id"] != "oc_x" {
|
||||
t.Errorf("compact missed chat_id: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Route describes one --route rule. The CLI accepts the wire form
|
||||
// `<regex>=dir:<path>`; ParseRoute compiles regex once at startup so the
|
||||
// hot path is just a Match.
|
||||
//
|
||||
// Pattern matches against event.EventType (NOT the whole event JSON).
|
||||
// The first matching rule in CLI order wins; unmatched events fall through
|
||||
// to the default sink (stdout or --output-dir).
|
||||
type Route struct {
|
||||
Pattern *regexp.Regexp
|
||||
Dir string
|
||||
// Raw is the original CLI spec; preserved for status / debug output.
|
||||
Raw string
|
||||
}
|
||||
|
||||
// ParseRoute parses one `<regex>=dir:<path>` spec. Returns a typed error
|
||||
// for bad inputs so the CLI can render a clear "did you mean" message.
|
||||
//
|
||||
// Wire grammar:
|
||||
//
|
||||
// spec = regex "=dir:" path
|
||||
// regex = any chars except literal "=" (use \= to escape) (v1: no escape)
|
||||
// path = any string (no validation here; sink validates at write time)
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "^im\\.message=dir:./im/"
|
||||
// "^approval\\.=dir:./approval/"
|
||||
func ParseRoute(spec string) (Route, error) {
|
||||
if spec == "" {
|
||||
return Route{}, fmt.Errorf("consume: empty route spec")
|
||||
}
|
||||
// v1 grammar is intentionally rigid: split on the first "=dir:".
|
||||
// Earlier proposals supported other sink kinds (=file: / =mcp:), but
|
||||
// the cobra layer rejects those — keep parsing tight here too.
|
||||
const sep = "=dir:"
|
||||
idx := strings.Index(spec, sep)
|
||||
if idx <= 0 || idx == len(spec)-len(sep) {
|
||||
return Route{}, fmt.Errorf("consume: route spec must be '<regex>=dir:<path>', got %q", spec)
|
||||
}
|
||||
pattern := spec[:idx]
|
||||
path := spec[idx+len(sep):]
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return Route{}, fmt.Errorf("consume: route regex %q: %w", pattern, err)
|
||||
}
|
||||
if path == "" {
|
||||
return Route{}, fmt.Errorf("consume: route path is empty in %q", spec)
|
||||
}
|
||||
return Route{Pattern: re, Dir: path, Raw: spec}, nil
|
||||
}
|
||||
|
||||
// ParseRoutes parses many specs in CLI order. On any parse failure returns
|
||||
// the partial parse so far and the error — the caller decides whether to
|
||||
// continue. (The cobra layer treats any parse error as fatal validation.)
|
||||
func ParseRoutes(specs []string) ([]Route, error) {
|
||||
out := make([]Route, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
r, err := ParseRoute(s)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Router decides which sink an event goes to. Match returns the directory
|
||||
// of the first matching Route, or empty string when no rule matches (fall
|
||||
// through to default sink).
|
||||
type Router struct {
|
||||
rules []Route
|
||||
}
|
||||
|
||||
// NewRouter constructs a router from pre-parsed rules.
|
||||
func NewRouter(rules []Route) *Router { return &Router{rules: rules} }
|
||||
|
||||
// Match returns the destination directory for the event's type, or "" if
|
||||
// no rule matched. Iterates rules in CLI order (first match wins).
|
||||
func (r *Router) Match(ev transport.Event) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
for _, rule := range r.rules {
|
||||
if rule.Pattern.MatchString(ev.EventType) {
|
||||
return rule.Dir
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Rules returns the parsed routes for status / debug output. Caller MUST
|
||||
// NOT mutate the returned slice.
|
||||
func (r *Router) Rules() []Route {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.rules
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func TestParseRoute_Valid(t *testing.T) {
|
||||
r, err := ParseRoute(`^im\.message=dir:./im/`)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRoute: %v", err)
|
||||
}
|
||||
if !r.Pattern.MatchString("im.message.receive_v1") {
|
||||
t.Error("regex did not match expected event type")
|
||||
}
|
||||
if r.Dir != "./im/" {
|
||||
t.Errorf("Dir = %q", r.Dir)
|
||||
}
|
||||
if r.Raw != `^im\.message=dir:./im/` {
|
||||
t.Errorf("Raw = %q", r.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoute_Invalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"", // empty
|
||||
"no-separator", // no =dir:
|
||||
"=dir:./x/", // empty regex
|
||||
"^im=dir:", // empty path
|
||||
"(unclosed=dir:./x/", // invalid regex
|
||||
"=dir:", // both empty
|
||||
}
|
||||
for _, in := range cases {
|
||||
if _, err := ParseRoute(in); err == nil {
|
||||
t.Errorf("ParseRoute(%q) should error", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoutes_StopsOnFirstError(t *testing.T) {
|
||||
good := `^im=dir:./im/`
|
||||
bad := `(unclosed=dir:./x/`
|
||||
out, err := ParseRoutes([]string{good, bad, good})
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Errorf("partial parse should have 1 entry, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_FirstMatchWins(t *testing.T) {
|
||||
routes, err := ParseRoutes([]string{
|
||||
`^im\.message=dir:./im/`,
|
||||
`^im\.=dir:./other-im/`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRouter(routes)
|
||||
|
||||
// First rule should win for im.message.* events.
|
||||
got := r.Match(transport.Event{EventType: "im.message.receive_v1"})
|
||||
if got != "./im/" {
|
||||
t.Errorf("Match im.message = %q, want ./im/", got)
|
||||
}
|
||||
// Second rule covers im.chat.*
|
||||
got = r.Match(transport.Event{EventType: "im.chat.member.bot.added_v1"})
|
||||
if got != "./other-im/" {
|
||||
t.Errorf("Match im.chat = %q, want ./other-im/", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_NoMatchReturnsEmpty(t *testing.T) {
|
||||
routes, _ := ParseRoutes([]string{`^im\.=dir:./im/`})
|
||||
r := NewRouter(routes)
|
||||
if got := r.Match(transport.Event{EventType: "approval.task"}); got != "" {
|
||||
t.Fatalf("no-match should return empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_NilSafe(t *testing.T) {
|
||||
var r *Router
|
||||
if got := r.Match(transport.Event{EventType: "x"}); got != "" {
|
||||
t.Fatalf("nil Router.Match should return empty, got %q", got)
|
||||
}
|
||||
if rules := r.Rules(); rules != nil {
|
||||
t.Fatalf("nil Router.Rules should return nil, got %v", rules)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Config holds everything Run needs. Built by the cobra command handler
|
||||
// (P5) from flag values + strict resolver output.
|
||||
type Config struct {
|
||||
// WorkDir is the bus working directory:
|
||||
// <ConfigDir>/events/<edition>/<source_kind>/<identity_hash>/
|
||||
WorkDir string
|
||||
// IPCEndpoint is the Unix socket path / Windows pipe name. Caller
|
||||
// computes from WorkDir on Unix, from edition+hash on Windows.
|
||||
IPCEndpoint string
|
||||
// ClientID is forwarded to busctl.Spawn so it can pass --client-id
|
||||
// when forking _bus.
|
||||
ClientID string
|
||||
// SpawnExtraArgs are forwarded to the hidden _bus process when consume.Run
|
||||
// needs to start a daemon. Used for source-mode options that must be
|
||||
// reproduced in the child process, including portal ticket mode and
|
||||
// personal_stream.
|
||||
SpawnExtraArgs []string
|
||||
|
||||
// EventTypes / Filter / Compact are forwarded to the bus via Hello
|
||||
// for server-side pushdown filtering.
|
||||
EventTypes []string
|
||||
Filter string
|
||||
SubscribeID string
|
||||
Compact bool
|
||||
|
||||
// MaxEvents: stop after receiving this many events. 0 = no limit.
|
||||
MaxEvents int
|
||||
|
||||
// EventKey is the single event key being consumed. Used only for the
|
||||
// AI-subprocess contract stderr lines (`[event] ready event_key=...`).
|
||||
// Empty → the key is omitted from the marker.
|
||||
EventKey string
|
||||
|
||||
// Duration: wall-clock budget for the consume run. After this elapses,
|
||||
// Run returns nil (clean exit, exit code 0). Zero = no limit.
|
||||
//
|
||||
// Note: this is event-consume specific and intentionally NOT named
|
||||
// "Timeout" — global dws --timeout is HTTP request timeout (int
|
||||
// seconds) which would collide if reused. See plan §1 决策
|
||||
// "事件运行时长 flag 不复用全局 --timeout".
|
||||
Duration time.Duration
|
||||
|
||||
// DryRun, when true, prints the resolved configuration to Stderr and
|
||||
// returns nil without dialing the bus. Used by the cobra layer to
|
||||
// preview configuration with `--dry-run` (plan §3.1).
|
||||
DryRun bool
|
||||
|
||||
// Foreground hint, passed through to status output but otherwise has
|
||||
// no behavioural effect inside consume.Run — the cobra layer decides
|
||||
// whether to call this Run or to bus.Run directly when --foreground
|
||||
// is set.
|
||||
Foreground bool
|
||||
// Force, like Foreground, is informational at this layer. The cobra
|
||||
// layer enforces the "--force requires --foreground" rule before
|
||||
// calling Run.
|
||||
Force bool
|
||||
|
||||
// --- Output / Sink config (P4) ---
|
||||
// Format controls the per-event output shape (ndjson/json/pretty/raw/
|
||||
// compact). The cobra layer maps --format string → Format via
|
||||
// NormalizeFormat; an empty Format here defaults to NDJSON inside
|
||||
// BuildPipeline.
|
||||
Format Format
|
||||
// OutputDir, if non-empty, switches the fallback sink from stdout to
|
||||
// "file per event" under this directory.
|
||||
OutputDir string
|
||||
// Routes are pre-parsed --route specs. Empty = no routing.
|
||||
Routes []Route
|
||||
|
||||
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
|
||||
// graceful shutdown (reason: signal). This wires the AI-subprocess
|
||||
// contract — a parent closes stdin to stop the consumer. The cobra
|
||||
// layer passes os.Stdin; tests inject a controllable reader. nil →
|
||||
// stdin is not watched (backward-compatible default for callers that
|
||||
// do not opt in).
|
||||
//
|
||||
// Note: `< /dev/null` EOFs immediately and exits at once. To stay
|
||||
// resident feed a never-EOF stdin (`< <(tail -f /dev/null)`) or run
|
||||
// bounded (--max-events / --duration).
|
||||
Stdin io.Reader
|
||||
|
||||
// Stdout sink; nil → os.Stdout. Injected for tests.
|
||||
Stdout io.Writer
|
||||
// Stderr sink for status lines (HelloAck info, bye reason); nil → os.Stderr.
|
||||
// Set to io.Discard when --quiet is in effect.
|
||||
Stderr io.Writer
|
||||
|
||||
// Quiet suppresses stderr status writes (the HelloAck / bye banners).
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
// Run dials the bus (forking one if necessary), sends Hello, and writes
|
||||
// each received Event frame as one NDJSON line to stdout. Blocks until
|
||||
// ctx is cancelled, MaxEvents is reached, the bus sends Bye, or the
|
||||
// stream is interrupted.
|
||||
//
|
||||
// Returns nil on graceful exits (ctx done, max-events reached, bye
|
||||
// received, stdout pipe closed). Returns a non-nil error only for
|
||||
// connection / protocol failures.
|
||||
func Run(ctx context.Context, cfg Config) error {
|
||||
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
|
||||
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
|
||||
}
|
||||
if cfg.Stdout == nil {
|
||||
cfg.Stdout = os.Stdout
|
||||
}
|
||||
if cfg.Stderr == nil {
|
||||
cfg.Stderr = os.Stderr
|
||||
}
|
||||
if cfg.Quiet {
|
||||
cfg.Stderr = io.Discard
|
||||
}
|
||||
if cfg.Format == "" {
|
||||
cfg.Format = FormatNDJSON
|
||||
}
|
||||
|
||||
// --dry-run: print resolved config, return without dialing.
|
||||
if cfg.DryRun {
|
||||
PrintDryRun(cfg.Stderr, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Distinguish the exit cause for the contract's `exited` line:
|
||||
// duration deadline → timeout
|
||||
// parentCtx cancelled → signal (SIGTERM/SIGINT)
|
||||
// runCtx-only cancelled → signal (stdin EOF)
|
||||
parentCtx := ctx
|
||||
|
||||
// --duration: layer a deadline on top of caller-provided ctx. Run
|
||||
// returns nil on deadline (clean exit) rather than surfacing the
|
||||
// context.DeadlineExceeded as an error to the user.
|
||||
var timeoutCtx context.Context
|
||||
if cfg.Duration > 0 {
|
||||
var cancel context.CancelFunc
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
defer cancel()
|
||||
ctx = timeoutCtx
|
||||
}
|
||||
|
||||
// runCtx lets the stdin watcher and the read loop share one cancel
|
||||
// without disturbing the timeout/parent contexts (so we can still tell
|
||||
// stdin-EOF from a real signal from a duration deadline).
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
ctx = runCtx
|
||||
|
||||
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume: build pipeline: %w", err)
|
||||
}
|
||||
defer pipeline.Close()
|
||||
|
||||
conn, err := busctl.Discover(busctl.DiscoverConfig{
|
||||
WorkDir: cfg.WorkDir,
|
||||
IPCEndpoint: cfg.IPCEndpoint,
|
||||
ClientID: cfg.ClientID,
|
||||
SpawnExtraArgs: cfg.SpawnExtraArgs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume: discover bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Ensure the conn closes when ctx cancels so blocked Read returns.
|
||||
closeOnContext(ctx, conn)
|
||||
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
|
||||
hello := transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
EventTypes: cfg.EventTypes,
|
||||
Filter: cfg.Filter,
|
||||
SubscribeID: cfg.SubscribeID,
|
||||
Compact: cfg.Compact,
|
||||
}
|
||||
if err := w.WriteJSON(hello); err != nil {
|
||||
return fmt.Errorf("consume: write hello: %w", err)
|
||||
}
|
||||
|
||||
var ack transport.HelloAck
|
||||
if err := r.ReadJSON(&ack); err != nil {
|
||||
return fmt.Errorf("consume: read hello_ack: %w", err)
|
||||
}
|
||||
if ack.Type != transport.FrameTypeHelloAck {
|
||||
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
|
||||
}
|
||||
if !cfg.Quiet {
|
||||
// Contract: a fixed ready line on stderr BEFORE any stdout event.
|
||||
// Parents block on stderr until this appears, then read stdout.
|
||||
if cfg.EventKey != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
|
||||
} else {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
|
||||
}
|
||||
// Secondary diagnostic line (source/state/idle); not part of the
|
||||
// ready contract.
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
}
|
||||
|
||||
// Watch stdin for EOF → graceful shutdown (AI-subprocess contract).
|
||||
// The cobra layer only sets Stdin when the watcher should arm (a
|
||||
// pipe-style, unbounded run); tests inject it directly.
|
||||
if cfg.Stdin != nil {
|
||||
go watchStdinEOF(runCtx, cfg.Stdin, cfg.Stderr, cancelRun)
|
||||
}
|
||||
|
||||
// Exit-reason contract: on a graceful exit emit a final stderr line
|
||||
// [event] exited — received N event(s) in Xs (reason: <r>)
|
||||
// Error returns leave reason empty → no `exited` line (an `Error:` line
|
||||
// is printed by the cobra layer instead).
|
||||
received := 0
|
||||
start := time.Now()
|
||||
reason := ""
|
||||
defer func() {
|
||||
if !cfg.Quiet && reason != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] exited — received %d event(s) in %s (reason: %s)\n",
|
||||
received, time.Since(start).Round(time.Millisecond), reason)
|
||||
}
|
||||
}()
|
||||
|
||||
// classifyCancel maps a context-cancelled exit to a contract reason.
|
||||
classifyCancel := func() string {
|
||||
if timeoutCtx != nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
|
||||
return "timeout"
|
||||
}
|
||||
return "signal"
|
||||
}
|
||||
|
||||
for {
|
||||
raw, err := r.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
reason = "bus_shutdown" // peer closed cleanly
|
||||
return nil
|
||||
}
|
||||
if isCtxCancelled(ctx) {
|
||||
reason = classifyCancel()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: read frame: %w", err)
|
||||
}
|
||||
typ, err := transport.PeekType(raw)
|
||||
if err != nil {
|
||||
// Malformed frame; skip and continue.
|
||||
continue
|
||||
}
|
||||
switch typ {
|
||||
case transport.FrameTypeEvent:
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal(raw, &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := pipeline.Deliver(ev); err != nil {
|
||||
if errors.Is(err, ErrPipeClosed) {
|
||||
// Downstream stdout consumer closed; exit cleanly.
|
||||
_ = w.WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "signal"
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: deliver event: %w", err)
|
||||
}
|
||||
received++
|
||||
if cfg.MaxEvents > 0 && received >= cfg.MaxEvents {
|
||||
_ = w.WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "limit"
|
||||
return nil
|
||||
}
|
||||
case transport.FrameTypeBye:
|
||||
var bye transport.Bye
|
||||
_ = json.Unmarshal(raw, &bye)
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
|
||||
}
|
||||
reason = "bus_shutdown"
|
||||
return nil
|
||||
case transport.FrameTypeSourceState:
|
||||
if !cfg.Quiet {
|
||||
var s transport.SourceState
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
fmt.Fprintf(cfg.Stderr, "source state: %s (source=%s, attempt=%d)\n", s.State, s.StateSource, s.Attempt)
|
||||
}
|
||||
case transport.FrameTypeHeartbeat:
|
||||
// silent
|
||||
default:
|
||||
// future frame types: ignored for forward compat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closeOnContext spawns a goroutine that closes conn when ctx is done.
|
||||
// This unblocks any pending Read on conn so the main loop can return.
|
||||
func closeOnContext(ctx context.Context, conn net.Conn) {
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
func isCtxCancelled(ctx context.Context) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// watchStdinEOF reads and discards stdin until EOF (or any read error),
|
||||
// then prints a self-explaining diagnostic and calls onEOF to trigger a
|
||||
// graceful shutdown. This implements the AI-subprocess contract: a parent
|
||||
// closes the child's stdin to stop it. It returns early if ctx is
|
||||
// cancelled first (the run ended for another reason), so it does not fire
|
||||
// a spurious shutdown. errOut is io.Discard under --quiet.
|
||||
func watchStdinEOF(ctx context.Context, r io.Reader, errOut io.Writer, onEOF func()) {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
if _, err := r.Read(buf); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Run already ending; do not attribute this to stdin.
|
||||
default:
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as an exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--duration for a bounded run, "+
|
||||
"keep stdin open (`< <(tail -f /dev/null)` in a script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
onEOF()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Discard any data and keep reading until EOF.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func skipOnWindows(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix socket")
|
||||
}
|
||||
}
|
||||
|
||||
func shortTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("/tmp", "dws-consume-")
|
||||
if err != nil {
|
||||
t.Fatalf("mktemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// fakeSource mirrors the one in bus tests; reproduced here to keep the
|
||||
// integration test self-contained.
|
||||
type fakeSource struct {
|
||||
events []dwsevent.RawEvent
|
||||
trigger <-chan struct{}
|
||||
}
|
||||
|
||||
func (f *fakeSource) Start(ctx context.Context, emit dwsevent.EmitFn) error {
|
||||
if f.trigger != nil {
|
||||
select {
|
||||
case <-f.trigger:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
for i := range f.events {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
ev := f.events[i]
|
||||
ev.ReceivedAt = time.Now().UTC()
|
||||
emit(&ev)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// bringUpBus starts a bus.Run in a goroutine and waits for its socket.
|
||||
// Returns (workDir, sockPath, cancelFunc, runDone, fakeSource trigger).
|
||||
func bringUpBus(t *testing.T, events []dwsevent.RawEvent) (string, string, context.CancelFunc, <-chan error, chan struct{}) {
|
||||
t.Helper()
|
||||
dir := shortTempDir(t)
|
||||
sock := filepath.Join(dir, "bus.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
trigger := make(chan struct{})
|
||||
src := &fakeSource{events: events, trigger: trigger}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- bus.Run(ctx, bus.Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Edition: "open",
|
||||
Source: src,
|
||||
})
|
||||
}()
|
||||
|
||||
// Wait for socket.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(sock); err == nil {
|
||||
return dir, sock, cancel, done, trigger
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
t.Fatalf("bus socket did not appear")
|
||||
return "", "", nil, nil, nil
|
||||
}
|
||||
|
||||
// dialOnlyDiscover is a Discover-impl-bypass: tests don't want consume.Run
|
||||
// to exec a real dws binary, so we sidestep by pre-bringing-up the bus and
|
||||
// letting Discover succeed on its first dial attempt. No Spawn is required.
|
||||
|
||||
func TestRun_StdoutNDJSON(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{"text":"hi"}`},
|
||||
{EventID: "2", EventType: "im.message.at_v1", Data: `{"at":1}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
// Trigger source emission after we've started consume (otherwise events
|
||||
// race ahead of consumer registration).
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(trigger)
|
||||
}()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer consumeCancel()
|
||||
err := Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
EventTypes: []string{"im.*"},
|
||||
MaxEvents: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// Verify NDJSON: each non-empty line is a valid Event JSON.
|
||||
lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 NDJSON lines, got %d:\n%s", len(lines), stdout.String())
|
||||
}
|
||||
for i, line := range lines {
|
||||
var ev transport.Event
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Errorf("line %d not valid JSON: %v\n%s", i, err, line)
|
||||
}
|
||||
if ev.Type != transport.FrameTypeEvent {
|
||||
t.Errorf("line %d type = %s, want event", i, ev.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Stderr should carry the standardized ready marker.
|
||||
if !strings.Contains(stderr.String(), "[event] ready ") {
|
||||
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_QuietSuppressesStderr(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer consumeCancel()
|
||||
err := Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Quiet: true,
|
||||
MaxEvents: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("--quiet should suppress all stderr; got: %s", stderr.String())
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Error("stdout should still contain the NDJSON event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_CtxCancelReturnsCleanly(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
ctx, consumeCancel := context.WithCancel(context.Background())
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
consumeDone <- Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
})
|
||||
}()
|
||||
// Let consume connect.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
consumeCancel()
|
||||
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run returned %v, want nil or canceled", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_MaxEventsZeroIsUnlimited(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "x", Data: `{}`},
|
||||
{EventID: "2", EventType: "x", Data: `{}`},
|
||||
{EventID: "3", EventType: "x", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
ctx, consumeCancel := context.WithCancel(context.Background())
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
consumeDone <- Run(ctx, Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: &stdout,
|
||||
Stderr: io.Discard,
|
||||
MaxEvents: 0, // unlimited
|
||||
})
|
||||
}()
|
||||
// Wait for consume to dial + Hello (otherwise events fire before
|
||||
// consumer registers and the Hub drops them silently — no consumer
|
||||
// to deliver to).
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(trigger)
|
||||
// Let all 3 events flow.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
consumeCancel()
|
||||
<-consumeDone
|
||||
|
||||
lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 events with MaxEvents=0, got %d:\n%s", len(lines), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_MultipleConsumersOneBus exercises the daemon's multi-consumer
|
||||
// fan-out via the real consume.Run path. Both consumers should receive
|
||||
// every matching event independently.
|
||||
func TestRun_MultipleConsumersOneBus(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
{EventID: "2", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
bufs := make([]*bytes.Buffer, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
i := i
|
||||
bufs[i] = &bytes.Buffer{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = Run(context.Background(), Config{
|
||||
WorkDir: dir,
|
||||
IPCEndpoint: sock,
|
||||
ClientID: "ding_test",
|
||||
Stdout: bufs[i],
|
||||
Stderr: io.Discard,
|
||||
MaxEvents: 2,
|
||||
})
|
||||
}()
|
||||
}
|
||||
// Both consumers should be Hello'd before trigger.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
close(trigger)
|
||||
|
||||
wg.Wait()
|
||||
for i, buf := range bufs {
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("consumer %d: got %d lines, want 2:\n%s", i, len(lines), buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI subprocess contract tests (event-subprocess-contract.md) ---
|
||||
|
||||
// T1a/T1b: the ready marker carries event_key and precedes the first
|
||||
// stdout event.
|
||||
func TestRun_ReadyMarkerContract(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
EventKey: "im.message.receive_v1", MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
// T1a: ready line with event_key.
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1") {
|
||||
t.Errorf("missing ready marker with event_key:\n%s", stderr.String())
|
||||
}
|
||||
// T1b: ready is on stderr, the event is on stdout — the ready line is
|
||||
// emitted before Deliver writes the first stdout line. Assert stdout
|
||||
// got exactly the event and stderr got ready before exited.
|
||||
rIdx := strings.Index(stderr.String(), "[event] ready")
|
||||
xIdx := strings.Index(stderr.String(), "[event] exited")
|
||||
if rIdx < 0 || xIdx < 0 || rIdx > xIdx {
|
||||
t.Errorf("ready must precede exited:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "im.message.receive_v1") {
|
||||
t.Errorf("stdout missing the event:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3a: --max-events exit → reason=limit.
|
||||
func TestRun_ExitReasonLimit(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: limit") {
|
||||
t.Errorf("expected reason: limit:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3b: --duration deadline with no events → reason=timeout, exit clean.
|
||||
func TestRun_ExitReasonTimeout(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
// Bring up bus but never trigger emission → consume blocks until the
|
||||
// duration deadline fires.
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Duration: 300 * time.Millisecond,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on duration: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: timeout") {
|
||||
t.Errorf("expected reason: timeout:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3c: parent ctx cancel (signal) → reason=signal.
|
||||
func TestRun_ExitReasonSignal(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(250 * time.Millisecond); cc() }()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on signal: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T2c: closing stdin triggers a graceful shutdown (reason=signal), with no
|
||||
// --max-events / --duration set.
|
||||
func TestRun_StdinEOFShutsDown(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() { time.Sleep(250 * time.Millisecond); _ = pw.Close() }() // EOF
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Stdin: pr,
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Run should exit clean on stdin EOF: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not exit after stdin EOF")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal on stdin EOF:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3d: a startup failure returns non-nil and emits NO `exited` line (the
|
||||
// cobra layer prints an Error: line instead).
|
||||
func TestRun_FailureHasNoExitedLine(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
var stderr bytes.Buffer
|
||||
// Missing WorkDir/IPCEndpoint/ClientID → immediate config error, before
|
||||
// the exit-reason machinery is armed.
|
||||
err := Run(context.Background(), Config{Stderr: &stderr})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required config")
|
||||
}
|
||||
if strings.Contains(stderr.String(), "[event] exited") {
|
||||
t.Errorf("failure must not print an exited line:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// isBrokenPipe reports whether err originates from a closed downstream
|
||||
// pipe (typical: `dws event consume | head -1`). On Unix this surfaces as
|
||||
// EPIPE; the Go runtime by default also raises SIGPIPE which would kill
|
||||
// the process, but Go programs ignore SIGPIPE on stdio writes (since
|
||||
// Go 1.x). We just need to detect EPIPE and exit cleanly.
|
||||
func isBrokenPipe(err error) bool {
|
||||
return errors.Is(err, syscall.EPIPE)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// isBrokenPipe reports whether err is the Windows equivalent of EPIPE
|
||||
// (ERROR_BROKEN_PIPE / ERROR_NO_DATA) surfaced when a downstream pipe
|
||||
// consumer closes its read end.
|
||||
func isBrokenPipe(err error) bool {
|
||||
return errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_NO_DATA)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Sink is what a Pipeline writes formatted event bytes to. Implementations
|
||||
// take both the event (for filename derivation in file sinks) and the
|
||||
// already-formatted bytes (so the same event can be rendered with
|
||||
// different formats per call without re-running the formatter inside the
|
||||
// sink).
|
||||
type Sink interface {
|
||||
// Write places one event on the sink. Returns an error for IO failures;
|
||||
// returns ErrPipeClosed when the downstream consumer closed the pipe
|
||||
// (typical pattern: `dws event consume | head -1`).
|
||||
Write(ev transport.Event, formatted []byte) error
|
||||
// Close releases sink-owned resources. Idempotent.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ErrPipeClosed is returned by stdout-style sinks when the downstream
|
||||
// reader closed its end (SIGPIPE / EPIPE on Unix, ERROR_BROKEN_PIPE on
|
||||
// Windows). The pipeline catches this sentinel and exits cleanly without
|
||||
// surfacing it as a fatal error.
|
||||
var ErrPipeClosed = errors.New("sink: downstream pipe closed")
|
||||
|
||||
// NewStdoutSink wraps the given writer (typically os.Stdout) in a Sink
|
||||
// that writes formatted bytes verbatim. Detects broken-pipe on the host
|
||||
// platform and returns ErrPipeClosed so the caller can exit code 0.
|
||||
func NewStdoutSink(w io.Writer) Sink { return &stdoutSink{w: w} }
|
||||
|
||||
type stdoutSink struct{ w io.Writer }
|
||||
|
||||
func (s *stdoutSink) Write(_ transport.Event, formatted []byte) error {
|
||||
_, err := s.w.Write(formatted)
|
||||
if err != nil && isBrokenPipe(err) {
|
||||
return ErrPipeClosed
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (s *stdoutSink) Close() error { return nil }
|
||||
|
||||
// NewFileDirSink returns a sink that writes each event to its own file
|
||||
// under dir, naming files `{type}_{id}_{ts}.json`. The directory is
|
||||
// mkdir'd on first write so callers don't have to ensure it themselves.
|
||||
//
|
||||
// Filename pieces are sanitised: characters that would escape the
|
||||
// directory (path separators) or break shell globbing are replaced with
|
||||
// '_'. `ts` is the ReceivedAtUnixMS (or current time if zero) so two
|
||||
// events with the same id (re-delivery, dedup-defeated edge cases) don't
|
||||
// collide.
|
||||
func NewFileDirSink(dir string) Sink { return &fileDirSink{dir: dir} }
|
||||
|
||||
type fileDirSink struct{ dir string }
|
||||
|
||||
func (s *fileDirSink) Write(ev transport.Event, formatted []byte) error {
|
||||
if err := os.MkdirAll(s.dir, 0o700); err != nil {
|
||||
return fmt.Errorf("sink: mkdir %s: %w", s.dir, err)
|
||||
}
|
||||
name := buildFilename(ev)
|
||||
full := filepath.Join(s.dir, name)
|
||||
return atomicWrite(full, formatted)
|
||||
}
|
||||
func (s *fileDirSink) Close() error { return nil }
|
||||
|
||||
// NewRoutedSink composes a Router with per-route dir sinks plus a fallback.
|
||||
// On each Write, Router.Match decides the target dir; if non-empty, the
|
||||
// event is written there; otherwise the fallback sink handles it. The
|
||||
// fallback is typically NewStdoutSink (default) or NewFileDirSink
|
||||
// (--output-dir mode).
|
||||
func NewRoutedSink(router *Router, fallback Sink) Sink {
|
||||
return &routedSink{router: router, fallback: fallback}
|
||||
}
|
||||
|
||||
type routedSink struct {
|
||||
router *Router
|
||||
fallback Sink
|
||||
}
|
||||
|
||||
func (s *routedSink) Write(ev transport.Event, formatted []byte) error {
|
||||
if dir := s.router.Match(ev); dir != "" {
|
||||
return NewFileDirSink(dir).Write(ev, formatted)
|
||||
}
|
||||
return s.fallback.Write(ev, formatted)
|
||||
}
|
||||
func (s *routedSink) Close() error { return s.fallback.Close() }
|
||||
|
||||
// buildFilename produces `{type}_{id}_{ts}.json`. All three pieces are
|
||||
// sanitised to be safe filesystem path segments — see safePart.
|
||||
func buildFilename(ev transport.Event) string {
|
||||
typ := safePart(ev.EventType)
|
||||
if typ == "" {
|
||||
typ = "unknown"
|
||||
}
|
||||
id := safePart(ev.EventID)
|
||||
if id == "" {
|
||||
id = "no-id"
|
||||
}
|
||||
ts := ev.ReceivedAtUnixMS
|
||||
if ts == 0 {
|
||||
ts = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
return fmt.Sprintf("%s_%s_%d.json", typ, id, ts)
|
||||
}
|
||||
|
||||
// safePart strips path separators, NULs, and leading/trailing whitespace
|
||||
// from a filename piece. Replaces unsafe chars with '_' instead of
|
||||
// dropping them so different inputs don't collide.
|
||||
//
|
||||
// We DO allow dots and dashes (common in event types like "im.message.at_v1");
|
||||
// we just reject path separators and parent-directory traversal.
|
||||
func safePart(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '/', '\\', 0, ':':
|
||||
b.WriteRune('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
// We intentionally do NOT collapse ".." sequences. After path
|
||||
// separator replacement (above), a bare ".." in the middle of a
|
||||
// filename cannot perform parent-directory traversal because there
|
||||
// is no separator to anchor it against. The final filename is
|
||||
// joined into a known-safe directory with filepath.Join which itself
|
||||
// will clean any traversal that does sneak through.
|
||||
}
|
||||
|
||||
// atomicWrite writes content to path via tmp-file + rename, so a concurrent
|
||||
// reader either sees the previous version or the new version — never a
|
||||
// half-written file.
|
||||
func atomicWrite(path string, content []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
return fmt.Errorf("sink: write tmp %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("sink: rename to %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func TestStdoutSink_WritesVerbatim(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStdoutSink(&buf)
|
||||
if err := s.Write(transport.Event{}, []byte("line one\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Write(transport.Event{}, []byte("line two\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != "line one\nline two\n" {
|
||||
t.Fatalf("got: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDirSink_WritesPerEvent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileDirSink(dir)
|
||||
ev := transport.Event{
|
||||
EventType: "im.message.receive_v1",
|
||||
EventID: "ev_abc",
|
||||
ReceivedAtUnixMS: 1700000000123,
|
||||
}
|
||||
body := []byte(`{"hello":"world"}`)
|
||||
if err := s.Write(ev, body); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(entries))
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, entries[0].Name()))
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Fatalf("body mismatch: %q", got)
|
||||
}
|
||||
if entries[0].Name() != "im.message.receive_v1_ev_abc_1700000000123.json" {
|
||||
t.Errorf("filename = %q", entries[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDirSink_MkdirAutomatic(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "events")
|
||||
s := NewFileDirSink(dir)
|
||||
err := s.Write(transport.Event{EventType: "x", EventID: "1", ReceivedAtUnixMS: 1}, []byte("ok"))
|
||||
if err != nil {
|
||||
t.Fatalf("Write should auto-mkdir: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("dir not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDirSink_AtomicWriteNoTmpLeft(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileDirSink(dir)
|
||||
_ = s.Write(transport.Event{EventType: "x", EventID: "1", ReceivedAtUnixMS: 1}, []byte("ok"))
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("tmp file leaked: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePart_StripsPathSeparators(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"normal", "normal"},
|
||||
{"im.message.receive_v1", "im.message.receive_v1"}, // dots OK
|
||||
{"a/b", "a_b"},
|
||||
{"a\\b", "a_b"},
|
||||
{"a:b", "a_b"},
|
||||
{"../../etc/passwd", ".._.._etc_passwd"}, // slashes → _; dots preserved (safe — no separator anchor)
|
||||
{" spaces ", "spaces"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := safePart(c.in); got != c.want {
|
||||
t.Errorf("safePart(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilename_DefaultsForMissingFields(t *testing.T) {
|
||||
got := buildFilename(transport.Event{})
|
||||
// Should contain unknown_no-id_<ts>.json
|
||||
if !strings.HasPrefix(got, "unknown_no-id_") || !strings.HasSuffix(got, ".json") {
|
||||
t.Fatalf("default filename shape unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutedSink_MatchedGoesToDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
imDir := filepath.Join(tmp, "im")
|
||||
var fallback bytes.Buffer
|
||||
routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir})
|
||||
rs := NewRoutedSink(NewRouter(routes), NewStdoutSink(&fallback))
|
||||
|
||||
// IM event → file in imDir
|
||||
if err := rs.Write(transport.Event{EventType: "im.message.receive_v1", EventID: "x", ReceivedAtUnixMS: 1}, []byte("body")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(imDir); len(entries) != 1 {
|
||||
t.Errorf("expected 1 file in %s, got %d", imDir, len(entries))
|
||||
}
|
||||
if fallback.Len() != 0 {
|
||||
t.Errorf("fallback should be empty for matched route")
|
||||
}
|
||||
|
||||
// Non-IM event → fallback (stdout)
|
||||
_ = rs.Write(transport.Event{EventType: "approval.task", EventID: "y", ReceivedAtUnixMS: 2}, []byte("body2\n"))
|
||||
if fallback.String() != "body2\n" {
|
||||
t.Errorf("fallback got: %q", fallback.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidationError represents a flag-level user error. It wraps a clear,
|
||||
// actionable message — the cobra command layer surfaces it to the user
|
||||
// with exit code 2 (validation error).
|
||||
type ValidationError struct{ Msg string }
|
||||
|
||||
func (e *ValidationError) Error() string { return e.Msg }
|
||||
|
||||
// validation sentinels (cobra layer uses errors.Is to set the exit code).
|
||||
var (
|
||||
// ErrForceRequiresForeground is the plan §3.1 contract: --force only
|
||||
// makes sense in foreground mode where the bus runs in the current
|
||||
// process (then --force skips the single-instance lock so a second
|
||||
// foreground bus can co-exist for a brief debug window). Outside of
|
||||
// --foreground, --force would silently produce two daemons writing
|
||||
// to the same socket — refuse upfront.
|
||||
ErrForceRequiresForeground = &ValidationError{
|
||||
Msg: "--force is only meaningful with --foreground (in daemon mode it would produce multiple bus instances; cloud events would be randomly split across connections). To restart the bus: dws event stop && dws event consume",
|
||||
}
|
||||
|
||||
// ErrJSONFormatRequiresBounded is the plan §3.1 contract: --format
|
||||
// json renders each event as a multi-line JSON object suitable for
|
||||
// human inspection. With an unbounded stream the output mixes events
|
||||
// without delimiters. Force the user to bound the run.
|
||||
ErrJSONFormatRequiresBounded = &ValidationError{
|
||||
Msg: "--format json requires --max-events or --duration (an unbounded JSON stream is not parseable). Use --format ndjson for unbounded streams.",
|
||||
}
|
||||
)
|
||||
|
||||
// ValidateConfig performs all pre-flight validation that does not require
|
||||
// disk / network I/O. Returns a *ValidationError for any rule violation;
|
||||
// returns nil if the cfg is launchable. The cobra layer calls this BEFORE
|
||||
// calling Run so the user gets clear errors at parse time.
|
||||
//
|
||||
// Rules implemented:
|
||||
// 1. WorkDir / IPCEndpoint / ClientID non-empty
|
||||
// 2. --force requires --foreground (plan §3.1)
|
||||
// 3. --format json requires --max-events OR --duration (bounded)
|
||||
// 4. Routes already pre-parsed (any parse error is reported by ParseRoutes)
|
||||
// 5. --output-dir conflict with global --output (caller-supplied flag —
|
||||
// we expose ValidateNoOutputConflict separately because global -o is
|
||||
// a cobra-layer concern)
|
||||
//
|
||||
// Rules NOT enforced here (deferred to caller / Run):
|
||||
// - Credentials presence (auth.ResolveAppCredentialsStrict already
|
||||
// reports a typed error)
|
||||
// - bus availability (busctl.Discover handles)
|
||||
func ValidateConfig(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.WorkDir) == "" {
|
||||
return &ValidationError{Msg: "consume: WorkDir is required"}
|
||||
}
|
||||
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
|
||||
return &ValidationError{Msg: "consume: IPCEndpoint is required"}
|
||||
}
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
return &ValidationError{Msg: "consume: ClientID is required"}
|
||||
}
|
||||
if cfg.Force && !cfg.Foreground {
|
||||
return ErrForceRequiresForeground
|
||||
}
|
||||
if cfg.Format == FormatJSON && cfg.MaxEvents <= 0 && cfg.Duration <= 0 {
|
||||
return ErrJSONFormatRequiresBounded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateNoOutputConflict ensures --output-dir / --route (event-stream
|
||||
// sinks) are not combined with the dws global hidden -o/--output flag
|
||||
// (request-output to file). The cobra layer reads the global output flag
|
||||
// from inherited flags and passes its value here; an empty globalOutput
|
||||
// means the flag was unset.
|
||||
func ValidateNoOutputConflict(cfg Config, globalOutput string) error {
|
||||
if globalOutput == "" {
|
||||
return nil
|
||||
}
|
||||
if cfg.OutputDir != "" || len(cfg.Routes) > 0 {
|
||||
return &ValidationError{
|
||||
Msg: fmt.Sprintf("--output-dir/--route cannot be combined with global -o/--output=%q (event stream sinks are mutually exclusive with single-file output capture)", globalOutput),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValidationError reports whether err is a flag-level user error.
|
||||
// Cobra command handlers use this to map validation errors to exit code 2.
|
||||
func IsValidationError(err error) bool {
|
||||
var v *ValidationError
|
||||
return errors.As(err, &v)
|
||||
}
|
||||
|
||||
// PrintDryRun writes the resolved configuration to w in a single
|
||||
// human-readable block. Called by Run when cfg.DryRun is true. Format
|
||||
// avoids JSON so users can `dws event consume --dry-run | head` cleanly.
|
||||
//
|
||||
// Secret-bearing fields are never present in Config (credentials never
|
||||
// reach this layer), so no redaction is required here.
|
||||
func PrintDryRun(w io.Writer, cfg Config) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "dws event consume — dry run (no bus connection will be made)")
|
||||
fmt.Fprintf(w, " client_id : %s\n", cfg.ClientID)
|
||||
fmt.Fprintf(w, " workdir : %s\n", cfg.WorkDir)
|
||||
fmt.Fprintf(w, " ipc_endpoint : %s\n", cfg.IPCEndpoint)
|
||||
if len(cfg.EventTypes) > 0 {
|
||||
fmt.Fprintf(w, " event_types : %s\n", strings.Join(cfg.EventTypes, ","))
|
||||
} else {
|
||||
fmt.Fprintln(w, " event_types : (catch-all)")
|
||||
}
|
||||
if cfg.Filter != "" {
|
||||
fmt.Fprintf(w, " filter : %s\n", cfg.Filter)
|
||||
}
|
||||
fmt.Fprintf(w, " format : %s\n", cfg.Format)
|
||||
if cfg.OutputDir != "" {
|
||||
fmt.Fprintf(w, " output_dir : %s\n", cfg.OutputDir)
|
||||
}
|
||||
for i, r := range cfg.Routes {
|
||||
fmt.Fprintf(w, " route[%d] : %s\n", i, r.Raw)
|
||||
}
|
||||
if cfg.MaxEvents > 0 {
|
||||
fmt.Fprintf(w, " max_events : %d\n", cfg.MaxEvents)
|
||||
}
|
||||
if cfg.Duration > 0 {
|
||||
fmt.Fprintf(w, " duration : %s\n", cfg.Duration)
|
||||
}
|
||||
fmt.Fprintf(w, " compact : %v\n", cfg.Compact)
|
||||
fmt.Fprintf(w, " quiet : %v\n", cfg.Quiet)
|
||||
fmt.Fprintf(w, " foreground : %v\n", cfg.Foreground)
|
||||
fmt.Fprintf(w, " force : %v\n", cfg.Force)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func base() Config {
|
||||
return Config{
|
||||
WorkDir: "/tmp/x",
|
||||
IPCEndpoint: "/tmp/x/bus.sock",
|
||||
ClientID: "ding_abc",
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Happy(t *testing.T) {
|
||||
if err := ValidateConfig(base()); err != nil {
|
||||
t.Fatalf("baseline should be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RequiredFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*Config)
|
||||
}{
|
||||
{"empty WorkDir", func(c *Config) { c.WorkDir = "" }},
|
||||
{"whitespace WorkDir", func(c *Config) { c.WorkDir = " " }},
|
||||
{"empty IPCEndpoint", func(c *Config) { c.IPCEndpoint = "" }},
|
||||
{"empty ClientID", func(c *Config) { c.ClientID = "" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := base()
|
||||
tc.mut(&c)
|
||||
err := ValidateConfig(c)
|
||||
if !IsValidationError(err) {
|
||||
t.Fatalf("expected ValidationError, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ForceRequiresForeground(t *testing.T) {
|
||||
c := base()
|
||||
c.Force = true
|
||||
c.Foreground = false
|
||||
err := ValidateConfig(c)
|
||||
if !errors.Is(err, ErrForceRequiresForeground) {
|
||||
t.Fatalf("err = %v, want ErrForceRequiresForeground", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "event stop && dws event consume") {
|
||||
t.Errorf("error message must include the recovery hint, got: %s", err.Error())
|
||||
}
|
||||
|
||||
// With --foreground it's fine.
|
||||
c.Foreground = true
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
t.Fatalf("--force + --foreground should be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_FormatJSONRequiresBounded(t *testing.T) {
|
||||
c := base()
|
||||
c.Format = FormatJSON
|
||||
c.MaxEvents = 0
|
||||
c.Duration = 0
|
||||
err := ValidateConfig(c)
|
||||
if !errors.Is(err, ErrJSONFormatRequiresBounded) {
|
||||
t.Fatalf("err = %v, want ErrJSONFormatRequiresBounded", err)
|
||||
}
|
||||
|
||||
// With --max-events it passes.
|
||||
c.MaxEvents = 10
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
t.Fatalf("--format json + --max-events should be valid: %v", err)
|
||||
}
|
||||
c.MaxEvents = 0
|
||||
c.Duration = 30 * time.Second
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
t.Fatalf("--format json + --duration should be valid: %v", err)
|
||||
}
|
||||
|
||||
// NDJSON has no such requirement.
|
||||
c.Format = FormatNDJSON
|
||||
c.MaxEvents = 0
|
||||
c.Duration = 0
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
t.Fatalf("ndjson unbounded should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNoOutputConflict(t *testing.T) {
|
||||
c := base()
|
||||
c.OutputDir = "/tmp/events"
|
||||
if err := ValidateNoOutputConflict(c, ""); err != nil {
|
||||
t.Fatalf("no global -o → ok, got %v", err)
|
||||
}
|
||||
if err := ValidateNoOutputConflict(c, "/tmp/out.json"); !IsValidationError(err) {
|
||||
t.Fatalf("--output-dir + global -o should be ValidationError, got %v", err)
|
||||
}
|
||||
|
||||
c2 := base()
|
||||
c2.Routes, _ = ParseRoutes([]string{`^im=dir:./im/`})
|
||||
if err := ValidateNoOutputConflict(c2, "/tmp/out.json"); !IsValidationError(err) {
|
||||
t.Fatalf("--route + global -o should be ValidationError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_NilWriterSafe(t *testing.T) {
|
||||
// Must not panic.
|
||||
PrintDryRun(nil, base())
|
||||
}
|
||||
|
||||
func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
c := base()
|
||||
c.EventTypes = []string{"im.*", "approval.*"}
|
||||
c.Filter = "^im\\."
|
||||
c.Format = FormatCompact
|
||||
c.OutputDir = "/tmp/events"
|
||||
c.Routes, _ = ParseRoutes([]string{`^im\.=dir:/tmp/im/`})
|
||||
c.MaxEvents = 5
|
||||
c.Duration = 30 * time.Second
|
||||
c.Compact = true
|
||||
c.Quiet = true
|
||||
c.Foreground = true
|
||||
c.Force = true
|
||||
|
||||
PrintDryRun(&buf, c)
|
||||
out := buf.String()
|
||||
wants := []string{
|
||||
"client_id", "workdir", "ipc_endpoint", "im.*,approval.*",
|
||||
"^im\\.", "compact", "/tmp/events", "route[0]", "max_events : 5",
|
||||
"duration", "true",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("dry-run missing %q in output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_CatchAllWhenEventTypesEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
PrintDryRun(&buf, base())
|
||||
if !strings.Contains(buf.String(), "(catch-all)") {
|
||||
t.Errorf("expected '(catch-all)' for empty event_types:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package dedup implements a fixed-capacity LRU set used by the bus to
|
||||
// suppress duplicate events that the DingTalk Stream SDK redelivers on
|
||||
// reconnect (see plan invariant #2). The set stores only keys, never values,
|
||||
// and is safe for concurrent use by multiple goroutines.
|
||||
package dedup
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultCapacity is the default LRU size when LRU is constructed without an
|
||||
// explicit capacity. 8192 is sized to absorb a typical reconnect-storm window
|
||||
// (~5 min × 30 events/s) while staying memory-cheap (~256 KB at 32 bytes/key).
|
||||
const DefaultCapacity = 8192
|
||||
|
||||
// LRU is a fixed-capacity LRU set of string keys. Zero value is not usable;
|
||||
// call New or NewWithCapacity.
|
||||
type LRU struct {
|
||||
mu sync.Mutex
|
||||
cap int
|
||||
keys map[string]*list.Element
|
||||
eviction *list.List // back = newest, front = oldest
|
||||
}
|
||||
|
||||
// New returns an LRU with DefaultCapacity.
|
||||
func New() *LRU { return NewWithCapacity(DefaultCapacity) }
|
||||
|
||||
// NewWithCapacity returns an LRU sized to hold up to cap keys. cap must be > 0.
|
||||
func NewWithCapacity(cap int) *LRU {
|
||||
if cap <= 0 {
|
||||
cap = DefaultCapacity
|
||||
}
|
||||
return &LRU{
|
||||
cap: cap,
|
||||
keys: make(map[string]*list.Element, cap),
|
||||
eviction: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Seen reports whether key was already present and inserts it if not. The
|
||||
// return value is true when the caller should treat the event as a duplicate
|
||||
// (drop it) and false when this is the first occurrence.
|
||||
//
|
||||
// Empty keys are never considered duplicates and are not stored — callers
|
||||
// without a stable identifier should use RawEvent.DedupKey() which falls back
|
||||
// to a content hash.
|
||||
func (l *LRU) Seen(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if el, ok := l.keys[key]; ok {
|
||||
l.eviction.MoveToBack(el)
|
||||
return true
|
||||
}
|
||||
|
||||
if len(l.keys) >= l.cap {
|
||||
oldest := l.eviction.Front()
|
||||
if oldest != nil {
|
||||
delete(l.keys, oldest.Value.(string))
|
||||
l.eviction.Remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
el := l.eviction.PushBack(key)
|
||||
l.keys[key] = el
|
||||
return false
|
||||
}
|
||||
|
||||
// Len returns the current number of stored keys.
|
||||
func (l *LRU) Len() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.keys)
|
||||
}
|
||||
|
||||
// Cap returns the configured capacity.
|
||||
func (l *LRU) Cap() int { return l.cap }
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dedup
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLRU_FirstSeenIsFalse(t *testing.T) {
|
||||
l := New()
|
||||
if l.Seen("a") {
|
||||
t.Fatal("first occurrence should not be reported as seen")
|
||||
}
|
||||
if !l.Seen("a") {
|
||||
t.Fatal("second occurrence should be reported as seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRU_EmptyKeyNeverSeen(t *testing.T) {
|
||||
l := New()
|
||||
if l.Seen("") {
|
||||
t.Fatal("empty key must never be reported as seen")
|
||||
}
|
||||
if l.Seen("") {
|
||||
t.Fatal("empty key must never be stored / reported as seen on second call either")
|
||||
}
|
||||
if l.Len() != 0 {
|
||||
t.Fatalf("empty key must not be stored, got Len=%d", l.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRU_EvictsOldestAtCapacity(t *testing.T) {
|
||||
l := NewWithCapacity(3)
|
||||
for _, k := range []string{"a", "b", "c"} {
|
||||
if l.Seen(k) {
|
||||
t.Fatalf("unexpected seen for %s", k)
|
||||
}
|
||||
}
|
||||
// d inserts → a should be evicted
|
||||
if l.Seen("d") {
|
||||
t.Fatal("d is new, should not be seen")
|
||||
}
|
||||
if l.Seen("a") {
|
||||
t.Fatal("a should have been evicted; second insert returns not-seen")
|
||||
}
|
||||
// Now b should be the oldest. After re-querying "c" (refreshes c),
|
||||
// inserting "e" should evict b not c.
|
||||
if !l.Seen("c") {
|
||||
t.Fatal("c is still in set, should be seen")
|
||||
}
|
||||
if l.Seen("e") {
|
||||
t.Fatal("e is new")
|
||||
}
|
||||
if l.Seen("b") {
|
||||
t.Fatal("b should have been evicted by e (c was just refreshed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRU_LenAndCap(t *testing.T) {
|
||||
l := NewWithCapacity(5)
|
||||
if l.Cap() != 5 {
|
||||
t.Fatalf("Cap = %d, want 5", l.Cap())
|
||||
}
|
||||
if l.Len() != 0 {
|
||||
t.Fatalf("initial Len = %d, want 0", l.Len())
|
||||
}
|
||||
_ = l.Seen("x")
|
||||
_ = l.Seen("y")
|
||||
if l.Len() != 2 {
|
||||
t.Fatalf("after 2 inserts Len = %d, want 2", l.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRU_ZeroCapacityUsesDefault(t *testing.T) {
|
||||
l := NewWithCapacity(0)
|
||||
if l.Cap() != DefaultCapacity {
|
||||
t.Fatalf("zero cap should use DefaultCapacity, got %d", l.Cap())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRU_ConcurrentSafety(t *testing.T) {
|
||||
l := NewWithCapacity(1000)
|
||||
const N = 200
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
i := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = l.Seen(strconv.Itoa(i))
|
||||
_ = l.Seen(strconv.Itoa(i)) // duplicate
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if l.Len() != N {
|
||||
t.Fatalf("Len after concurrent insert = %d, want %d", l.Len(), N)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package event implements the DingTalk Stream event subscription pipeline for
|
||||
// dws. The architecture is a single-cloud-connection bus daemon (one per
|
||||
// ClientID) plus N local consumer processes communicating over Unix socket /
|
||||
// Windows Named Pipe. The bus keeps one cloud connection per identity while
|
||||
// exposing observable connection state, per-event-type metrics, and Hello-time
|
||||
// filter pushdown to local consumers.
|
||||
//
|
||||
// Package layout:
|
||||
//
|
||||
// event/ // top-level types (RawEvent, EmitFn, hash helpers)
|
||||
// event/dedup/ // event_id LRU dedup
|
||||
// event/registry/ // catch-all event types + compact processor registry
|
||||
// event/source/ // wrap dingtalk-stream-sdk-go + connection state machine
|
||||
// event/bus/ // daemon loop, hub, metrics, lockfile, meta
|
||||
// event/transport/ // UDS/Pipe abstraction, frame protocol
|
||||
// event/busctl/ // discover, spawn, stop helpers
|
||||
// event/consume/ // consumer-side pipeline, formatter, router, sink
|
||||
// event/lock/ // cross-platform flock primitive (Unix flock / Windows LockFileEx)
|
||||
// event/process/ // cross-platform process-alive check (Unix signal 0 / Windows OpenProcess)
|
||||
//
|
||||
// See plans/2026-05-28_event_capability_v1.plan.md for the full design,
|
||||
// invariants, and protocol.
|
||||
package event
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// MaxUnixSocketPath returns the longest Unix socket path accepted by
|
||||
// bind/connect on this OS (Go rejects longer names with EINVAL before
|
||||
// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the
|
||||
// BSDs and 108 on Linux; the usable budget is one less.
|
||||
func MaxUnixSocketPath() int {
|
||||
if runtime.GOOS == "linux" {
|
||||
return 107
|
||||
}
|
||||
return 103
|
||||
}
|
||||
|
||||
// IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe
|
||||
// name on Windows, otherwise bus.sock inside workDir.
|
||||
//
|
||||
// The canonical Unix location is <workDir>/bus.sock, but workDir derives
|
||||
// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes
|
||||
// use ~/.dwssb/sandboxes/<name>/config/...). When the canonical path would
|
||||
// exceed the OS sun_path limit, the socket falls back to a short
|
||||
// deterministic path under os.TempDir keyed by a hash of workDir, so every
|
||||
// process (consume parent, forked _bus child, status/stop tooling) that
|
||||
// derives the endpoint from the same workDir agrees on the location.
|
||||
// bus.lock / bus.meta / bus.log always stay in workDir — only the socket
|
||||
// moves.
|
||||
//
|
||||
// This is the single source of truth for endpoint derivation; the cobra
|
||||
// layer and busctl must not re-implement the shape.
|
||||
func IPCEndpoint(workDir, editionName string, sourceKind SourceKind, identityHash string) string {
|
||||
if sourceKind == "" {
|
||||
sourceKind = SourceKindAppStream
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash
|
||||
}
|
||||
sock := filepath.Join(workDir, "bus.sock")
|
||||
if len(sock) <= MaxUnixSocketPath() {
|
||||
return sock
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) {
|
||||
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
|
||||
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
|
||||
want := filepath.Join(workDir, "bus.sock")
|
||||
if got != want {
|
||||
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
|
||||
// Mirrors the dwssb sandbox layout that produced a 111-byte socket
|
||||
// path — over macOS's 103-byte usable sun_path budget.
|
||||
workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52"
|
||||
got := IPCEndpoint(workDir, "open", SourceKindPersonalStream, "3928ce0fb4860a52")
|
||||
if strings.HasPrefix(got, workDir) {
|
||||
t.Fatalf("IPCEndpoint = %q, want fallback outside workDir", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, os.TempDir()) {
|
||||
t.Fatalf("IPCEndpoint = %q, want fallback under os.TempDir %q", got, os.TempDir())
|
||||
}
|
||||
if len(got) > MaxUnixSocketPath() {
|
||||
t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), MaxUnixSocketPath(), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
|
||||
long := strings.Repeat("x", 120)
|
||||
a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
|
||||
b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
|
||||
c := IPCEndpoint("/base/"+long+"/two", "open", SourceKindPersonalStream, "hash")
|
||||
if a != b {
|
||||
t.Fatalf("same workDir produced different endpoints: %q vs %q", a, b)
|
||||
}
|
||||
if a == c {
|
||||
t.Fatalf("different workDirs collided on endpoint %q", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package lock implements a cross-platform exclusive file lock primitive used
|
||||
// by the bus daemon to enforce the "single bus per ClientID" invariant
|
||||
// (plan invariant #3). The primitive is intentionally tiny — it acquires and
|
||||
// releases a non-blocking exclusive lock on an opened file handle, with no
|
||||
// knowledge of PID files or business semantics. Higher layers (bus/lockfile.go)
|
||||
// combine this primitive with PID content read/write to provide the full
|
||||
// single-file bus.lock design.
|
||||
//
|
||||
// Unix: syscall.Flock(LOCK_EX|LOCK_NB).
|
||||
// Windows: windows.LockFileEx with LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY.
|
||||
package lock
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package lock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// ErrBusy indicates the lock is currently held by another process. Callers
|
||||
// distinguish "actually busy" (another live bus) from "lock file
|
||||
// inaccessible" (FS error) by checking for this sentinel.
|
||||
var ErrBusy = errors.New("lock: file is held by another process")
|
||||
|
||||
// File represents a held exclusive file lock. Close releases the lock and
|
||||
// closes the underlying file handle. A zero File is not usable.
|
||||
type File struct {
|
||||
f *os.File
|
||||
}
|
||||
|
||||
// TryAcquire opens path (creating it if absent, mode 0600) and attempts to
|
||||
// take an exclusive non-blocking lock. Returns ErrBusy when the lock is held
|
||||
// by another process; any other error wraps the underlying I/O failure.
|
||||
//
|
||||
// The directory containing path must already exist; callers should mkdir
|
||||
// with pkg/config.DirPerm beforehand.
|
||||
func TryAcquire(path string) (*File, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, config.FilePerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock: open %s: %w", path, err)
|
||||
}
|
||||
if err := lockFile(f); err != nil {
|
||||
_ = f.Close()
|
||||
if isBusy(err) {
|
||||
return nil, ErrBusy
|
||||
}
|
||||
return nil, fmt.Errorf("lock: flock %s: %w", path, err)
|
||||
}
|
||||
return &File{f: f}, nil
|
||||
}
|
||||
|
||||
// File returns the underlying *os.File so callers can Read/Write content
|
||||
// while holding the lock. The handle MUST NOT be closed by the caller —
|
||||
// use Close on the lock File instead.
|
||||
func (l *File) File() *os.File { return l.f }
|
||||
|
||||
// Path returns the file path the lock is held on.
|
||||
func (l *File) Path() string {
|
||||
if l == nil || l.f == nil {
|
||||
return ""
|
||||
}
|
||||
return l.f.Name()
|
||||
}
|
||||
|
||||
// Close releases the lock and closes the file handle. Safe to call on a nil
|
||||
// receiver. Subsequent calls are no-ops.
|
||||
func (l *File) Close() error {
|
||||
if l == nil || l.f == nil {
|
||||
return nil
|
||||
}
|
||||
unlockFile(l.f)
|
||||
err := l.f.Close()
|
||||
l.f = nil
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package lock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTryAcquire_FirstCallerWins(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.lock")
|
||||
l, err := TryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first TryAcquire: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
if l.Path() != path {
|
||||
t.Fatalf("Path() = %q, want %q", l.Path(), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryAcquire_SecondCallerGetsBusy(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.lock")
|
||||
l1, err := TryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first TryAcquire: %v", err)
|
||||
}
|
||||
defer l1.Close()
|
||||
|
||||
l2, err := TryAcquire(path)
|
||||
if !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("second TryAcquire: err = %v, want ErrBusy", err)
|
||||
}
|
||||
if l2 != nil {
|
||||
t.Fatal("on ErrBusy the returned lock must be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryAcquire_ReleasedLockIsReacquirable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.lock")
|
||||
l1, err := TryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first TryAcquire: %v", err)
|
||||
}
|
||||
if err := l1.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
l2, err := TryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("re-acquire after close: %v", err)
|
||||
}
|
||||
defer l2.Close()
|
||||
}
|
||||
|
||||
func TestTryAcquire_ContentReadWriteWhileHeld(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bus.lock")
|
||||
l, err := TryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
// Write PID-like content through the underlying handle
|
||||
const pid = "12345\n"
|
||||
if _, err := l.File().WriteString(pid); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// Rewind and read back
|
||||
if _, err := l.File().Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("seek: %v", err)
|
||||
}
|
||||
buf := make([]byte, len(pid))
|
||||
if _, err := io.ReadFull(l.File(), buf); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(buf) != pid {
|
||||
t.Fatalf("read back = %q, want %q", buf, pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_NilSafe(t *testing.T) {
|
||||
var l *File
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("nil Close should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package lock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockFile(f *os.File) error {
|
||||
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
}
|
||||
|
||||
func unlockFile(f *os.File) {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
|
||||
func isBusy(err error) bool {
|
||||
// Linux returns EWOULDBLOCK (==EAGAIN); macOS returns EWOULDBLOCK too.
|
||||
return errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user