Compare commits

..
Author SHA1 Message Date
修雨 68a1c7a49b fix(connect): remove message type attachment allowlist 2026-07-14 18:17:41 +08:00
修雨 9a6e60c643 fix(connect): recover forwarded unknown attachments 2026-07-14 17:46:45 +08:00
修雨 99a0952505 fix(connect): deliver forwarded attachments to all agents 2026-07-14 15:45:49 +08:00
修雨 f30aaeba91 fix(connect): forward complex message payloads 2026-07-14 11:02:37 +08:00
SCzheng 43798de088 fix(connect): preserve rich text image attachments (#606) 2026-07-13 22:13:56 +08:00
Ari c4d8987f6c feat(event): AI-subprocess contract and cobra-synthesized schema (#609)
Align `dws event consume` with an AI-subprocess contract an orchestrator
can drive deterministically, and expose a machine-readable input schema
for event commands via `dws schema`.

Subprocess contract:
- Fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>`;
  block on it instead of sleeping.
- Final `[event] exited — received N event(s) in Xs (reason: ...)` line;
  exit 0 on controlled exit, non-zero and no exited line on failure.
- stdin-EOF graceful shutdown, armed only for a pipe stdin on an
  unbounded run; an interactive TTY and `< /dev/null` never trigger it.
- Ownership-based subscription cleanup: a run-created subscription is
  unsubscribed on any clean exit while a --subscribe-id-reused one is
  kept (--ephemeral still forces cleanup). Forward --profile to the
  detached bus so non-default orgs resolve the right credentials, and
  surface the child's real startup error over the ready pipe.

Schema:
- `dws schema "event consume"` (or event.consume) synthesizes a flat,
  machine-readable schema from the command's cobra flags:
  {description, path, source:"cobra",
  parameters{<flag>:{type,required,description,default?}}} plus an
  `arguments` array for positional inputs. Intermediate nodes list
  subcommands. Inherited global flags and hidden internal flags are
  excluded so the schema describes just that command.
- Reusable registry (cobraSchemaRoots); event is the first consumer and
  more command trees can opt in without further wiring.

Docs: mono + dingtalk-event skills document the contract and the two
schema surfaces; design notes in docs/event-subprocess-contract.md.
2026-07-13 22:08:22 +08:00
Aemeathand张卓澎 657d2c25e3 feat: sync open product command capabilities (#608)
Co-authored-by: 张卓澎 <zhuopeng.zzp@alibaba-inc.com>
2026-07-13 17:21:47 +08:00
johnand玉澜 9f7107b6bb ci: sign macOS releases with Apple Developer ID (#605)
* fix release upload of signed macOS assets

* ci: sign macOS releases with Developer ID

* fix release publication atomicity

* harden Developer ID release verification

* fix: run release script tests in CI

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
2026-07-13 17:10:23 +08:00
修雨 bfd48b6a71 fix: preserve macOS auth across keychain mode changes (#597)
* fix: preserve auth across macOS keychain modes

* docs(auth): clarify per-profile recovery

* fix(auth): add safe macOS keychain migration

* ci: add native Windows auth coverage

* ci: scope Windows checks to auth paths

* fix(auth): address keychain review boundaries
2026-07-13 15:27:39 +08:00
修雨 390b6115bf fix(connect): harden daemon restart lifecycle (#599) 2026-07-12 23:11:08 +08:00
修雨 fc9acb9007 fix: align smart category args and runtime network errors (#591)
* fix: align smart category args and remove eval fixtures

* fix: classify runtime network failures

* fix: validate smart category inputs
2026-07-10 21:40:36 +08:00
aa6abc5ed6 feat(event): add personal event subscriptions (#589)
* dws event

* fix consume fail

* test: add stream ticket injection probe

* feat: add portal ticket stream mode

* user event

* fix: allow portal ticket normal without app secret

* event

* user event

* eventType filter

* refactor(event): 优化IPC端点路径处理和改进相关测试

- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖

* opt

* event skill

* default value

* install script event

* fix: remove subscribe id event fanout filter

* fix(personal): 修正指定发送人消息描述错误

* more im event

* filter subId

* fix: align personal event schema with stream payload

* fix: avoid duplicate app helper name

* feat: simplify personal event schemas

* feat: simplify event schema output

* docs: refine dingtalk event skill references

* feat: align personal event consume flags

* fix event stop and status visibility

* hide app event public entrypoints

* hide incomplete personal sender event

* remove external event reference comments

* chore(event): prepare official release

* fix(event): harden personal stream lifecycle

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
2026-07-10 17:54:43 +08:00
修雨 ea6fd16d11 chore(changelog): prepare v1.0.51 stable (#595) 2026-07-10 17:35:12 +08:00
修雨 4c43108bdf sync wukong hardcoded command additions
sync wukong hardcoded command additions

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:57 +08:00
修雨 5e9a920b76 fix(connect): prevent agent mid-turn blocking
fix(connect): prevent agent mid-turn blocking

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:35 +08:00
xuanandshangguanxuan.sgx 36b0528d90 fix: default pat chmod grants to permanent (#584)
* fix: default pat chmod grants to permanent

* docs(changelog): note pat chmod permanent default

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:45 +08:00
xuanandshangguanxuan.sgx a2201b4ab4 test(pat): remove external example auth URL trigger (#583)
* test(pat): remove external example auth URL trigger

* test(app): prevent browser launches during tests

* test(pat): build auth URL fixture as JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:40 +08:00
修雨 181cdf4a03 Merge pull request #578 from LastdianXuan/fix/dek-readonly-keychain-status
fix: keep keychain reads side-effect free
2026-07-08 18:46:33 +08:00
修雨 818b8b29e3 chore(changelog): add v1.0.50 release notes (#580)
Covers PR #575 (global --jq/--fields honored on product commands,
skill --dry-run preview, sheet batch-style JSON mode, skill docs
alignment) and the exported cmdutil leaf-merge / provenance helpers.
2026-07-08 14:49:25 +08:00
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00
张卓澎 fd6bbd928e fix: keep keychain reads side-effect free 2026-07-08 11:23:44 +08:00
张卓澎 67417d3fb1 fix: diagnose macos keychain auth failures 2026-07-08 11:23:44 +08:00
修雨 91dfc8b926 fix: export command merge helpers 2026-07-08 10:47:31 +08:00
319 changed files with 33704 additions and 1019 deletions
+40
View File
@@ -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
+115 -9
View File
@@ -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 开关消费。
+3
View File
@@ -42,5 +42,8 @@ dws.zip
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
/auto-test/
/eval-runs/
/.qoder/
.vercel
.env*
+3 -1
View File
@@ -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
+53
View File
@@ -6,6 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### 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
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
### Fixed
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
### Changed
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
## [1.0.49] - 2026-07-08
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
+58 -3
View File
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -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>
@@ -331,7 +341,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
@@ -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>
+58 -3
View File
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -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>
@@ -328,7 +338,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
@@ -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>
+115
View File
@@ -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 -2
View File
@@ -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
+4 -2
View File
@@ -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=
+115 -5
View File
@@ -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) {
@@ -446,6 +448,7 @@ func newAuthStatusCommand() *cobra.Command {
authenticated := false
refreshed := false
var tokenData *authpkg.TokenData
var statusErr error
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
if data, err := provider.Status(); err == nil {
@@ -468,12 +471,15 @@ func newAuthStatusCommand() *cobra.Command {
if authStatusAuthenticated(tokenData) {
authenticated = true
}
} else {
statusErr = err
}
diagnostic := authStatusDiagnosticFromError(statusErr)
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic)
}
// Default table output
@@ -503,7 +509,10 @@ func newAuthStatusCommand() *cobra.Command {
}
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
if !edition.Get().IsEmbedded {
if diagnostic != nil {
fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message)
fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint)
} else if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
}
}
@@ -514,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())
@@ -589,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,
))
}
@@ -1199,6 +1267,8 @@ type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Hint string `json:"hint,omitempty"`
Refreshed bool `json:"refreshed,omitempty"`
TokenValid bool `json:"token_valid,omitempty"`
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
@@ -1210,14 +1280,54 @@ type authStatusResponse struct {
UserName string `json:"user_name,omitempty"`
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
type authStatusDiagnostic struct {
Reason string
Message string
Hint string
}
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: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
}
}
if !keychain.IsUnavailable(err) {
return nil
}
return &authStatusDiagnostic{
Reason: "keychain_unavailable",
Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态",
Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
}
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
resp := authStatusResponse{
Success: true,
Authenticated: authenticated,
}
if !authenticated {
resp.Message = "未登录"
if diagnostic != nil {
resp.Message = diagnostic.Message
resp.Reason = diagnostic.Reason
resp.Hint = diagnostic.Hint
} else {
resp.Message = "未登录"
}
} else if data != nil {
resp.Refreshed = refreshed
resp.TokenValid = data.IsAccessTokenValid()
+208
View File
@@ -16,7 +16,10 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -132,6 +135,122 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
}
}
func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
}
var resp struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Reason string `json:"reason"`
Message string `json:"message"`
Hint string `json:"hint"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatalf("success = false, want true; response=%+v", resp)
}
if resp.Authenticated {
t.Fatalf("authenticated = true, want false; response=%+v", resp)
}
if resp.Reason != "keychain_unavailable" {
t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp)
}
if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp)
}
}
func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
}
var resp struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Reason string `json:"reason"`
Message string `json:"message"`
Hint string `json:"hint"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatalf("success = false, want true; response=%+v", resp)
}
if resp.Authenticated {
t.Fatalf("authenticated = true, want false; response=%+v", resp)
}
if resp.Reason != "dek_missing" {
t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; response=%+v", resp)
}
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) {
// Isolate keychain storage to a per-test directory so the saved
// token can't leak into other test packages running in parallel.
@@ -233,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"),
@@ -824,6 +1028,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
func (f *authLoginRecommendSequenceCaller) Fields() string { return "" }
func (f *authLoginRecommendSequenceCaller) JQ() string { return "" }
func stringSliceArgEqual(got any, want []string) bool {
if got == nil {
return len(want) == 0
+53
View File
@@ -21,6 +21,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -29,6 +30,8 @@ import (
"github.com/spf13/cobra"
)
var doctorKeychainDiagnose = keychain.Diagnose
// checkStatus represents the outcome of a single doctor check.
type checkStatus string
@@ -76,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
checks = append(checks, authResult)
keychainResult := doctorCheckKeychain(w, jsonOut)
checks = append(checks, keychainResult)
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
@@ -132,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
data, err := provider.Status()
if err != nil || data == nil {
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
r := checkResult{
Name: "auth",
Status: statusFail,
Message: diagnostic.Message,
Hint: diagnostic.Hint,
Detail: map[string]string{"reason": diagnostic.Reason},
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
if !edition.Get().IsEmbedded {
r.Hint = "运行 dws auth login 进行登录"
@@ -182,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
return r
}
// ── Keychain check ─────────────────────────────────────────────────────
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
}
diagnostic := doctorKeychainDiagnose()
r := checkResult{
Name: "keychain",
Status: statusPass,
Message: diagnostic.Message,
Detail: diagnostic.Detail,
}
if !diagnostic.OK {
r.Status = statusFail
r.Hint = diagnostic.Hint
if diagnostic.Detail == nil {
r.Detail = map[string]string{"reason": diagnostic.Reason}
} else if diagnostic.Reason != "" {
detail := make(map[string]string, len(diagnostic.Detail)+1)
for k, v := range diagnostic.Detail {
detail[k] = v
}
detail["reason"] = diagnostic.Reason
r.Detail = detail
}
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Network check ───────────────────────────────────────────────────────
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
+109
View File
@@ -15,9 +15,16 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCountResults(t *testing.T) {
@@ -127,6 +134,108 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
}
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
}
}
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; result=%+v", r)
}
if !strings.Contains(r.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["reason"] != "dek_missing" {
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
}
}
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
prev := doctorKeychainDiagnose
doctorKeychainDiagnose = func() keychain.Diagnostic {
return keychain.Diagnostic{
OK: false,
Reason: "keychain_unavailable",
Message: "macOS 默认钥匙串不存在",
Hint: "恢复默认钥匙串后重试",
Detail: map[string]string{
"default_keychain": "/tmp/missing.keychain-db",
},
}
}
t.Cleanup(func() {
doctorKeychainDiagnose = prev
})
var buf bytes.Buffer
r := doctorCheckKeychain(&buf, false)
if r.Name != "keychain" {
t.Fatalf("name = %q, want keychain", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if r.Message != "macOS 默认钥匙串不存在" {
t.Fatalf("message = %q", r.Message)
}
if r.Hint == "" {
t.Fatalf("hint is empty; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["default_keychain"] == "" {
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
}
}
func TestDoctorCommandStructure(t *testing.T) {
cmd := newDoctorCommand()
if cmd.Use != "doctor" {
File diff suppressed because it is too large Load Diff
+69
View File
@@ -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)
}
}
+869
View File
@@ -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))
}
+131
View File
@@ -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
}
+349
View File
@@ -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)
}
}
}
+105
View File
@@ -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)
}
}
+64
View File
@@ -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)
}
}
}
+127
View File
@@ -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)
}
}
+37 -6
View File
@@ -590,6 +590,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
return string(data)
}
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
type patData struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
AuthorizationURL string `json:"authorizationUrl"`
ClientID string `json:"clientId"`
}
payload := struct {
Code string `json:"code"`
Data patData `json:"data"`
}{
Code: "AGENT_CODE_NOT_EXISTS",
Data: patData{
Desc: "test auth",
FlowID: flowID,
AuthorizationURL: authURL,
ClientID: clientID,
},
}
data, _ := json.Marshal(payload)
return string(data)
}
func patTestAuthorizationURL(server *httptest.Server) string {
return server.URL + "/pat"
}
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
@@ -664,10 +691,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, _ := setupHandlePATServer(t, "APPROVED", "")
server, configDir := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var retried atomic.Bool
var retryHadKey atomic.Bool
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
@@ -691,7 +721,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
server, _ := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
@@ -721,7 +751,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
@@ -1196,7 +1226,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
fallback: mock,
globalFlags: &GlobalFlags{Format: "table"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
authURL := patTestAuthorizationURL(server)
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
@@ -1216,7 +1247,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
if !strings.Contains(buf.String(), "需要 PAT 授权") {
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
}
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
}
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
+1
View File
@@ -356,6 +356,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
newCatalogCommand(loader),
newConfigCommand(),
newDoctorCommand(),
newEventCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
newUpgradeCommand(),
+16 -4
View File
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
dws skill setup --mode mono --yes # 非交互装 mono
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
DisableAutoGenTag: true,
RunE: runSkillSetup,
@@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
fmt.Fprintln(out, "将安装到:")
for _, d := range dests {
fmt.Fprintf(out, " - %s\n", d)
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
}
return nil
}
if !autoYes {
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
@@ -203,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
@@ -493,7 +505,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 22 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
+1 -2
View File
@@ -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",
+6
View File
@@ -35,6 +35,11 @@ import (
// Setting keychain.StorageDirEnv here forces every keychain read/write in
// this binary into a per-process tempdir, eliminating that contamination
// without touching production code.
//
// PAT authorization tests also exercise code paths that normally open the
// system browser. Keep the package-wide default opener inert so running the
// test binary never launches a page on the developer's machine; tests that
// need to assert the URL can still replace openBrowserFunc locally.
func TestMain(m *testing.M) {
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
if err != nil {
@@ -44,6 +49,7 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
openBrowserFunc = func(string) error { return nil }
code := m.Run()
_ = os.RemoveAll(tmpDir)
os.Exit(code)
+14
View File
@@ -54,6 +54,20 @@ func (a *toolCallerAdapter) DryRun() bool {
return a.flags != nil && a.flags.DryRun
}
func (a *toolCallerAdapter) Fields() string {
if a.flags != nil {
return a.flags.Fields
}
return ""
}
func (a *toolCallerAdapter) JQ() string {
if a.flags != nil {
return a.flags.JQ
}
return ""
}
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
+157
View File
@@ -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
}
+165
View File
@@ -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)
}
}
+4
View File
@@ -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()
+28
View File
@@ -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
}
+72 -1
View File
@@ -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)
+5
View File
@@ -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)
+6
View File
@@ -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)")
+9 -2
View File
@@ -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()
+4 -1
View File
@@ -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
}
+376
View File
@@ -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)
}
}
+18 -2
View File
@@ -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
},
+204
View File
@@ -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
}
}
+227
View File
@@ -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]
}
+525
View File
@@ -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
}
+358
View File
@@ -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)
}
}
}
+34
View File
@@ -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
+81
View File
@@ -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
}
}
}
}
+51
View File
@@ -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
}
+399
View File
@@ -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)
}
+411
View File
@@ -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")
}
}
+157
View File
@@ -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
}
+147
View File
@@ -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)
}
}
+103
View File
@@ -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
}
+90
View File
@@ -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")
}
}
+120
View File
@@ -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
}
+110
View File
@@ -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)
}
}
+81
View File
@@ -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
}
}
}
+120
View File
@@ -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)
}
}
+150
View File
@@ -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")
}
+280
View File
@@ -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)
}
}
+25
View File
@@ -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
+210
View File
@@ -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")
}
+292
View File
@@ -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)
}
}
+31
View File
@@ -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,
}
}
+32
View File
@@ -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,
}
}
+290
View File
@@ -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
}
+203
View File
@@ -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)
}
+95
View File
@@ -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)
}
+140
View File
@@ -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)
}
}
+26
View File
@@ -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 }
+24
View File
@@ -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 }
+18
View File
@@ -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
}
+157
View File
@@ -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
}
+156
View File
@@ -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")
}
}
+316
View File
@@ -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)
}
+86
View File
@@ -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
}
+124
View File
@@ -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)
}
}
+121
View File
@@ -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
}
+104
View File
@@ -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)
}
}
+376
View File
@@ -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:
}
}
}
+464
View File
@@ -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())
}
}
+30
View File
@@ -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)
}
+29
View File
@@ -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)
}
+170
View File
@@ -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
}
+138
View File
@@ -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())
}
}
+151
View File
@@ -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)
}
+166
View File
@@ -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())
}
}
+94
View File
@@ -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 }
+111
View File
@@ -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)
}
}
+36
View File
@@ -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
+60
View File
@@ -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")
}
+61
View File
@@ -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)
}
}
+24
View File
@@ -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
+79
View File
@@ -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
}
+101
View File
@@ -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)
}
}
+35
View File
@@ -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)
}
+60
View File
@@ -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.
//go:build windows
package lock
import (
"errors"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
const (
lockfileExclusiveLock = 0x00000002
lockfileFailImmediately = 0x00000001
)
func lockFile(f *os.File) error {
handle := windows.Handle(f.Fd())
ol := new(windows.Overlapped)
return windows.LockFileEx(
handle,
lockfileExclusiveLock|lockfileFailImmediately,
0,
1,
0,
(*windows.Overlapped)(unsafe.Pointer(ol)),
)
}
func unlockFile(f *os.File) {
handle := windows.Handle(f.Fd())
ol := new(windows.Overlapped)
_ = windows.UnlockFileEx(
handle,
0,
1,
0,
(*windows.Overlapped)(unsafe.Pointer(ol)),
)
}
func isBusy(err error) bool {
// LockFileEx with LOCKFILE_FAIL_IMMEDIATELY returns ERROR_LOCK_VIOLATION
// when the region is already locked.
return errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING)
}
+681
View File
@@ -0,0 +1,681 @@
// 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 personal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const DefaultBasePath = "/dws"
const (
controlLogPayloadLimit = 8192
subscriptionListPageSize = 100
subscriptionListMaxPageGuard = 10000
)
type Identity struct {
AccessToken string `json:"-"`
LocalSubject string `json:"-"`
CorpID string `json:"corp_id"`
UserID string `json:"user_id"`
ClientID string `json:"client_id"`
SourceID string `json:"source_id"`
}
func (i Identity) Key() string {
corpID := strings.TrimSpace(i.CorpID)
userID := strings.TrimSpace(i.UserID)
clientID := strings.TrimSpace(i.ClientID)
sourceID := strings.TrimSpace(i.SourceID)
if corpID != "" && userID != "" {
return strings.Join([]string{"corp_user", corpID, userID, clientID, sourceID}, "\x00")
}
if localSubject := strings.TrimSpace(i.LocalSubject); localSubject != "" {
return strings.Join([]string{"local_subject", localSubject, clientID, sourceID}, "\x00")
}
return strings.Join([]string{"unknown", corpID, userID, clientID, sourceID}, "\x00")
}
type Client struct {
BaseURL string
HTTPClient *http.Client
Identity Identity
}
type CreateSubscriptionRequest struct {
EventKey string `json:"event_key"`
RuleType string `json:"rule_type"`
Name string `json:"name,omitempty"`
RuleParam map[string]any `json:"rule_param"`
Filter any `json:"filter,omitempty"`
Delivery map[string]any `json:"delivery"`
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
}
type Subscription struct {
SubscribeID string `json:"subscribe_id"`
EventKey string `json:"event_key,omitempty"`
RuleType string `json:"rule_type,omitempty"`
Status string `json:"status,omitempty"`
SourceID string `json:"source_id,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type ListOptions struct {
Status string
EventKey string
SubscribeID string
}
type dwsCreateSubscriptionRequest struct {
ClientID string `json:"clientId"`
SourceID string `json:"sourceId,omitempty"`
EventKey string `json:"eventKey"`
FilterRule string `json:"filterRule,omitempty"`
DeliveryPref string `json:"deliveryPref,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Ext map[string]any `json:"ext,omitempty"`
}
type dwsSubListResult struct {
Total int `json:"total,omitempty"`
PageNo int `json:"pageNo,omitempty"`
PageSize int `json:"pageSize,omitempty"`
Items []dwsSubscription `json:"items"`
}
type dwsSubscription struct {
SubID string `json:"subId"`
SubscribeID string `json:"subscribe_id"`
EventKey string `json:"eventKey"`
EventKeySnake string `json:"event_key"`
RuleType string `json:"ruleType,omitempty"`
RuleTypeSnake string `json:"rule_type,omitempty"`
ClientID string `json:"clientId,omitempty"`
SourceID string `json:"sourceId"`
SourceIDSnake string `json:"source_id"`
DeliveryPref string `json:"deliveryPref,omitempty"`
Status json.RawMessage `json:"status,omitempty"`
GmtCreate string `json:"gmtCreate,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
func (s dwsSubscription) toSubscription() Subscription {
return Subscription{
SubscribeID: firstNonEmpty(s.SubID, s.SubscribeID),
EventKey: firstNonEmpty(s.EventKey, s.EventKeySnake),
RuleType: firstNonEmpty(s.RuleType, s.RuleTypeSnake),
Status: dwsStatusString(s.Status),
SourceID: firstNonEmpty(s.SourceID, s.SourceIDSnake),
CreatedAt: firstNonEmpty(s.GmtCreate, s.CreatedAt),
}
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
func (e *APIError) Error() string {
if e == nil {
return ""
}
if e.Code != "" && e.Message != "" {
return e.Code + ": " + e.Message
}
if e.Code != "" {
return e.Code
}
return e.Message
}
func NewClient(baseURL string, identity Identity) *Client {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = strings.TrimRight(config.GetMCPBaseURL(), "/") + DefaultBasePath
}
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
Identity: identity,
}
}
func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionRequest) (*Subscription, error) {
if req.EventKey == "" || req.RuleType == "" {
return nil, errors.New("personal event: event_key and rule_type are required")
}
var sub Subscription
if err := c.do(ctx, http.MethodPost, "/subscription/user", nil, c.buildCreateRequest(req), &sub); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
if subID, ok := apiErr.Details["subscribe_id"].(string); ok && subID != "" {
return &Subscription{
SubscribeID: subID,
EventKey: req.EventKey,
RuleType: req.RuleType,
Status: "active",
SourceID: c.Identity.SourceID,
}, nil
}
}
return nil, err
}
if sub.EventKey == "" {
sub.EventKey = req.EventKey
}
if sub.RuleType == "" {
sub.RuleType = req.RuleType
}
if sub.Status == "" {
sub.Status = "active"
}
if sub.SourceID == "" {
sub.SourceID = c.Identity.SourceID
}
return &sub, nil
}
func (c *Client) GetSubscription(ctx context.Context, subscribeID string) (*Subscription, error) {
subscribeID = strings.TrimSpace(subscribeID)
if subscribeID == "" {
return nil, errors.New("personal event: subscribe_id is required")
}
subs, err := c.ListSubscriptions(ctx, ListOptions{SubscribeID: subscribeID})
if err != nil {
return nil, err
}
if len(subs) == 0 {
return nil, &APIError{Code: "PERSONAL_EVENT_NOT_FOUND", Message: "subscription not found"}
}
return &subs[0], nil
}
func (c *Client) ListSubscriptions(ctx context.Context, opts ListOptions) ([]Subscription, error) {
q := make(url.Values)
if clientID := strings.TrimSpace(c.Identity.ClientID); clientID != "" {
q.Set("clientId", clientID)
}
if sourceID := strings.TrimSpace(c.Identity.SourceID); sourceID != "" {
q.Set("sourceId", sourceID)
}
q.Set("pageSize", fmt.Sprintf("%d", subscriptionListPageSize))
all := make([]Subscription, 0, subscriptionListPageSize)
seen := make(map[string]struct{}, subscriptionListPageSize)
for pageNo := 1; pageNo <= subscriptionListMaxPageGuard; pageNo++ {
q.Set("pageNo", fmt.Sprintf("%d", pageNo))
var result dwsSubListResult
if err := c.do(ctx, http.MethodGet, "/event/sublist", q, nil, &result); err != nil {
return nil, err
}
if len(result.Items) == 0 {
break
}
effectivePageSize := subscriptionListPageSize
if result.PageSize > 0 {
effectivePageSize = result.PageSize
}
added := 0
for _, item := range result.Items {
sub := item.toSubscription()
if sub.SubscribeID != "" {
if _, ok := seen[sub.SubscribeID]; ok {
continue
}
seen[sub.SubscribeID] = struct{}{}
}
all = append(all, sub)
added++
}
if added == 0 && (result.Total > len(all) || len(result.Items) >= effectivePageSize) {
return nil, fmt.Errorf("personal event: subscription pagination made no progress at page %d", pageNo)
}
if result.Total > 0 && len(all) >= result.Total {
break
}
if len(result.Items) < effectivePageSize {
break
}
if pageNo == subscriptionListMaxPageGuard {
return nil, fmt.Errorf("personal event: subscription pagination exceeded %d pages", subscriptionListMaxPageGuard)
}
}
items := make([]Subscription, 0, len(all))
for _, sub := range all {
if opts.Status != "" && opts.Status != "all" && sub.Status != opts.Status {
continue
}
if opts.EventKey != "" && sub.EventKey != opts.EventKey {
continue
}
if opts.SubscribeID != "" && sub.SubscribeID != opts.SubscribeID {
continue
}
items = append(items, sub)
}
return items, nil
}
func (c *Client) DeleteSubscription(ctx context.Context, subscribeID string) error {
subscribeID = strings.TrimSpace(subscribeID)
if subscribeID == "" {
return errors.New("personal event: subscribe_id is required")
}
err := c.do(ctx, http.MethodPost, "/subscription/cancel", nil, map[string]string{"subId": subscribeID}, nil)
if isNotFound(err) {
return nil
}
return err
}
func (c *Client) buildCreateRequest(req CreateSubscriptionRequest) dwsCreateSubscriptionRequest {
filterRule := ""
if req.RuleParam != nil {
if b, err := json.Marshal(req.RuleParam); err == nil {
filterRule = string(b)
}
}
ext := map[string]any{
"ruleType": req.RuleType,
}
if req.Name != "" {
ext["name"] = req.Name
}
if req.Filter != nil {
ext["filter"] = req.Filter
}
if req.IdempotencyKey != "" {
ext["idempotencyKey"] = req.IdempotencyKey
}
out := dwsCreateSubscriptionRequest{
ClientID: c.Identity.ClientID,
SourceID: c.Identity.SourceID,
EventKey: req.EventKey,
FilterRule: filterRule,
DeliveryPref: "realtime",
Ext: ext,
}
if req.TTLSeconds > 0 {
out.ExpiresAt = time.Now().UTC().Add(time.Duration(req.TTLSeconds) * time.Second).Format(time.RFC3339)
}
return out
}
func (c *Client) do(ctx context.Context, method, path string, q url.Values, body any, out any) error {
if c == nil {
return errors.New("personal event: nil client")
}
if c.Identity.AccessToken == "" {
return errors.New("personal event: access token is required")
}
u := strings.TrimRight(c.BaseURL, "/") + path
if len(q) > 0 {
u += "?" + q.Encode()
}
var r io.Reader
requestLog := ""
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("personal event: encode request: %w", err)
}
requestLog = sanitizeLogPayload(b)
r = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return fmt.Errorf("personal event: create request: %w", err)
}
c.decorate(req)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
hc := c.HTTPClient
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("personal event: send request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return fmt.Errorf("personal event: read response: %w", err)
}
responseLog := sanitizeLogPayload(data)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if apiErr := decodeAPIError(data); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, responseRequestID(data))
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
return fmt.Errorf("personal event: HTTP %d", resp.StatusCode)
}
if len(bytes.TrimSpace(data)) == 0 {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil)
return nil
}
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") {
if env.Success == nil {
if apiErr := env.apiError(); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
return apiErr
}
}
if env.Success != nil && !*env.Success {
if apiErr := env.apiError(); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
return errors.New("personal event: request failed")
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
if env.Result == nil {
return nil
}
if out == nil {
return nil
}
return decodeResult(env.Result, out)
}
if out == nil {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
return nil
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
return json.Unmarshal(data, out)
}
func (c *Client) decorate(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken)
req.Header.Set("x-user-access-token", c.Identity.AccessToken)
req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID)
req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID)
if c.Identity.CorpID != "" {
req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID)
}
req.Header.Set("Accept", "application/json")
}
type responseEnvelope struct {
Success *bool `json:"success"`
RequestID string `json:"request_id,omitempty"`
RequestID2 string `json:"requestId,omitempty"`
Result json.RawMessage `json:"result"`
Error *APIError `json:"error"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
}
func (e responseEnvelope) apiError() *APIError {
if e.Error != nil {
return e.Error
}
if e.ErrorCode != "" || e.ErrorMsg != "" {
return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg}
}
return nil
}
func (e responseEnvelope) requestID() string {
return firstNonEmpty(e.RequestID, e.RequestID2)
}
func decodeResult(raw json.RawMessage, out any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
if sub, ok := out.(*Subscription); ok {
if decoded, ok, err := decodeSubscriptionResult(raw); ok || err != nil {
if err != nil {
return err
}
*sub = decoded
return nil
}
}
if err := json.Unmarshal(raw, out); err == nil {
return nil
}
return json.Unmarshal(raw, out)
}
func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool, error) {
var ids []string
if err := json.Unmarshal(raw, &ids); err == nil {
if len(ids) == 0 {
return Subscription{}, true, nil
}
return Subscription{SubscribeID: ids[0]}, true, nil
}
var item dwsSubscription
if err := json.Unmarshal(raw, &item); err == nil &&
(firstNonEmpty(item.SubID, item.SubscribeID) != "" ||
firstNonEmpty(item.EventKey, item.EventKeySnake) != "") {
return item.toSubscription(), true, nil
}
return Subscription{}, false, nil
}
func decodeAPIError(data []byte) *APIError {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
if env.Error != nil {
return env.Error
}
if env.ErrorCode != "" || env.ErrorMsg != "" {
return &APIError{Code: env.ErrorCode, Message: env.ErrorMsg}
}
}
var apiErr APIError
if err := json.Unmarshal(data, &apiErr); err == nil && (apiErr.Code != "" || apiErr.Message != "") {
return &apiErr
}
return nil
}
func responseRequestID(data []byte) string {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
return env.requestID()
}
return ""
}
func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError {
if apiErr == nil {
return nil
}
if apiErr.Details == nil {
apiErr.Details = make(map[string]any, 4)
}
apiErr.Details["method"] = method
apiErr.Details["path"] = path
apiErr.Details["http_status"] = status
if requestID != "" {
apiErr.Details["request_id"] = requestID
}
return apiErr
}
func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) {
attrs := []any{
"method", method,
"path", path,
"http_status", status,
}
if query := redactedQueryString(q); query != "" {
attrs = append(attrs, "query", query)
}
if requestPayload != "" {
attrs = append(attrs, "request", requestPayload)
}
if responsePayload != "" {
attrs = append(attrs, "response", responsePayload)
}
if requestID != "" {
attrs = append(attrs, "request_id", requestID)
}
if apiErr != nil {
if apiErr.Code != "" {
attrs = append(attrs, "error_code", apiErr.Code)
}
if apiErr.Message != "" {
attrs = append(attrs, "error_msg", apiErr.Message)
}
}
slog.Debug(message, attrs...)
}
func sanitizeLogPayload(data []byte) string {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return ""
}
var parsed any
if err := json.Unmarshal(data, &parsed); err == nil {
redacted := redactJSONValue(parsed)
if s, err := marshalLogJSON(redacted); err == nil {
return truncateLogPayload(s)
}
}
return truncateLogPayload(string(data))
}
func marshalLogJSON(v any) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func redactJSONValue(v any) any {
switch x := v.(type) {
case map[string]any:
out := make(map[string]any, len(x))
for k, value := range x {
if sensitiveLogKey(k) {
out[k] = "<redacted>"
continue
}
out[k] = redactJSONValue(value)
}
return out
case []any:
out := make([]any, len(x))
for i, value := range x {
out[i] = redactJSONValue(value)
}
return out
default:
return v
}
}
func sensitiveLogKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
return strings.Contains(key, "token") ||
strings.Contains(key, "secret") ||
strings.Contains(key, "ticket") ||
strings.Contains(key, "authorization")
}
func redactedQueryString(q url.Values) string {
if len(q) == 0 {
return ""
}
clone := make(url.Values, len(q))
for key, values := range q {
if sensitiveLogKey(key) {
clone[key] = []string{"<redacted>"}
continue
}
clone[key] = append([]string(nil), values...)
}
return clone.Encode()
}
func truncateLogPayload(s string) string {
if len(s) <= controlLogPayloadLimit {
return s
}
return s[:controlLogPayloadLimit] + "...<truncated>"
}
func isNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && (apiErr.Code == "PERSONAL_EVENT_NOT_FOUND" || apiErr.Code == "NOT_FOUND")
}
func dwsStatusString(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var n int
if err := json.Unmarshal(raw, &n); err == nil {
switch n {
case 1:
return "active"
case 2:
return "paused"
case 3:
return "deleted"
default:
return fmt.Sprintf("%d", n)
}
}
return string(raw)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+646
View File
@@ -0,0 +1,646 @@
// 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 personal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
var gotPath string
var gotReq dwsCreateSubscriptionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
gotPath = r.URL.Path
if got := r.Header.Get("Authorization"); got != "Bearer token-1" {
t.Fatalf("Authorization = %q", got)
}
if got := r.Header.Get("x-user-access-token"); got != "token-1" {
t.Fatalf("x-user-access-token = %q", got)
}
if got := r.Header.Get("X-DWS-Client-Id"); got != "client-1" {
t.Fatalf("X-DWS-Client-Id = %q", got)
}
if got := r.Header.Get("X-DWS-Source-Id"); got != "open" {
t.Fatalf("X-DWS-Source-Id = %q", got)
}
if got := r.Header.Get("X-DWS-Corp-Id"); got != "corp-1" {
t.Fatalf("X-DWS-Corp-Id = %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("Decode body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": []string{"sub-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{
AccessToken: "token-1",
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
SourceID: "open",
})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventSingleChat,
RuleType: "singleChat",
Name: "test-o2o",
RuleParam: map[string]any{
"targetUid": "507971",
"targetUidType": "staffId",
},
Filter: map[string]any{"field": "payload.body.content", "op": "contains", "value": "P0"},
IdempotencyKey: "idem-1",
})
if err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
if gotPath != "/subscription/user" {
t.Fatalf("path = %q, want /subscription/user", gotPath)
}
if gotReq.ClientID != "client-1" || gotReq.SourceID != "open" || gotReq.EventKey != EventSingleChat {
t.Fatalf("request identity/event = %#v", gotReq)
}
if gotReq.DeliveryPref != "realtime" {
t.Fatalf("deliveryPref = %q, want realtime", gotReq.DeliveryPref)
}
var filterRule map[string]any
if err := json.Unmarshal([]byte(gotReq.FilterRule), &filterRule); err != nil {
t.Fatalf("filterRule is not JSON: %q: %v", gotReq.FilterRule, err)
}
if filterRule["targetUid"] != "507971" || filterRule["targetUidType"] != "staffId" {
t.Fatalf("filterRule = %#v", filterRule)
}
if gotReq.Ext["ruleType"] != "singleChat" || gotReq.Ext["name"] != "test-o2o" || gotReq.Ext["idempotencyKey"] != "idem-1" {
t.Fatalf("ext = %#v", gotReq.Ext)
}
if sub.SubscribeID != "sub-1" {
t.Fatalf("subscribe_id = %q", sub.SubscribeID)
}
if sub.EventKey != EventSingleChat || sub.RuleType != "singleChat" || sub.Status != "active" || sub.SourceID != "open" {
t.Fatalf("subscription = %#v", sub)
}
}
func TestClientCreateSubscriptionObjectResponses(t *testing.T) {
cases := []map[string]any{
{"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1},
{"subscribe_id": "sub-snake", "event_key": EventMention, "source_id": "open", "status": "active"},
}
for _, result := range cases {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": result,
})
}))
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
srv.Close()
if err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
if sub.SubscribeID == "" || !strings.HasPrefix(sub.SubscribeID, "sub-") {
t.Fatalf("subscription = %#v", sub)
}
}
}
func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"requestId": "req-ok",
"result": []string{"sub-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "secret-token", ClientID: "client-1", SourceID: "pre_open_source"})
if _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventSingleChat,
RuleType: "singleChat",
RuleParam: map[string]any{
"targetUid": "507971",
"targetUidType": "staffId",
},
IdempotencyKey: "idem-1",
}); err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
out := logs.String()
for _, want := range []string{
"personal event control request",
"/subscription/user",
"client-1",
"pre_open_source",
EventSingleChat,
"filterRule",
"targetUid",
"507971",
"sub-1",
"req-ok",
} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
if strings.Contains(out, "secret-token") {
t.Fatalf("debug log leaked access token: %s", out)
}
}
func TestClientBusinessErrorHTTP200(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"requestId": "req-1",
"errorCode": "INVALID_PARAM",
"errorMsg": "clientId is empty",
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
if err == nil || !strings.Contains(err.Error(), "INVALID_PARAM") || !strings.Contains(err.Error(), "clientId is empty") {
t.Fatalf("error = %v, want INVALID_PARAM business error", err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *APIError", err)
}
if apiErr.Details["method"] != http.MethodPost || apiErr.Details["path"] != "/subscription/user" ||
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
t.Fatalf("details = %#v", apiErr.Details)
}
out := logs.String()
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
}
func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-DWS-Corp-Id"); got != "" {
t.Fatalf("X-DWS-Corp-Id = %q, want empty", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer token-1" {
t.Fatalf("Authorization = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": []string{"sub-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
if _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
}); err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
}
func TestIdentityKeyUsesLocalSubjectFallback(t *testing.T) {
withCorpUser := Identity{CorpID: "corp-1", UserID: "user-1", ClientID: "client-1", SourceID: "open"}
if got := withCorpUser.Key(); got != "corp_user\x00corp-1\x00user-1\x00client-1\x00open" {
t.Fatalf("corp/user key = %q", got)
}
fallback := Identity{LocalSubject: "refresh:abc", ClientID: "client-1", SourceID: "open"}
if got := fallback.Key(); got != "local_subject\x00refresh:abc\x00client-1\x00open" {
t.Fatalf("fallback key = %q", got)
}
}
func TestClientDeleteSubscriptionTreatsNotFoundAsSuccess(t *testing.T) {
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
if r.URL.Path != "/subscription/cancel" {
t.Fatalf("path = %q", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"error": map[string]any{"code": "PERSONAL_EVENT_NOT_FOUND", "message": "not found"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if err := c.DeleteSubscription(t.Context(), "sub-404"); err != nil {
t.Fatalf("DeleteSubscription() error = %v", err)
}
if gotBody["subId"] != "sub-404" {
t.Fatalf("cancel body = %#v", gotBody)
}
}
func TestClientDeleteSubscriptionBusinessError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"requestId": "req-cancel",
"errorCode": "INVALID_STATE",
"errorMsg": "subscription cannot be cancelled",
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
err := c.DeleteSubscription(t.Context(), "sub-1")
if err == nil || !strings.Contains(err.Error(), "INVALID_STATE") {
t.Fatalf("DeleteSubscription() error = %v, want INVALID_STATE", err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *APIError", err)
}
if apiErr.Details["path"] != "/subscription/cancel" || apiErr.Details["request_id"] != "req-cancel" {
t.Fatalf("details = %#v", apiErr.Details)
}
}
func TestClientDebugLogListAndDeleteSubscription(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/event/sublist":
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"items": []map[string]any{}},
})
case "/subscription/cancel":
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if _, err := c.ListSubscriptions(t.Context(), ListOptions{Status: "active"}); err != nil {
t.Fatalf("ListSubscriptions() error = %v", err)
}
if err := c.DeleteSubscription(t.Context(), "sub-1"); err != nil {
t.Fatalf("DeleteSubscription() error = %v", err)
}
out := logs.String()
for _, want := range []string{"/event/sublist", "clientId=client", "sourceId=open", "/subscription/cancel", "sub-1"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
}
func TestClientDebugLogRedactsSensitivePayloadFields(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"requestId": "req-secret",
"result": map[string]any{
"access_token": "resp-access-token",
"client_secret": "resp-client-secret",
"ticket": "resp-ticket",
"Authorization": "Bearer resp-auth",
"safe": "ok",
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "header-token-secret", ClientID: "client", SourceID: "open"})
err := c.do(t.Context(), http.MethodPost, "/subscription/user", nil, map[string]any{
"access_token": "req-access-token",
"client_secret": "req-client-secret",
"ticket": "req-ticket",
"Authorization": "Bearer req-auth",
"safe": "ok",
}, nil)
if err != nil {
t.Fatalf("do() error = %v", err)
}
out := logs.String()
for _, leaked := range []string{
"header-token-secret",
"req-access-token",
"req-client-secret",
"req-ticket",
"Bearer req-auth",
"resp-access-token",
"resp-client-secret",
"resp-ticket",
"Bearer resp-auth",
} {
if strings.Contains(out, leaked) {
t.Fatalf("debug log leaked %q: %s", leaked, out)
}
}
for _, want := range []string{"<redacted>", "safe", "ok", "req-secret"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
}
func TestClientListSubscriptionsDWSSublist(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/event/sublist" {
t.Fatalf("path = %q", r.URL.Path)
}
gotQuery = r.URL.RawQuery
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 2,
"pageNo": 1,
"pageSize": 20,
"items": []map[string]any{
{
"subId": "sub-1",
"eventKey": EventSingleChat,
"sourceId": "open",
"deliveryPref": "realtime",
"status": 1,
"gmtCreate": "2026-06-29T10:00:00Z",
},
{
"subId": "sub-2",
"eventKey": EventMention,
"sourceId": "open",
"status": 3,
},
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{Status: "active", EventKey: EventSingleChat})
if err != nil {
t.Fatalf("ListSubscriptions() error = %v", err)
}
if !strings.Contains(gotQuery, "clientId=client") || !strings.Contains(gotQuery, "sourceId=open") ||
!strings.Contains(gotQuery, "pageNo=1") || !strings.Contains(gotQuery, "pageSize=100") {
t.Fatalf("query = %q", gotQuery)
}
if len(subs) != 1 || subs[0].SubscribeID != "sub-1" || subs[0].Status != "active" || subs[0].CreatedAt == "" {
t.Fatalf("subs = %#v", subs)
}
}
func TestClientGetSubscriptionFiltersSublist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"items": []map[string]any{
{"subId": "sub-1", "eventKey": EventMention, "sourceId": "open", "status": 1},
{"subId": "sub-2", "eventKey": EventSingleChat, "sourceId": "open", "status": 1},
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.GetSubscription(t.Context(), "sub-2")
if err != nil {
t.Fatalf("GetSubscription() error = %v", err)
}
if sub.SubscribeID != "sub-2" || sub.EventKey != EventSingleChat {
t.Fatalf("subscription = %#v", sub)
}
}
func TestClientListSubscriptionsPaginatesAllResults(t *testing.T) {
var pages []int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageNo, err := strconv.Atoi(r.URL.Query().Get("pageNo"))
if err != nil {
t.Fatalf("pageNo = %q", r.URL.Query().Get("pageNo"))
}
pages = append(pages, pageNo)
start := (pageNo - 1) * subscriptionListPageSize
end := start + subscriptionListPageSize
if end > 205 {
end = 205
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 205,
"items": dwsSubscriptionTestItems(start, end),
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatalf("ListSubscriptions() error = %v", err)
}
if len(subs) != 205 || subs[204].SubscribeID != "sub-204" {
t.Fatalf("subscriptions = %d, last = %#v", len(subs), subs[len(subs)-1])
}
if fmt.Sprint(pages) != "[1 2 3]" {
t.Fatalf("pages = %v, want [1 2 3]", pages)
}
}
func TestClientGetSubscriptionFindsLaterPage(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
pageNo, _ := strconv.Atoi(r.URL.Query().Get("pageNo"))
items := dwsSubscriptionTestItems(0, 100)
if pageNo == 2 {
items = dwsSubscriptionTestItems(100, 101)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"total": 101, "items": items},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.GetSubscription(t.Context(), "sub-100")
if err != nil {
t.Fatalf("GetSubscription() error = %v", err)
}
if sub.SubscribeID != "sub-100" || calls != 2 {
t.Fatalf("subscription = %#v, calls = %d", sub, calls)
}
}
func TestClientListSubscriptionsWithoutTotalStopsOnEmptyPage(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
items := dwsSubscriptionTestItems(0, 100)
if r.URL.Query().Get("pageNo") == "2" {
items = nil
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"items": items},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatal(err)
}
if len(subs) != 100 || calls != 2 {
t.Fatalf("subscriptions = %d, calls = %d", len(subs), calls)
}
}
func TestClientListSubscriptionsUsesServerPageSize(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
pageNo, _ := strconv.Atoi(r.URL.Query().Get("pageNo"))
start := (pageNo - 1) * 20
end := start + 20
if end > 45 {
end = 45
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 45,
"pageSize": 20,
"items": dwsSubscriptionTestItems(start, end),
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatal(err)
}
if len(subs) != 45 || calls != 3 {
t.Fatalf("subscriptions = %d, calls = %d", len(subs), calls)
}
}
func TestClientListSubscriptionsDeduplicatesSubscribeID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
items := dwsSubscriptionTestItems(0, 100)
if r.URL.Query().Get("pageNo") == "2" {
items = append(dwsSubscriptionTestItems(99, 100), dwsSubscriptionTestItems(100, 101)...)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"total": 101, "items": items},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatal(err)
}
if len(subs) != 101 || subs[100].SubscribeID != "sub-100" {
t.Fatalf("subscriptions = %#v", subs)
}
}
func TestClientListSubscriptionsRejectsRepeatedPage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 200,
"items": dwsSubscriptionTestItems(0, 100),
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
_, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err == nil || !strings.Contains(err.Error(), "pagination made no progress") {
t.Fatalf("ListSubscriptions() error = %v", err)
}
}
func dwsSubscriptionTestItems(start, end int) []map[string]any {
items := make([]map[string]any, 0, end-start)
for i := start; i < end; i++ {
items = append(items, map[string]any{
"subId": fmt.Sprintf("sub-%d", i),
"eventKey": EventSingleChat,
"sourceId": "open",
"status": 1,
})
}
return items
}
func captureClientDebugLogs(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() {
slog.SetDefault(previous)
})
return &buf
}
+401
View File
@@ -0,0 +1,401 @@
// 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 personal
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
)
const (
StatusEnabled = "enabled"
StatusPending = "pending"
)
type Definition struct {
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
Status string `json:"status"`
RequiredParams []string `json:"required_params"`
Auth map[string]any `json:"auth,omitempty"`
Public bool `json:"-"`
}
type SchemaDocument struct {
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
RequiredParams []string `json:"required_params"`
JQRootPath string `json:"jq_root_path"`
Schema map[string]any `json:"schema"`
}
type RuleOptions struct {
RuleType string
UserID string
GroupID string
}
type SchemaPendingError struct {
EventKey string
}
func (e *SchemaPendingError) Error() string {
return fmt.Sprintf("%s schema is pending; try user_im_message_receive_at or user_im_message_receive_o2o first", e.EventKey)
}
var definitions = []Definition{
{
EventKey: EventMention,
DisplayName: "@我的消息",
Description: "当前用户被 @ 的消息",
Category: "im",
RuleType: "at",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventSingleChat,
DisplayName: "指定单聊消息",
Description: "当前用户与指定用户的单聊消息",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: []string{"user"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventInChat,
DisplayName: "指定群消息",
Description: "当前用户所在指定会话的消息",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventFromUser,
DisplayName: "指定发送人消息",
Description: "当前用户收到的特别关注用户的消息",
Category: "im",
RuleType: "sender",
Status: StatusEnabled,
RequiredParams: []string{"user"},
Auth: map[string]any{"identity": "user"},
Public: false,
},
}
func Definitions() []Definition {
out := append([]Definition(nil), definitions...)
return out
}
func Lookup(eventKey string) (Definition, bool) {
for _, def := range definitions {
if def.EventKey == eventKey {
return def, true
}
}
return Definition{}, false
}
func IsPublic(eventKey string) bool {
def, ok := Lookup(eventKey)
return ok && def.Public
}
func PublicAvailabilityError(eventKey string) error {
return fmt.Errorf("event %s is not publicly available yet", eventKey)
}
func Catalog(category string, enabledOnly, includePending bool) []Definition {
category = strings.TrimSpace(category)
var out []Definition
for _, def := range definitions {
if !def.Public {
continue
}
if category != "" && def.Category != category {
continue
}
if enabledOnly && def.Status != StatusEnabled {
continue
}
if !includePending && def.Status == StatusPending {
continue
}
out = append(out, def)
}
return out
}
func BuildSchemaDocument(def Definition) SchemaDocument {
requiredParams := make([]string, 0, len(def.RequiredParams))
requiredParams = append(requiredParams, def.RequiredParams...)
return SchemaDocument{
EventKey: def.EventKey,
DisplayName: def.DisplayName,
Description: def.Description,
Category: def.Category,
RuleType: def.RuleType,
RequiredParams: requiredParams,
JQRootPath: ".data | fromjson",
Schema: personalMessageSchema(def.EventKey),
}
}
func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, ruleParam map[string]any, err error) {
def, ok := Lookup(eventKey)
if !ok {
return "", nil, fmt.Errorf("unknown personal event key %q", eventKey)
}
if opts.RuleType != "" && opts.RuleType != def.RuleType {
return "", nil, fmt.Errorf("--rule %q does not match %s rule %q", opts.RuleType, eventKey, def.RuleType)
}
if def.Status == StatusPending {
return "", nil, &SchemaPendingError{EventKey: eventKey}
}
userID := strings.TrimSpace(opts.UserID)
groupID := strings.TrimSpace(opts.GroupID)
switch def.RuleType {
case "at":
if userID != "" {
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
}
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
return def.RuleType, map[string]any{}, nil
case "singleChat":
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
if userID == "" {
return "", nil, fmt.Errorf("--user is required")
}
return def.RuleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
case "sender":
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
if userID == "" {
return "", nil, fmt.Errorf("--user is required")
}
return def.RuleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
case "group":
if userID != "" {
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
}
if groupID == "" {
return "", nil, fmt.Errorf("--group is required")
}
return def.RuleType, map[string]any{
"openConversationId": groupID,
}, nil
default:
return "", nil, &SchemaPendingError{EventKey: eventKey}
}
}
func BuildFilter(filterJSON string, queryCSV string) (any, string, error) {
var parts []any
filterJSON = strings.TrimSpace(filterJSON)
if filterJSON != "" {
var v any
if err := json.Unmarshal([]byte(filterJSON), &v); err != nil {
return nil, "", fmt.Errorf("--filter-json must be valid JSON: %w", err)
}
v = normalizeFilterAliases(v)
parts = append(parts, v)
}
queries := splitCSV(queryCSV)
if len(queries) > 0 {
parts = append(parts, map[string]any{
"field": "payload.body.content",
"op": "contains_any",
"value": queries,
})
}
switch len(parts) {
case 0:
return nil, "", nil
case 1:
canon, err := CanonicalJSON(parts[0])
return parts[0], canon, err
default:
v := map[string]any{"and": parts}
canon, err := CanonicalJSON(v)
return v, canon, err
}
}
func IdempotencyKey(identity Identity, eventKey, ruleType string, ruleParam map[string]any, filterCanonical string) string {
ruleCanonical, _ := CanonicalJSON(ruleParam)
sum := sha256.Sum256([]byte(strings.Join([]string{
identity.Key(),
eventKey,
ruleType,
ruleCanonical,
filterCanonical,
}, "\x00")))
return "dws-cli-" + hex.EncodeToString(sum[:8])
}
func CanonicalJSON(v any) (string, error) {
if v == nil {
return "", nil
}
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
func splitCSV(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
var filterFieldAliases = map[string]string{
"content": "payload.body.content",
"conversation_id": "payload.body.openConversationId",
"sender": "payload.body.sender",
"sender_open_dingtalk_id": "payload.body.senderOpenDingTalkId",
}
func normalizeFilterAliases(v any) any {
switch x := v.(type) {
case map[string]any:
out := make(map[string]any, len(x))
for k, value := range x {
if k == "field" {
if raw, ok := value.(string); ok {
if mapped, ok := filterFieldAliases[raw]; ok {
value = mapped
}
}
} else {
value = normalizeFilterAliases(value)
}
out[k] = value
}
return out
case []any:
out := make([]any, len(x))
for i, value := range x {
out[i] = normalizeFilterAliases(value)
}
return out
default:
return v
}
}
func personalMessageSchema(eventKey string) map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"type": map[string]any{
"type": "string",
"description": "事件类型,固定为当前 event_key",
"enum": []string{eventKey},
},
"event_id": map[string]any{
"type": "string",
"description": "事件 ID,可用于去重",
},
"timestamp": map[string]any{
"type": "integer",
"description": "事件发生时间戳,对应 occurredAtMs",
"format": "timestamp_ms",
},
"subscribe_id": map[string]any{
"type": "string",
"description": "订阅 ID,对应 subId",
},
"message_id": map[string]any{
"type": "string",
"description": "开放消息 ID,对应 payload.body.openMessageId",
"format": "open_message_id",
},
"conversation_id": map[string]any{
"type": "string",
"description": "会话 ID,对应 payload.body.openConversationId",
"format": "open_conversation_id",
},
"sender": map[string]any{
"type": "string",
"description": "发送人展示名,对应 payload.body.sender",
},
"sender_open_dingtalk_id": map[string]any{
"type": "string",
"description": "发送人开放 ID,对应 payload.body.senderOpenDingTalkId",
"format": "open_dingtalk_id",
},
"content": map[string]any{
"type": "string",
"description": "消息正文,对应 payload.body.content",
},
"create_time": map[string]any{
"type": "string",
"description": "消息创建时间,对应 payload.body.createTime",
},
"event_time": map[string]any{
"type": "integer",
"description": "消息事件时间戳,对应 payload.event_time",
"format": "timestamp_ms",
},
},
}
}
func IsSchemaPending(err error) bool {
var pending *SchemaPendingError
return errors.As(err, &pending)
}

Some files were not shown because too many files have changed in this diff Show More