Merge remote-tracking branch 'origin/main' into codex/skill-token-shallow-water

# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
This commit is contained in:
Dennis committed 2026-07-30 17:39:06 +08:00
commit e04ff5a12b
27 files changed
+5688 -189

No files matched your search

+68 -1
View File
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the four **contract** items below. Reconnect
Scope of this branch: the five **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
@@ -92,6 +92,73 @@ Ownership-based cleanup:
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
### 5. Subscription-create retry orchestration and local guard
This policy covers all 16 public personal-event keys and every logical
subscription in a multi-event command. It applies only before the ready
marker; reconnecting an established Stream remains a separate mechanism.
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
process sends at most one subscription-create HTTP request for a logical
subscription and performs no in-process automatic retry. The CLI persists
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
not persist or enforce the Agent/host attempt count across invocations.
- ID resolution, `event consume`, and later `event status/stop` must use the
same `--profile`. A user or conversation ID resolved under another profile
must not be reused for the current subscription.
- A logical subscription is keyed by the current profile/identity, event key,
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
does not create a new logical operation or reset the Agent/host budget.
- For the Agent/host, `retryable=false` means
`max_additional_attempts=0`.
- For the Agent/host, `retryable=true` means
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
`next_retry_at` when present and must not retry early.
- For the Agent/host, an omitted retryable value
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
failure stops the operation.
- `in_flight` means the original logical request is still running.
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
it. These states must not recursively launch `event consume`, start a
parallel equivalent subscription, or bypass the guard with a new subId or
trace. The caller waits for the original request/guard or stops, while the
Agent/host keeps its own orchestration count.
- A multi-event command remains one original operation. A caller must not
split out a failed event, reorder events, or restart the command to bypass
a budget. Existing startup rollback cleans subscriptions created before a
later item fails.
#### Local guard state operations
- The default open-edition state file is
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
that edition's directory instead of `open`.
- The identity directory is mode `0700`; both
`personal_subscription_attempts.json` and
`personal_subscription_attempts.lock` are mode `0600`.
- A failure streak resets after 24h without another failure. A
`terminal_hold` lasts 1h. Prefer waiting until the reported
`next_retry_at`; do not clear the file as a normal retry mechanism.
- For emergency recovery, first ensure that no subscription-create process is
running for that identity. Delete only
`personal_subscription_attempts.json`, never the lock file. This clears
every protection record for that identity, not just one event.
**Verification**
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
contract and explicitly reject describing it as a CLI-persisted hard cap.
- T5b (CLI): one process issues at most one create request per logical
subscription; a changed subId/trace or process restart does not bypass the
persisted fingerprint guard.
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
and a partial startup still rolls back earlier subscriptions.
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
+8
View File
@@ -199,6 +199,14 @@ func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
sharedAuditSink = previousSink
loadTokenForProfile = previousLoader
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
// The process-wide sink was initialized by TestMain. Preserve that
// initialized state when restoring it: leaving auditSinkOnce unused
// lets a later runner overwrite the live sink without closing its
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
auditSinkOnce.Do(func() {})
if got := setupAuditSink(); got != previousSink {
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
}
resetAuditIdentityCache()
})
+4 -3
View File
@@ -1657,12 +1657,13 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
CorpID: "corp", UserID: "user", ClientID: "client",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
var cancelCount int
var subscribeCount, cancelCount int
server := 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{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
case "/subscription/user":
subscribeCount++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
case "/subscription/cancel":
cancelCount++
@@ -1697,8 +1698,8 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
t.Fatal("invalid foreground consume succeeded")
}
if cancelCount == 0 {
t.Fatal("failed foreground consume did not clean up subscription")
if subscribeCount != 0 || cancelCount != 0 {
t.Fatalf("invalid local configuration reached subscription control: subscribe=%d cancel=%d", subscribeCount, cancelCount)
}
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
+1 -1
View File
@@ -161,7 +161,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"subscribe-id", "rule", "event-types", "filter",
"foreground", "force", "debug-raw-events",
); err != nil {
return fmt.Errorf("event consume: %w", err)
return fmt.Errorf("event consume: %w", personalSubscriptionValidationError(err))
}
}
personalOpts.Common = commonConsumeOptions{
+552
View File
@@ -0,0 +1,552 @@
// 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"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const personalSubscriptionAttemptOperation = "event.consume.personal.subscribe"
type personalSubscriptionAttemptStore interface {
Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error)
CompleteSuccess(*personal.AttemptClaim) error
CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error)
Release(*personal.AttemptClaim) error
}
var (
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
return personal.NewAttemptStore(workDir)
}
personalSubscriptionAttemptNow = time.Now
)
type personalSubscriptionAttemptItem struct {
eventKey string
fingerprint string
}
type personalSubscriptionAttemptReservation struct {
store personalSubscriptionAttemptStore
claim *personal.AttemptClaim
items []personalSubscriptionAttemptItem
}
type personalSubscriptionFailureClass struct {
retryability personal.Retryability
retryAfter time.Duration
code string
traceID string
reason string
auth bool
}
func reservePersonalSubscriptionAttempts(
workDir string,
client *personal.Client,
identity personal.Identity,
profileSelector string,
plans []personalConsumeOptions,
) (*personalSubscriptionAttemptReservation, error) {
if len(plans) == 0 {
return nil, personalSubscriptionGuardError(
errors.New("personal event: no subscription attempts to reserve"),
)
}
if client == nil {
return nil, personalSubscriptionGuardError(
errors.New("personal event: nil subscription control client"),
)
}
if err := validatePersonalSubscriptionEndpoint(client.BaseURL); err != nil {
return nil, personalSubscriptionValidationError(err)
}
items := make([]personalSubscriptionAttemptItem, 0, len(plans))
specs := make([]personal.AttemptSpec, 0, len(plans))
for _, plan := range plans {
prepared, err := preparePersonalSubscription(identity, plan)
if err != nil {
return nil, personalSubscriptionValidationError(err)
}
fingerprint := personal.Fingerprint(
client.BaseURL,
prepared.Request.IdempotencyKey,
profileSelector,
)
items = append(items, personalSubscriptionAttemptItem{
eventKey: prepared.EventKey,
fingerprint: fingerprint,
})
specs = append(specs, personal.AttemptSpec{
Fingerprint: fingerprint,
EventKey: prepared.EventKey,
})
}
store := personalNewSubscriptionAttemptStore(workDir)
if store == nil {
return nil, personalSubscriptionGuardError(
errors.New("personal event: subscription attempt store is unavailable"),
)
}
claim, err := store.Claim(specs, personalSubscriptionAttemptLease(client, len(specs)))
if err != nil {
var blocked *personal.AttemptBlockedError
if errors.As(err, &blocked) {
return nil, personalSubscriptionBlockedError(blocked)
}
return nil, personalSubscriptionGuardError(err)
}
return &personalSubscriptionAttemptReservation{
store: store,
claim: claim,
items: items,
}, nil
}
func validatePersonalSubscriptionEndpoint(raw string) error {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" ||
(!strings.EqualFold(parsed.Scheme, "http") &&
!strings.EqualFold(parsed.Scheme, "https")) {
if err == nil {
err = errors.New("an absolute http(s) URL is required")
}
return fmt.Errorf("personal event: invalid subscription control endpoint %q: %w", raw, err)
}
return nil
}
func personalSubscriptionAttemptLease(client *personal.Client, batchSize int) time.Duration {
const (
leaseOverhead = 30 * time.Second
minLease = time.Minute
maxLease = 10 * time.Minute
)
if batchSize < 1 {
batchSize = 1
}
timeout := config.HTTPTimeout
if client != nil && client.HTTPClient != nil && client.HTTPClient.Timeout > 0 {
timeout = client.HTTPClient.Timeout
}
maxRequestBudget := maxLease - leaseOverhead
if timeout <= 0 || timeout > maxRequestBudget/time.Duration(batchSize) {
return maxLease
}
lease := timeout*time.Duration(batchSize) + leaseOverhead
if lease < minLease {
return minLease
}
return lease
}
func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
if r == nil {
return nil
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(
errors.New("personal event: subscription attempt reservation is incomplete"),
)
}
if err := r.store.CompleteSuccess(r.claim); err != nil {
return personalSubscriptionGuardError(err)
}
return nil
}
func (r *personalSubscriptionAttemptReservation) completeFailure(
ctx context.Context,
failedIndex int,
succeededCount int,
cause error,
override *personalSubscriptionFailureClass,
) error {
if r == nil {
return cause
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(errors.Join(
cause,
errors.New("personal event: subscription attempt reservation is incomplete"),
))
}
if failedIndex < 0 || failedIndex >= len(r.items) ||
succeededCount < 0 || succeededCount > failedIndex {
return personalSubscriptionGuardError(errors.Join(
cause,
errors.New("personal event: invalid subscription attempt completion indexes"),
))
}
if personalSubscriptionCanceled(ctx, cause) {
// Cancellation is not a failed attempt. Restoring the claim normally
// completes immediately; if the lock cannot be acquired, leaving the
// finite lease behind is still safer than recording a false failure.
_ = r.store.Release(r.claim)
return cause
}
classification := classifyPersonalSubscriptionFailure(cause, personalSubscriptionAttemptNow())
if override != nil {
classification = *override
}
succeeded := make([]string, 0, succeededCount)
for i := 0; i < succeededCount; i++ {
succeeded = append(succeeded, r.items[i].fingerprint)
}
hold, err := r.store.CompleteFailure(r.claim, succeeded, personal.AttemptFailure{
Fingerprint: r.items[failedIndex].fingerprint,
Retryability: classification.retryability,
RetryAfter: classification.retryAfter,
ErrorCode: classification.code,
TraceID: classification.traceID,
})
if err != nil {
return personalSubscriptionGuardError(errors.Join(cause, err))
}
return personalSubscriptionFailureError(cause, classification, hold)
}
func personalSubscriptionCanceled(ctx context.Context, err error) bool {
if errors.Is(err, context.Canceled) {
return true
}
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
}
func classifyPersonalSubscriptionFailure(err error, now time.Time) personalSubscriptionFailureClass {
classification := personalSubscriptionFailureClass{
retryability: personal.RetryabilityUnknown,
reason: "personal_subscription_unknown",
}
var apiErr *personal.APIError
if errors.As(err, &apiErr) {
classification.code = strings.TrimSpace(apiErr.Code)
classification.traceID = strings.TrimSpace(apiErr.TraceID)
classification.retryAfter = personalAPIRetryDelay(apiErr, now)
classification.auth = personalSubscriptionAuthFailure(apiErr.HTTPStatus, apiErr.Code)
switch {
case apiErr.Retryable != nil && *apiErr.Retryable:
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_server_retryable"
case apiErr.Retryable != nil:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_server_non_retryable"
case apiErr.HTTPStatus == http.StatusRequestTimeout ||
apiErr.HTTPStatus == http.StatusTooEarly ||
apiErr.HTTPStatus == http.StatusTooManyRequests ||
apiErr.HTTPStatus >= http.StatusInternalServerError:
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_transient_http"
case apiErr.HTTPStatus == http.StatusUnauthorized ||
apiErr.HTTPStatus == http.StatusForbidden:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_auth"
case personalSubscriptionTerminalBusinessCode(apiErr.Code):
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_business_rejected"
case personalSubscriptionErrorHasSubscribeID(apiErr):
// A few legacy/proxy error shapes include an existing subscription
// ID without a stable server contract. Keep the response as an
// error, but do not turn that unverified shape into a one-hour hold.
classification.reason = "personal_subscription_unverified_existing_id"
case apiErr.HTTPStatus >= http.StatusBadRequest:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_http_rejected"
}
return classification
}
if errors.Is(err, context.DeadlineExceeded) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_timeout"
return classification
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
if strings.EqualFold(strings.TrimSpace(urlErr.Op), "parse") {
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_invalid"
return classification
}
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
var netErr net.Error
if errors.As(err, &netErr) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
lower := strings.ToLower(err.Error())
if strings.Contains(lower, "access token") || strings.Contains(lower, "oauth") {
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_auth"
classification.auth = true
}
return classification
}
func personalSubscriptionErrorHasSubscribeID(apiErr *personal.APIError) bool {
if apiErr == nil {
return false
}
subscribeID, ok := apiErr.Details["subscribe_id"].(string)
return ok && strings.TrimSpace(subscribeID) != ""
}
func personalAPIRetryDelay(apiErr *personal.APIError, now time.Time) time.Duration {
if apiErr == nil {
return 0
}
var delay time.Duration
if apiErr.RetryAfterSeconds != nil {
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(*apiErr.RetryAfterSeconds))
}
if apiErr.NextRetryAt != nil {
delay = maxPersonalRetryDelay(delay, apiErr.NextRetryAt.Sub(now))
}
if raw, ok := apiErr.Details["retry_after"].(string); ok {
raw = strings.TrimSpace(raw)
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(seconds))
} else if next, err := http.ParseTime(raw); err == nil {
delay = maxPersonalRetryDelay(delay, next.Sub(now))
}
}
return delay
}
func personalRetrySeconds(seconds int64) time.Duration {
if seconds <= 0 {
return 0
}
if seconds > math.MaxInt64/int64(time.Second) {
return time.Duration(math.MaxInt64)
}
return time.Duration(seconds) * time.Second
}
func maxPersonalRetryDelay(left, right time.Duration) time.Duration {
if right > left {
return right
}
return left
}
func personalSubscriptionTerminalBusinessCode(raw string) bool {
code := strings.ToUpper(strings.TrimSpace(raw))
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
code = replacer.Replace(code)
// Keep this list deliberately conservative. Unknown server codes must stay
// unknown so a newly introduced transient condition cannot accidentally be
// converted into a one-hour terminal hold.
switch code {
case "INVALID_PARAM", "INVALID_PARAMS", "INVALID_PARAMETER", "INVALID_PARAMETERS",
"ILLEGAL_PARAM", "ILLEGAL_PARAMS", "ILLEGAL_PARAMETER", "ILLEGAL_PARAMETERS",
"PARAM_ERROR", "PARAMETER_ERROR",
"CLIENT_ID_REQUIRED", "SOURCE_ID_REQUIRED", "EVENT_KEY_REQUIRED", "RULE_TYPE_REQUIRED",
"NO_AUTH", "NO_PERMISSION", "PERMISSION_DENIED", "ACCESS_DENIED",
"FORBIDDEN", "UNAUTHORIZED",
"NOT_FOUND", "NOT_EXIST", "NOT_SUPPORTED", "UNSUPPORTED",
"UNIFIED_APP_ID_NOT_FOUND":
return true
}
// Resource-qualified variants are stable business-rejection shapes. Avoid
// broad substring matching (for example, RETRY_REQUIRED must remain
// unknown).
for _, suffix := range []string{
"_NOT_BELONG_TO_ORG",
"_DOES_NOT_BELONG_TO_ORG",
"_NOT_FOUND",
"_NOT_EXIST",
"_NOT_SUPPORTED",
"_UNSUPPORTED",
"_NO_PERMISSION",
"_PERMISSION_DENIED",
"_ACCESS_DENIED",
} {
if strings.HasSuffix(code, suffix) {
return true
}
}
return false
}
func personalSubscriptionAuthFailure(status int, rawCode string) bool {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true
}
code := strings.ToUpper(strings.TrimSpace(rawCode))
for _, marker := range []string{
"NO_AUTH", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION", "ACCESS_DENIED",
} {
if strings.Contains(code, marker) {
return true
}
}
return false
}
func personalSubscriptionFailureError(
cause error,
classification personalSubscriptionFailureClass,
hold personal.AttemptHold,
) error {
options := personalSubscriptionErrorOptions(
classification.retryability,
hold.RetryAfter,
hold.NextAllowedAt,
classification.code,
classification.traceID,
classification.reason,
cause,
)
message := cause.Error()
if classification.retryability == personal.RetryabilityNonRetryable {
if classification.auth {
return apperrors.NewAuth(message, options...)
}
return apperrors.NewValidation(message, options...)
}
return apperrors.NewAPI(message, options...)
}
func personalSubscriptionBlockedError(blocked *personal.AttemptBlockedError) error {
if blocked == nil {
return personalSubscriptionGuardError(
errors.New("personal event: nil blocked subscription attempt"),
)
}
reason := "personal_subscription_" + string(blocked.State)
options := personalSubscriptionErrorOptions(
blocked.Retryability,
blocked.RetryAfter,
blocked.NextAllowedAt,
blocked.ErrorCode,
blocked.TraceID,
reason,
blocked,
)
if blocked.Retryability == personal.RetryabilityNonRetryable {
if personalSubscriptionAuthFailure(0, blocked.ErrorCode) {
return apperrors.NewAuth(blocked.Error(), options...)
}
return apperrors.NewValidation(blocked.Error(), options...)
}
return apperrors.NewAPI(blocked.Error(), options...)
}
func personalSubscriptionErrorOptions(
retryability personal.Retryability,
retryAfter time.Duration,
nextRetryAt time.Time,
code string,
traceID string,
reason string,
cause error,
) []apperrors.Option {
options := []apperrors.Option{
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason(reason),
apperrors.WithCause(cause),
}
if retryable, known := retryability.Value(); known {
options = append(options, apperrors.WithRetryable(retryable))
}
if retryAfter > 0 {
options = append(options, apperrors.WithRetryAfterSeconds(ceilPersonalRetrySeconds(retryAfter)))
}
if !nextRetryAt.IsZero() {
options = append(options, apperrors.WithNextRetryAt(nextRetryAt))
}
if code != "" || traceID != "" {
options = append(options, apperrors.WithServerDiag(apperrors.ServerDiagnostics{
TraceID: strings.TrimSpace(traceID),
ServerErrorCode: strings.TrimSpace(code),
}))
}
return options
}
func ceilPersonalRetrySeconds(delay time.Duration) int64 {
if delay <= 0 {
return 0
}
seconds := int64(delay / time.Second)
if delay%time.Second != 0 {
seconds++
}
return seconds
}
func personalSubscriptionGuardError(cause error) error {
if cause == nil {
cause = errors.New("personal event: subscription attempt guard failed")
}
return apperrors.NewInternal(
fmt.Sprintf("personal subscription attempt guard failed: %v", cause),
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason("personal_subscription_guard_failed"),
apperrors.WithRetryable(false),
apperrors.WithCause(cause),
)
}
func personalSubscriptionValidationError(cause error) error {
if cause == nil {
cause = errors.New("personal event: invalid subscription parameters")
}
return apperrors.NewValidation(
cause.Error(),
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason("personal_subscription_invalid"),
apperrors.WithRetryable(false),
apperrors.WithCause(cause),
)
}
func personalSubscriptionLocalFailure() personalSubscriptionFailureClass {
return personalSubscriptionFailureClass{
retryability: personal.RetryabilityUnknown,
reason: "personal_subscription_local_failure",
}
}
File diff suppressed because it is too large. Load diff
+229 -113
View File
@@ -227,7 +227,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
return personalSubscriptionValidationError(err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
@@ -238,7 +238,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
@@ -255,12 +255,12 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
}
cfg := consume.Config{
@@ -284,16 +284,100 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return personalConsumeRun(ctx, cfg)
if err := personalConsumeRun(ctx, cfg); err != nil {
return personalSubscriptionValidationError(err)
}
return nil
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Complete all local validation before creating a remote subscription.
// Otherwise an invalid output mode can repeatedly create and roll back a
// valid subscription when an outer agent relaunches the command.
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(cfg); err != nil {
return personalSubscriptionValidationError(err)
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return personalSubscriptionValidationError(err)
}
}
var foregroundSource *source.PersonalSource
if opts.Common.Foreground {
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
return personalSubscriptionValidationError(err)
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
workDir,
client,
identity,
spawnProfileSelector,
[]personalConsumeOptions{opts},
)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
}
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
err = attempt.completeFailure(ctx, 0, 0, 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 sub == nil {
err = attempt.completeFailure(
ctx,
0,
0,
errors.New("personal event: server returned an empty subscription"),
nil,
)
return fmt.Errorf("event consume --as user: %w", err)
}
if strings.TrimSpace(sub.SubscribeID) == "" {
err = attempt.completeFailure(
ctx,
0,
0,
errors.New("personal event: server returned empty subscribe_id"),
nil,
)
return fmt.Errorf("event consume --as user: %w", err)
}
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -303,11 +387,21 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
return fmt.Errorf("event consume --as user: save run state: %w", err)
wrapped := fmt.Errorf("save run state: %w", err)
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
return fmt.Errorf("event consume --as user: %w", wrapped)
}
cleanup := func() {
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
// unsubscribed on exit
@@ -317,59 +411,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup()
defer cleanup(context.Background())
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
ReadySubscribeID: sub.SubscribeID,
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).
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
cfg.EventKey = eventKey
cfg.ReadySubscribeID = sub.SubscribeID
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 := personalValidateConsumeConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := personalNewStreamSource(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,
@@ -378,18 +430,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
cleanup(context.Background())
}
return err
}
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
cleanup(context.Background())
}
return err
}
@@ -403,7 +455,7 @@ type personalMultiSubscription struct {
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
plans, err := preparePersonalMultiOptions(opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
@@ -414,7 +466,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
projector := personalEventProjector(false, opts.Flatten)
@@ -428,15 +480,16 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
baseCfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -451,11 +504,11 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(baseCfg); err != nil {
return err
return personalSubscriptionValidationError(err)
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
return err
return personalSubscriptionValidationError(err)
}
}
if opts.Common.DryRun {
@@ -464,13 +517,23 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
attempt, err := reservePersonalSubscriptionAttempts(
workDir,
client,
identity,
spawnProfileSelector,
plans,
)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
created := make([]personalMultiSubscription, 0, len(plans))
cleanup := func() {
cleanup := func(cleanupCtx context.Context) {
ids := make([]string, 0, len(created))
for i := len(created) - 1; i >= 0; i-- {
id := strings.TrimSpace(created[i].Sub.SubscribeID)
ids = append(ids, id)
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
}
}
@@ -480,26 +543,45 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
}
}
failAndCleanup := func(
failedIndex int,
succeededCount int,
cause error,
override *personalSubscriptionFailureClass,
) error {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, cause) {
cleanupCtx = ctx
}
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
// Persist the hold (or release a canceled claim) before any potentially
// slow remote rollback. Otherwise the attempt lease can expire while
// deleting earlier subscriptions and admit a duplicate create batch.
cleanup(cleanupCtx)
return completed
}
seenSubscribeIDs := make(map[string]struct{}, len(plans))
for _, plan := range plans {
for i, plan := range plans {
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
if err != nil {
cleanup()
err = failAndCleanup(i, len(created), err, nil)
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
}
if sub == nil {
cleanup()
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
id := strings.TrimSpace(sub.SubscribeID)
if id == "" {
cleanup()
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
if _, exists := seenSubscribeIDs[id]; exists {
_ = personalDeleteSubscription(client, context.Background(), id)
cleanup()
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
seenSubscribeIDs[id] = struct{}{}
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
@@ -512,11 +594,17 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
cleanup()
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
cause := fmt.Errorf("save run state for %s: %w", eventKey, err)
classification := personalSubscriptionLocalFailure()
cause = failAndCleanup(i, len(created)-1, cause, &classification)
return fmt.Errorf("event consume --as user: %w", cause)
}
}
defer cleanup()
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
defer cleanup(context.Background())
specs := make([]consume.ConsumerSpec, 0, len(created))
for _, item := range created {
@@ -705,6 +793,59 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
Request personal.CreateSubscriptionRequest
}
func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) {
if strings.TrimSpace(opts.EventKey) == "" {
return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
if err != nil {
return personalPreparedSubscription{}, err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return personalPreparedSubscription{}, 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())
}
return personalPreparedSubscription{
EventKey: opts.EventKey,
RuleType: ruleType,
Request: req,
}, nil
}
func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) {
sub, err := personalCreateSubscription(client, ctx, plan.Request)
if err != nil {
return nil, "", "", err
}
return sub, plan.EventKey, plan.RuleType, nil
}
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 := personalGetSubscription(client, ctx, opts.SubscribeID)
@@ -727,42 +868,11 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
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,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
plan, err := preparePersonalSubscription(identity, opts)
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 := personalCreateSubscription(client, ctx, req)
if err != nil {
return nil, "", "", err
}
return sub, opts.EventKey, ruleType, nil
return createPreparedPersonalSubscription(ctx, client, plan)
}
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
@@ -829,7 +939,7 @@ func ensurePublicPersonalEvent(eventKey string) error {
if eventKey == "" {
return nil
}
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
if def, ok := personalLookupDefinition(eventKey); ok && !def.Public {
return personal.PublicAvailabilityError(eventKey)
}
return nil
@@ -1091,6 +1201,12 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
version := strings.TrimSpace(RawVersion())
if version == "" {
version = "unknown"
}
client.ClientVersion = version
client.UserAgent = "dws-cli/" + version
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
@@ -77,6 +77,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldEnsure := personalEnsureSubscription
oldAttemptStore := personalNewSubscriptionAttemptStore
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
@@ -88,6 +89,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalEnsureSubscription = oldEnsure
personalNewSubscriptionAttemptStore = oldAttemptStore
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
@@ -97,6 +99,9 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
})
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return personalNoopAttemptStore{}
}
wantErr := errors.New("consume")
cmd := newPersonalCoverageCommand()
@@ -154,11 +159,11 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, wantErr
}
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != 0 {
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
}
before := deletes
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != before {
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
+224 -2
View File
@@ -12,6 +12,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
@@ -251,7 +252,7 @@ func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
}
}
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
@@ -379,7 +380,158 @@ func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
}
}
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
calls := 0
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", errors.New("second subscription failed")
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
order = append(order, "delete")
return nil
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil {
t.Fatal("partial creation unexpectedly succeeded")
}
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
t.Fatalf("failure/rollback order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error {
return errors.New("state disk failed")
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
order = append(order, "delete")
return nil
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
})
if err == nil {
t.Fatal("run-state failure unexpectedly succeeded")
}
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
t.Fatalf("failure/rollback order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
calls := 0
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", context.Canceled
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
if cleanupCtx.Err() == nil {
t.Fatal("cancellation cleanup received a live context")
}
order = append(order, "delete")
return cleanupCtx.Err()
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cmd := newPersonalCoverageCommand()
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v", err)
}
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
t.Fatalf("release/canceled-cleanup order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
for _, test := range []struct {
name string
ensure func(int, personalConsumeOptions) *personal.Subscription
@@ -641,16 +793,21 @@ func installPersonalManySeams(t *testing.T) func() {
oldIdentity := personalResolveEventIdentity
oldLookup := personalLookupDefinition
oldEnsure := personalEnsureSubscription
oldAttemptStore := personalNewSubscriptionAttemptStore
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldRunMany := personalConsumeRunMany
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return personalNoopAttemptStore{}
}
return func() {
personalResolveEventIdentity = oldIdentity
personalLookupDefinition = oldLookup
personalEnsureSubscription = oldEnsure
personalNewSubscriptionAttemptStore = oldAttemptStore
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
@@ -659,3 +816,68 @@ func installPersonalManySeams(t *testing.T) func() {
personalValidateNoOutputConflict = oldConflict
}
}
type personalNoopAttemptStore struct{}
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
fingerprints := make([]string, 0, len(specs))
for _, spec := range specs {
fingerprints = append(fingerprints, spec.Fingerprint)
}
return &personal.AttemptClaim{
AttemptID: "test-attempt",
Fingerprints: fingerprints,
}, nil
}
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
return nil
}
func (personalNoopAttemptStore) CompleteFailure(
_ *personal.AttemptClaim,
_ []string,
failure personal.AttemptFailure,
) (personal.AttemptHold, error) {
return personal.AttemptHold{
Fingerprint: failure.Fingerprint,
Retryability: failure.Retryability,
}, nil
}
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
return nil
}
type personalOrderingAttemptStore struct {
order *[]string
}
func (s *personalOrderingAttemptStore) Claim(
specs []personal.AttemptSpec,
lease time.Duration,
) (*personal.AttemptClaim, error) {
return personalNoopAttemptStore{}.Claim(specs, lease)
}
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
*s.order = append(*s.order, "complete_success")
return nil
}
func (s *personalOrderingAttemptStore) CompleteFailure(
_ *personal.AttemptClaim,
_ []string,
failure personal.AttemptFailure,
) (personal.AttemptHold, error) {
*s.order = append(*s.order, "complete_failure")
return personal.AttemptHold{
Fingerprint: failure.Fingerprint,
Retryability: failure.Retryability,
}, nil
}
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
*s.order = append(*s.order, "release")
return nil
}
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:8b6f42e8729815d12a9d981d406b5e323eef110806f03502803fdfca0e112af2",
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"coverage": {
"surface_products": 26,
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:8b6f42e8729815d12a9d981d406b5e323eef110806f03502803fdfca0e112af2",
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_files": 160,
"hint_files": 54,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"version": 1,
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:b227b19dccbe78d2df26dc283449f7a85ef490a0d7e7f2d644a411230a7d850c",
"source_hash": "sha256:862734fb976a4c9c78d010b7391c74822f37805f5acc650551f4ac60f701c13a",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:8b6f42e8729815d12a9d981d406b5e323eef110806f03502803fdfca0e112af2",
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"surface_products": 26,
"surface_tools": 845,
+1 -1
View File
@@ -25,7 +25,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
}),
)
typed := err.(*Error)
if typed.Retryable || typed.ServerDiag.TraceID != "trace" {
if typed.Retryable || !typed.RetryableSet || typed.ServerDiag.TraceID != "trace" {
t.Fatalf("diagnostics options = %#v", typed)
}
if (ServerDiagnostics{TraceID: "trace"}).IsEmpty() {
+1
View File
@@ -44,6 +44,7 @@ func WithServerDiag(diag ServerDiagnostics) Option {
// Override retryable if server explicitly specified.
if diag.ServerRetryable != nil {
e.Retryable = *diag.ServerRetryable
e.RetryableSet = true
}
}
}
+59 -18
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
@@ -41,20 +42,23 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -107,6 +111,31 @@ func WithServerKey(serverKey string) Option {
func WithRetryable(retryable bool) Option {
return func(err *Error) {
err.Retryable = retryable
err.RetryableSet = true
}
}
// WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance.
func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) {
if seconds < 0 {
return
}
value := seconds
err.RetryAfterSeconds = &value
}
}
// WithNextRetryAt records the absolute time at which a retry may be attempted.
func WithNextRetryAt(next time.Time) Option {
return func(err *Error) {
if next.IsZero() {
return
}
value := next.UTC()
err.NextRetryAt = &value
}
}
@@ -266,8 +295,14 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Retryable {
errorPayload["retryable"] = true
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
if typed.RetryAfterSeconds != nil {
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
}
if typed.NextRetryAt != nil {
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
if typed.Hint != "" {
errorPayload["hint"] = typed.Hint
@@ -383,8 +418,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
lines = append(lines, tui.Dim(line))
}
if typed.Retryable {
lines = append(lines, tui.Warning("Retryable: true"))
if typed.RetryableSet {
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
}
if typed.RetryAfterSeconds != nil {
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
}
if typed.NextRetryAt != nil {
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
}
// Always shown when present: Trace ID, Server Code
+94
View File
@@ -17,6 +17,7 @@ import (
stderrors "errors"
"strings"
"testing"
"time"
)
func TestExitCodeByCategory(t *testing.T) {
@@ -77,6 +78,99 @@ func TestPrintJSON(t *testing.T) {
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
t.Parallel()
next := time.Date(2026, time.July, 30, 4, 5, 6, 0, time.FixedZone("CST", 8*60*60))
tests := []struct {
name string
err error
wantRetryable string
wantRetryAfter bool
wantNextRetryAt bool
}{
{
name: "unknown is omitted",
err: NewAPI("unknown"),
},
{
name: "explicit false is preserved",
err: NewValidation("terminal", WithRetryable(false)),
wantRetryable: `"retryable": false`,
},
{
name: "explicit true with timing",
err: NewAPI("transient", WithRetryable(true), WithRetryAfterSeconds(30), WithNextRetryAt(next)),
wantRetryable: `"retryable": true`,
wantRetryAfter: true,
wantNextRetryAt: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var jsonOut strings.Builder
if err := PrintJSON(&jsonOut, tt.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
gotJSON := jsonOut.String()
if tt.wantRetryable == "" {
if strings.Contains(gotJSON, `"retryable"`) {
t.Fatalf("unknown retryability must be omitted: %s", gotJSON)
}
} else if !strings.Contains(gotJSON, tt.wantRetryable) {
t.Fatalf("missing %s in %s", tt.wantRetryable, gotJSON)
}
if got := strings.Contains(gotJSON, `"retry_after_seconds": 30`); got != tt.wantRetryAfter {
t.Fatalf("retry_after_seconds presence = %v, want %v: %s", got, tt.wantRetryAfter, gotJSON)
}
if got := strings.Contains(gotJSON, `"next_retry_at": "2026-07-29T20:05:06Z"`); got != tt.wantNextRetryAt {
t.Fatalf("next_retry_at presence = %v, want %v: %s", got, tt.wantNextRetryAt, gotJSON)
}
var humanOut strings.Builder
if err := PrintHuman(&humanOut, tt.err); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
gotHuman := humanOut.String()
if tt.wantRetryable == "" {
if strings.Contains(gotHuman, "Retryable:") {
t.Fatalf("unknown retryability must be omitted: %s", gotHuman)
}
} else {
want := "Retryable: true"
if strings.Contains(tt.wantRetryable, "false") {
want = "Retryable: false"
}
if !strings.Contains(gotHuman, want) {
t.Fatalf("missing %q in %s", want, gotHuman)
}
}
if tt.wantRetryAfter && !strings.Contains(gotHuman, "Retry After: 30s") {
t.Fatalf("missing retry delay in %s", gotHuman)
}
if tt.wantNextRetryAt && !strings.Contains(gotHuman, "Next Retry At: 2026-07-29T20:05:06Z") {
t.Fatalf("missing next retry time in %s", gotHuman)
}
})
}
}
func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T) {
t.Parallel()
err := NewAPI(
"invalid timing",
WithRetryAfterSeconds(-1),
WithNextRetryAt(time.Time{}),
).(*Error)
if err.RetryAfterSeconds != nil || err.NextRetryAt != nil {
t.Fatalf("invalid retry timing was retained: %#v", err)
}
}
func TestPrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
+866
View File
@@ -0,0 +1,866 @@
// 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/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
// AttemptStateFileName stores retry suppression state for personal
// subscription creation. It intentionally does not share the successful
// subscription run-state file.
AttemptStateFileName = "personal_subscription_attempts.json"
// AttemptStateLockFileName serializes cross-process attempt-state changes.
AttemptStateLockFileName = "personal_subscription_attempts.lock"
attemptStateVersion = 1
attemptLockWaitTimeout = 5 * time.Second
attemptLockRetryDelay = 25 * time.Millisecond
attemptFailureReset = 24 * time.Hour
attemptTerminalHold = time.Hour
attemptMaxFieldLength = 256
)
var (
// ErrAttemptClaimStale means another process replaced at least one claimed
// fingerprint. Completion is rejected as a unit so an old process cannot
// overwrite newer state.
ErrAttemptClaimStale = errors.New("personal event: subscription attempt claim is stale")
)
// AttemptState is the persisted lifecycle state of one subscription attempt.
type AttemptState string
const (
AttemptStateInFlight AttemptState = "in_flight"
AttemptStateCooldown AttemptState = "cooldown"
AttemptStateTerminalHold AttemptState = "terminal_hold"
)
// Retryability preserves the distinction between an explicit server decision
// and an error for which the server did not provide retry guidance.
type Retryability string
const (
RetryabilityUnknown Retryability = "unknown"
RetryabilityRetryable Retryability = "retryable"
RetryabilityNonRetryable Retryability = "non_retryable"
)
// Value converts retryability to a bool while preserving whether it is known.
func (r Retryability) Value() (value bool, known bool) {
switch r {
case RetryabilityRetryable:
return true, true
case RetryabilityNonRetryable:
return false, true
default:
return false, false
}
}
// AttemptSpec identifies one normalized subscription request. Fingerprint must
// come from Fingerprint; only the digest and the non-sensitive event key are
// persisted.
type AttemptSpec struct {
Fingerprint string
EventKey string
}
// AttemptClaim is the ownership token returned by Claim. Callers must finish
// it with CompleteSuccess, CompleteFailure, or Release.
type AttemptClaim struct {
AttemptID string
Fingerprints []string
previous map[string]attemptPrevious
}
type attemptPrevious struct {
record attemptRecord
present bool
}
// AttemptBlockedError reports a local suppression decision. It never includes
// the raw endpoint, idempotency key, profile selector, rule parameter, or
// filter.
type AttemptBlockedError struct {
Fingerprint string
EventKey string
State AttemptState
Retryability Retryability
RetryAfter time.Duration
NextAllowedAt time.Time
FailureCount int
ErrorCode string
TraceID string
}
func (e *AttemptBlockedError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf(
"personal event: subscription attempt %s until %s",
e.State,
e.NextAllowedAt.UTC().Format(time.RFC3339),
)
}
// AttemptFailure describes the one failed item in a claimed batch.
type AttemptFailure struct {
Fingerprint string
Retryability Retryability
RetryAfter time.Duration
ErrorCode string
TraceID string
}
// AttemptHold is the persisted suppression decision after CompleteFailure.
type AttemptHold struct {
Fingerprint string
State AttemptState
Retryability Retryability
RetryAfter time.Duration
NextAllowedAt time.Time
FailureCount int
}
// AttemptStoreOption customizes an AttemptStore.
type AttemptStoreOption func(*AttemptStore)
// WithAttemptClock installs a clock, primarily for deterministic tests.
func WithAttemptClock(now func() time.Time) AttemptStoreOption {
return func(store *AttemptStore) {
if now != nil {
store.now = now
}
}
}
// WithAttemptIDGenerator installs an attempt-ID generator. IDs are persisted
// only as CAS tokens and must not contain request data.
func WithAttemptIDGenerator(generate func() (string, error)) AttemptStoreOption {
return func(store *AttemptStore) {
if generate != nil {
store.newID = generate
}
}
}
// AttemptStore coordinates personal subscription creation across CLI
// processes sharing one identity work directory.
type AttemptStore struct {
workDir string
now func() time.Time
newID func() (string, error)
readFile func(string) ([]byte, error)
mkdirAll func(string, os.FileMode) error
tryAcquire func(string) (*eventlock.File, error)
remove func(string) error
marshal func(any, string, string) ([]byte, error)
writeFile func(string, []byte, os.FileMode) error
chmod func(string, os.FileMode) error
rename func(string, string) error
sleep func(time.Duration)
}
// NewAttemptStore creates a fail-closed persistent attempt guard rooted in the
// identity-specific personal event work directory.
func NewAttemptStore(workDir string, options ...AttemptStoreOption) *AttemptStore {
store := &AttemptStore{
workDir: strings.TrimSpace(workDir),
now: time.Now,
newID: newAttemptID,
readFile: os.ReadFile,
mkdirAll: os.MkdirAll,
tryAcquire: eventlock.TryAcquire,
remove: os.Remove,
marshal: json.MarshalIndent,
writeFile: os.WriteFile,
chmod: os.Chmod,
rename: os.Rename,
sleep: time.Sleep,
}
for _, option := range options {
if option != nil {
option(store)
}
}
return store
}
// Fingerprint hashes an endpoint, the existing subscription idempotency key,
// and optional profile selectors into a stable non-reversible store key.
// Passing the active selector ensures two profiles resolving to the same
// corp/user can recover independently after a profile change.
func Fingerprint(endpoint, idempotencyKey string, profileSelector ...string) string {
endpoint = normalizeAttemptEndpoint(endpoint)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if endpoint == "" || idempotencyKey == "" {
return ""
}
h := sha256.New()
writeFingerprintPart(h, "dws-personal-subscription-attempt-v1")
writeFingerprintPart(h, endpoint)
writeFingerprintPart(h, idempotencyKey)
for _, selector := range profileSelector {
if selector = strings.TrimSpace(selector); selector != "" {
writeFingerprintPart(h, selector)
}
}
return hex.EncodeToString(h.Sum(nil))
}
type fingerprintWriter interface {
Write([]byte) (int, error)
}
func writeFingerprintPart(w fingerprintWriter, value string) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
_, _ = w.Write(size[:])
_, _ = w.Write([]byte(value))
}
func normalizeAttemptEndpoint(raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed.String()
}
// Claim atomically reserves every fingerprint in specs. If any fingerprint is
// still suppressed, none are reserved and an AttemptBlockedError is returned.
func (s *AttemptStore) Claim(specs []AttemptSpec, lease time.Duration) (*AttemptClaim, error) {
normalized, err := normalizeAttemptSpecs(specs)
if err != nil {
return nil, err
}
if lease <= 0 {
return nil, errors.New("personal event: subscription attempt lease must be positive")
}
if err := s.validate(); err != nil {
return nil, err
}
attemptID, err := s.newID()
if err != nil {
return nil, fmt.Errorf("personal event: generate subscription attempt id: %w", err)
}
attemptID = strings.TrimSpace(attemptID)
if attemptID == "" || len(attemptID) > attemptMaxFieldLength {
return nil, errors.New("personal event: generated subscription attempt id is invalid")
}
now := s.now().UTC()
claim := &AttemptClaim{
AttemptID: attemptID,
Fingerprints: make([]string, 0, len(normalized)),
previous: make(map[string]attemptPrevious, len(normalized)),
}
var blocked *AttemptBlockedError
err = s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
pruneAttemptRecords(state, now)
for _, spec := range normalized {
record, ok := state.Records[spec.Fingerprint]
if !ok {
continue
}
if candidate := blockedAttempt(record, now); candidate != nil {
blocked = candidate
return nil
}
}
for _, spec := range normalized {
previous, present := state.Records[spec.Fingerprint]
claim.previous[spec.Fingerprint] = attemptPrevious{
record: previous,
present: present,
}
claim.Fingerprints = append(claim.Fingerprints, spec.Fingerprint)
record := previous
record.Fingerprint = spec.Fingerprint
record.EventKey = spec.EventKey
record.State = AttemptStateInFlight
record.AttemptID = attemptID
record.LeaseUntil = now.Add(lease)
record.NextAllowedAt = time.Time{}
record.Retryability = ""
record.ErrorCode = ""
record.TraceID = ""
state.Records[spec.Fingerprint] = record
}
return s.write(state)
})
if err != nil {
return nil, err
}
if blocked != nil {
return nil, blocked
}
return claim, nil
}
// CompleteSuccess clears failure history for every item in a successful
// claimed batch.
func (s *AttemptStore) CompleteSuccess(claim *AttemptClaim) error {
if err := validateAttemptClaim(claim); err != nil {
return err
}
if err := s.validate(); err != nil {
return err
}
return s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
delete(state.Records, fingerprint)
}
return s.write(state)
})
}
// CompleteFailure records the failed item, clears successfully completed
// items, and restores every unexecuted item to its exact pre-claim state.
func (s *AttemptStore) CompleteFailure(
claim *AttemptClaim,
succeeded []string,
failure AttemptFailure,
) (AttemptHold, error) {
var zero AttemptHold
if err := validateAttemptClaim(claim); err != nil {
return zero, err
}
failure.Fingerprint = strings.TrimSpace(failure.Fingerprint)
if !containsFingerprint(claim.Fingerprints, failure.Fingerprint) {
return zero, errors.New("personal event: failed fingerprint is not part of the attempt claim")
}
if !validRetryability(failure.Retryability) {
return zero, fmt.Errorf("personal event: invalid retryability %q", failure.Retryability)
}
succeededSet, err := normalizeSucceededFingerprints(claim, succeeded, failure.Fingerprint)
if err != nil {
return zero, err
}
if err := s.validate(); err != nil {
return zero, err
}
now := s.now().UTC()
var hold AttemptHold
err = s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
switch {
case fingerprint == failure.Fingerprint:
previous := claim.previous[fingerprint]
record := previous.record
count := record.FailureCount
if record.LastFailureAt.IsZero() || now.Sub(record.LastFailureAt) >= attemptFailureReset {
count = 0
}
count++
delay, stateName := attemptFailureDelay(fingerprint, count, failure)
record.Fingerprint = fingerprint
record.EventKey = state.Records[fingerprint].EventKey
record.State = stateName
record.AttemptID = ""
record.LeaseUntil = time.Time{}
record.FailureCount = count
record.LastFailureAt = now
record.NextAllowedAt = now.Add(delay)
record.Retryability = failure.Retryability
record.ErrorCode = boundedAttemptField(failure.ErrorCode)
record.TraceID = boundedAttemptField(failure.TraceID)
state.Records[fingerprint] = record
hold = AttemptHold{
Fingerprint: fingerprint,
State: stateName,
Retryability: failure.Retryability,
RetryAfter: delay,
NextAllowedAt: record.NextAllowedAt,
FailureCount: count,
}
case succeededSet[fingerprint]:
delete(state.Records, fingerprint)
default:
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
}
}
return s.write(state)
})
if err != nil {
return zero, err
}
return hold, nil
}
// Release restores every claimed item without recording a remote failure. It
// is intended for local failures before a subscription request is sent.
func (s *AttemptStore) Release(claim *AttemptClaim) error {
if err := validateAttemptClaim(claim); err != nil {
return err
}
if err := s.validate(); err != nil {
return err
}
return s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
}
return s.write(state)
})
}
func (s *AttemptStore) validate() error {
if s == nil {
return errors.New("personal event: nil subscription attempt store")
}
if s.workDir == "" {
return errors.New("personal event: subscription attempt work directory is required")
}
if s.now == nil || s.newID == nil || s.readFile == nil || s.mkdirAll == nil ||
s.tryAcquire == nil || s.remove == nil || s.marshal == nil ||
s.writeFile == nil || s.chmod == nil || s.rename == nil || s.sleep == nil {
return errors.New("personal event: subscription attempt store is not initialized")
}
return nil
}
func normalizeAttemptSpecs(specs []AttemptSpec) ([]AttemptSpec, error) {
if len(specs) == 0 {
return nil, errors.New("personal event: at least one subscription attempt is required")
}
out := make([]AttemptSpec, 0, len(specs))
seen := make(map[string]struct{}, len(specs))
for _, spec := range specs {
spec.Fingerprint = strings.TrimSpace(spec.Fingerprint)
spec.EventKey = strings.TrimSpace(spec.EventKey)
if !validAttemptFingerprint(spec.Fingerprint) {
return nil, errors.New("personal event: subscription attempt fingerprint is invalid")
}
if len(spec.EventKey) > attemptMaxFieldLength {
return nil, errors.New("personal event: subscription attempt event key is too long")
}
if _, ok := seen[spec.Fingerprint]; ok {
continue
}
seen[spec.Fingerprint] = struct{}{}
out = append(out, spec)
}
return out, nil
}
func normalizeSucceededFingerprints(
claim *AttemptClaim,
succeeded []string,
failed string,
) (map[string]bool, error) {
out := make(map[string]bool, len(succeeded))
for _, fingerprint := range succeeded {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == failed {
return nil, errors.New("personal event: failed fingerprint cannot also be successful")
}
if !containsFingerprint(claim.Fingerprints, fingerprint) {
return nil, errors.New("personal event: successful fingerprint is not part of the attempt claim")
}
out[fingerprint] = true
}
return out, nil
}
func validateAttemptClaim(claim *AttemptClaim) error {
if claim == nil || strings.TrimSpace(claim.AttemptID) == "" ||
len(claim.Fingerprints) == 0 || claim.previous == nil {
return errors.New("personal event: invalid subscription attempt claim")
}
for _, fingerprint := range claim.Fingerprints {
if !validAttemptFingerprint(fingerprint) {
return errors.New("personal event: subscription attempt claim contains an invalid fingerprint")
}
if _, ok := claim.previous[fingerprint]; !ok {
return errors.New("personal event: subscription attempt claim is incomplete")
}
}
return nil
}
func containsFingerprint(fingerprints []string, target string) bool {
for _, fingerprint := range fingerprints {
if fingerprint == target {
return true
}
}
return false
}
func validAttemptFingerprint(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}
func validRetryability(value Retryability) bool {
switch value {
case RetryabilityUnknown, RetryabilityRetryable, RetryabilityNonRetryable:
return true
default:
return false
}
}
func attemptFailureDelay(
fingerprint string,
count int,
failure AttemptFailure,
) (time.Duration, AttemptState) {
if failure.Retryability == RetryabilityNonRetryable {
return attemptTerminalHold, AttemptStateTerminalHold
}
base := attemptBackoff(count)
delay := base + deterministicAttemptJitter(fingerprint, count, base)
if failure.RetryAfter > delay {
delay = failure.RetryAfter
}
return delay, AttemptStateCooldown
}
func attemptBackoff(count int) time.Duration {
switch count {
case 1:
return 30 * time.Second
case 2:
return 2 * time.Minute
case 3:
return 10 * time.Minute
default:
return 30 * time.Minute
}
}
func deterministicAttemptJitter(fingerprint string, count int, base time.Duration) time.Duration {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", fingerprint, count)))
// 0..2000 basis points, inclusive (0%..20%).
basisPoints := int(binary.BigEndian.Uint16(sum[:2])) % 2001
return time.Duration(int64(base) * int64(basisPoints) / 10000)
}
func boundedAttemptField(value string) string {
value = strings.TrimSpace(value)
if len(value) > attemptMaxFieldLength {
return value[:attemptMaxFieldLength]
}
return value
}
func restoreAttemptPrevious(state *attemptStateFile, fingerprint string, previous attemptPrevious) {
if previous.present {
state.Records[fingerprint] = previous.record
return
}
delete(state.Records, fingerprint)
}
func claimOwnsAll(state *attemptStateFile, claim *AttemptClaim) bool {
for _, fingerprint := range claim.Fingerprints {
record, ok := state.Records[fingerprint]
if !ok || record.State != AttemptStateInFlight || record.AttemptID != claim.AttemptID {
return false
}
}
return true
}
func blockedAttempt(record attemptRecord, now time.Time) *AttemptBlockedError {
var next time.Time
retryability := record.Retryability
switch record.State {
case AttemptStateInFlight:
if !record.LeaseUntil.After(now) {
return nil
}
next = record.LeaseUntil
retryability = RetryabilityUnknown
case AttemptStateCooldown, AttemptStateTerminalHold:
if !record.NextAllowedAt.After(now) {
return nil
}
next = record.NextAllowedAt
default:
return nil
}
return &AttemptBlockedError{
Fingerprint: record.Fingerprint,
EventKey: record.EventKey,
State: record.State,
Retryability: retryability,
RetryAfter: next.Sub(now),
NextAllowedAt: next,
FailureCount: record.FailureCount,
ErrorCode: record.ErrorCode,
TraceID: record.TraceID,
}
}
type attemptStateFile struct {
Version int `json:"version"`
Records map[string]attemptRecord `json:"records"`
}
type attemptRecord struct {
Fingerprint string `json:"fingerprint"`
EventKey string `json:"event_key,omitempty"`
State AttemptState `json:"state"`
AttemptID string `json:"attempt_id,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
LastFailureAt time.Time `json:"last_failure_at,omitempty"`
NextAllowedAt time.Time `json:"next_allowed_at,omitempty"`
Retryability Retryability `json:"retryability,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
func (s *AttemptStore) load() (*attemptStateFile, error) {
path := filepath.Join(s.workDir, AttemptStateFileName)
data, err := s.readFile(path)
if os.IsNotExist(err) {
return newAttemptStateFile(), nil
}
if err != nil {
return nil, fmt.Errorf("personal event: read subscription attempt state: %w", err)
}
var state attemptStateFile
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("personal event: decode subscription attempt state: %w", err)
}
if state.Version != attemptStateVersion {
return nil, fmt.Errorf(
"personal event: unsupported subscription attempt state version %d",
state.Version,
)
}
if state.Records == nil {
return nil, errors.New("personal event: subscription attempt state has no records map")
}
for fingerprint, record := range state.Records {
if err := validateAttemptRecord(fingerprint, record); err != nil {
return nil, err
}
}
return &state, nil
}
func newAttemptStateFile() *attemptStateFile {
return &attemptStateFile{
Version: attemptStateVersion,
Records: make(map[string]attemptRecord),
}
}
func validateAttemptRecord(fingerprint string, record attemptRecord) error {
if !validAttemptFingerprint(fingerprint) || fingerprint != record.Fingerprint {
return errors.New("personal event: subscription attempt state contains an invalid fingerprint")
}
if len(record.EventKey) > attemptMaxFieldLength ||
len(record.AttemptID) > attemptMaxFieldLength ||
len(record.ErrorCode) > attemptMaxFieldLength ||
len(record.TraceID) > attemptMaxFieldLength {
return errors.New("personal event: subscription attempt state contains an oversized field")
}
if record.FailureCount < 0 {
return errors.New("personal event: subscription attempt state contains a negative failure count")
}
if record.Retryability != "" && !validRetryability(record.Retryability) {
return errors.New("personal event: subscription attempt state contains invalid retryability")
}
switch record.State {
case AttemptStateInFlight:
if record.AttemptID == "" || record.LeaseUntil.IsZero() {
return errors.New("personal event: in-flight subscription attempt state is incomplete")
}
case AttemptStateCooldown:
if record.NextAllowedAt.IsZero() ||
(record.Retryability != RetryabilityUnknown &&
record.Retryability != RetryabilityRetryable) {
return errors.New("personal event: cooldown subscription attempt state is invalid")
}
case AttemptStateTerminalHold:
if record.NextAllowedAt.IsZero() ||
record.Retryability != RetryabilityNonRetryable {
return errors.New("personal event: terminal subscription attempt state is invalid")
}
default:
return errors.New("personal event: subscription attempt state contains an invalid state")
}
return nil
}
func pruneAttemptRecords(state *attemptStateFile, now time.Time) {
for fingerprint, record := range state.Records {
switch {
case !record.LastFailureAt.IsZero() &&
!now.Before(record.LastFailureAt) &&
now.Sub(record.LastFailureAt) >= attemptFailureReset &&
((record.State == AttemptStateInFlight && !record.LeaseUntil.After(now)) ||
((record.State == AttemptStateCooldown ||
record.State == AttemptStateTerminalHold) &&
!record.NextAllowedAt.After(now))):
delete(state.Records, fingerprint)
case record.State == AttemptStateInFlight &&
record.LastFailureAt.IsZero() &&
!record.LeaseUntil.IsZero() &&
!now.Before(record.LeaseUntil) &&
now.Sub(record.LeaseUntil) >= attemptFailureReset:
delete(state.Records, fingerprint)
}
}
}
func (s *AttemptStore) write(state *attemptStateFile) error {
if state == nil {
return errors.New("personal event: nil subscription attempt state")
}
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
}
path := filepath.Join(s.workDir, AttemptStateFileName)
if len(state.Records) == 0 {
if err := s.remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("personal event: remove empty subscription attempt state: %w", err)
}
return nil
}
keys := make([]string, 0, len(state.Records))
for fingerprint := range state.Records {
keys = append(keys, fingerprint)
}
sort.Strings(keys)
ordered := make(map[string]attemptRecord, len(keys))
for _, fingerprint := range keys {
ordered[fingerprint] = state.Records[fingerprint]
}
payload := attemptStateFile{Version: attemptStateVersion, Records: ordered}
data, err := s.marshal(payload, "", " ")
if err != nil {
return fmt.Errorf("personal event: encode subscription attempt state: %w", err)
}
data = append(data, '\n')
tmp := path + ".tmp"
if err := s.writeFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("personal event: write subscription attempt state: %w", err)
}
if err := s.chmod(tmp, config.FilePerm); err != nil {
_ = s.remove(tmp)
return fmt.Errorf("personal event: secure subscription attempt state: %w", err)
}
if err := s.rename(tmp, path); err != nil {
_ = s.remove(tmp)
return fmt.Errorf("personal event: replace subscription attempt state: %w", err)
}
return nil
}
func (s *AttemptStore) withLock(fn func() error) error {
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
}
lockPath := filepath.Join(s.workDir, AttemptStateLockFileName)
deadline := s.now().Add(attemptLockWaitTimeout)
for {
held, err := s.tryAcquire(lockPath)
if err == nil {
if err := s.chmod(lockPath, config.FilePerm); err != nil {
_ = held.Close()
return fmt.Errorf("personal event: secure subscription attempt lock: %w", err)
}
defer held.Close()
return fn()
}
if !errors.Is(err, eventlock.ErrBusy) {
return fmt.Errorf("personal event: acquire subscription attempt lock: %w", err)
}
remaining := deadline.Sub(s.now())
if remaining <= 0 {
return fmt.Errorf(
"personal event: timed out waiting for subscription attempt lock after %s",
attemptLockWaitTimeout,
)
}
delay := attemptLockRetryDelay
if remaining < delay {
delay = remaining
}
s.sleep(delay)
}
}
func newAttemptID() (string, error) {
return rand.Text(), nil
}
File diff suppressed because it is too large. Load diff
+179 -42
View File
@@ -23,6 +23,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -67,6 +68,8 @@ type Client struct {
HTTPClient *http.Client
Identity Identity
AccessTokenProvider func(context.Context) (string, error)
ClientVersion string
UserAgent string
}
type CreateSubscriptionRequest struct {
@@ -141,10 +144,14 @@ func (s dwsSubscription) toSubscription() Subscription {
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
Details map[string]any `json:"details,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Retryable *bool `json:"retryable,omitempty"`
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
TraceID string `json:"trace_id,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
func (e *APIError) Error() string {
@@ -178,18 +185,6 @@ func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionR
}
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 == "" {
@@ -378,14 +373,20 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return fmt.Errorf("personal event: read response: %w", err)
}
responseLog := sanitizeLogPayload(data)
responseID := firstNonEmpty(responseRequestID(data), responseHeaderRequestID(resp.Header))
responseTrace := firstNonEmpty(responseBodyTraceID(data), responseTraceID(resp.Header))
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)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, responseID, responseTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, 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)
apiErr := withHTTPResponseDetails(&APIError{
Code: fmt.Sprintf("HTTP_%d", resp.StatusCode),
Message: firstNonEmpty(http.StatusText(resp.StatusCode), "HTTP request failed"),
}, method, path, resp, responseID, responseTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
return apiErr
}
if len(bytes.TrimSpace(data)) == 0 {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil)
@@ -393,23 +394,25 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
}
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") {
envID := firstNonEmpty(env.requestID(), responseID)
envTrace := firstNonEmpty(env.traceID(), responseTrace)
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)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, 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)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
return errors.New("personal event: request failed")
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
if env.Result == nil {
return nil
}
@@ -419,10 +422,10 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return decodeResult(env.Result, out)
}
if out == nil {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
return nil
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
return json.Unmarshal(data, out)
}
@@ -451,25 +454,58 @@ func (c *Client) decorate(req *http.Request, accessToken string) {
if c.Identity.CorpID != "" {
req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID)
}
if version := strings.TrimSpace(c.ClientVersion); version != "" {
req.Header.Set("X-Cli-Version", version)
}
if userAgent := strings.TrimSpace(c.UserAgent); userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
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"`
Success *bool `json:"success"`
RequestID string `json:"request_id,omitempty"`
RequestID2 string `json:"requestId,omitempty"`
TraceID string `json:"trace_id,omitempty"`
TraceID2 string `json:"traceId,omitempty"`
Arguments []json.RawMessage `json:"arguments,omitempty"`
Result json.RawMessage `json:"result"`
Error *APIError `json:"error"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
RetryAfterSecondsCamel *int64 `json:"retryAfterSeconds,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
NextRetryAtCamel *time.Time `json:"nextRetryAt,omitempty"`
}
func (e responseEnvelope) apiError() *APIError {
if e.Error != nil {
if e.Error.Retryable == nil {
e.Error.Retryable = e.Retryable
}
if e.Error.RetryAfterSeconds == nil {
e.Error.RetryAfterSeconds = firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel)
}
if e.Error.NextRetryAt == nil {
e.Error.NextRetryAt = firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel)
}
if e.Error.TraceID == "" {
e.Error.TraceID = e.traceID()
}
return e.Error
}
if e.ErrorCode != "" || e.ErrorMsg != "" {
return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg}
return &APIError{
Code: e.ErrorCode,
Message: e.ErrorMsg,
Retryable: e.Retryable,
RetryAfterSeconds: firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel),
NextRetryAt: firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel),
TraceID: e.traceID(),
}
}
return nil
}
@@ -478,6 +514,10 @@ func (e responseEnvelope) requestID() string {
return firstNonEmpty(e.RequestID, e.RequestID2)
}
func (e responseEnvelope) traceID() string {
return firstNonEmpty(e.TraceID, e.TraceID2, firstArgumentString(e.Arguments))
}
func decodeResult(raw json.RawMessage, out any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
@@ -511,11 +551,8 @@ func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool) {
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}
if apiErr := env.apiError(); apiErr != nil {
return apiErr
}
}
var apiErr APIError
@@ -533,12 +570,24 @@ func responseRequestID(data []byte) string {
return ""
}
func responseBodyTraceID(data []byte) string {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
if env.Error != nil && strings.TrimSpace(env.Error.TraceID) != "" {
return strings.TrimSpace(env.Error.TraceID)
}
return env.traceID()
}
return ""
}
func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError {
if apiErr == nil {
return nil
}
apiErr.HTTPStatus = status
if apiErr.Details == nil {
apiErr.Details = make(map[string]any, 4)
apiErr.Details = make(map[string]any, 8)
}
apiErr.Details["method"] = method
apiErr.Details["path"] = path
@@ -549,6 +598,91 @@ func withRequestDetails(apiErr *APIError, method, path string, status int, reque
return apiErr
}
func withHTTPResponseDetails(apiErr *APIError, method, path string, resp *http.Response, requestID, traceID string) *APIError {
if apiErr == nil {
return nil
}
status := 0
if resp != nil {
status = resp.StatusCode
traceID = firstNonEmpty(apiErr.TraceID, traceID, responseTraceID(resp.Header))
}
apiErr = withRequestDetails(apiErr, method, path, status, requestID)
if apiErr.TraceID == "" {
apiErr.TraceID = strings.TrimSpace(traceID)
}
if resp == nil {
return apiErr
}
retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After"))
if retryAfter == "" {
return apiErr
}
apiErr.Details["retry_after"] = retryAfter
if apiErr.RetryAfterSeconds == nil && apiErr.NextRetryAt == nil {
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 {
apiErr.RetryAfterSeconds = &seconds
} else if next, err := http.ParseTime(retryAfter); err == nil {
next = next.UTC()
apiErr.NextRetryAt = &next
}
}
if apiErr.RetryAfterSeconds != nil {
apiErr.Details["retry_after_seconds"] = *apiErr.RetryAfterSeconds
}
if apiErr.NextRetryAt != nil {
apiErr.Details["next_retry_at"] = apiErr.NextRetryAt.UTC().Format(time.RFC3339)
}
return apiErr
}
func responseTraceID(headers http.Header) string {
for _, key := range []string{"X-Trace-Id", "X-Dingtalk-Trace-Id"} {
if value := strings.TrimSpace(headers.Get(key)); value != "" {
return value
}
}
return ""
}
func responseHeaderRequestID(headers http.Header) string {
return strings.TrimSpace(headers.Get("X-Request-Id"))
}
func firstArgumentString(arguments []json.RawMessage) string {
if len(arguments) == 0 {
return ""
}
var value string
if err := json.Unmarshal(arguments[0], &value); err != nil {
return ""
}
return strings.TrimSpace(value)
}
func firstInt64Pointer(values ...*int64) *int64 {
for _, value := range values {
if value == nil {
continue
}
copy := *value
return &copy
}
return nil
}
func firstTimePointer(values ...*time.Time) *time.Time {
for _, value := range values {
if value == nil || value.IsZero() {
continue
}
copy := value.UTC()
return &copy
}
return nil
}
func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) {
attrs := []any{
"method", method,
@@ -568,6 +702,9 @@ func logControlRequest(message, method, path string, q url.Values, status int, r
attrs = append(attrs, "request_id", requestID)
}
if apiErr != nil {
if apiErr.TraceID != "" {
attrs = append(attrs, "trace_id", apiErr.TraceID)
}
if apiErr.Code != "" {
attrs = append(attrs, "error_code", apiErr.Code)
}
+279
View File
@@ -25,6 +25,7 @@ import (
"strconv"
"strings"
"testing"
"time"
)
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
@@ -282,6 +283,12 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
t.Fatalf("details = %#v", apiErr.Details)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", *apiErr.Retryable)
}
if apiErr.HTTPStatus != http.StatusOK || apiErr.TraceID != "" {
t.Fatalf("HTTP diagnostics = status %d trace %q", apiErr.HTTPStatus, apiErr.TraceID)
}
out := logs.String()
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
if !strings.Contains(out, want) {
@@ -290,6 +297,278 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
}
}
func TestCrossPlatformCoverageClientBusinessErrorPreservesRetryContractAndClientHeaders(t *testing.T) {
nextRetryAt := "2026-07-30T04:05:06Z"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Cli-Version"); got != "1.2.3" {
t.Fatalf("X-Cli-Version = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "dws-cli/1.2.3" {
t.Fatalf("User-Agent = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"errorCode": "RATE_LIMITED",
"errorMsg": "slow down",
"retryable": false,
"retryAfterSeconds": 30,
"nextRetryAt": nextRetryAt,
"arguments": []string{"portal-trace-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
c.ClientVersion = "1.2.3"
c.UserAgent = "dws-cli/1.2.3"
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.Retryable == nil || *apiErr.Retryable {
t.Fatalf("retryable = %v, want explicit false", apiErr.Retryable)
}
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 30 {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != nextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
if apiErr.TraceID != "portal-trace-1" || apiErr.HTTPStatus != http.StatusOK {
t.Fatalf("HTTP diagnostics = trace %q status %d", apiErr.TraceID, apiErr.HTTPStatus)
}
if _, exists := apiErr.Details["request_id"]; exists {
t.Fatalf("portal trace was mislabeled as request_id: %#v", apiErr.Details)
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticIdentityPrecedence(t *testing.T) {
tests := []struct {
name string
body map[string]any
headers http.Header
wantTraceID string
wantRequestID string
}{
{
name: "nested trace wins",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{
"code": "SYSTEM_ERROR",
"message": "failed",
"trace_id": "nested-trace",
},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "nested-trace",
wantRequestID: "body-request",
},
{
name: "top-level trace wins over arguments and header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "top-trace",
wantRequestID: "body-request",
},
{
name: "portal argument wins over header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "portal-trace",
wantRequestID: "body-request",
},
{
name: "headers are independent fallbacks",
body: map[string]any{
"success": false,
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "header-trace",
wantRequestID: "header-request",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
for key, values := range test.headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
_ = json.NewEncoder(w).Encode(test.body)
}))
defer srv.Close()
client := NewClient(srv.URL, Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
})
_, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.TraceID != test.wantTraceID {
t.Fatalf("trace_id = %q, want %q", apiErr.TraceID, test.wantTraceID)
}
if got, _ := apiErr.Details["request_id"].(string); got != test.wantRequestID {
t.Fatalf("request_id = %q, want %q; details=%#v", got, test.wantRequestID, apiErr.Details)
}
})
}
}
func TestCrossPlatformCoverageClientHTTPErrorPreservesRetryAfterAndHeaderTrace(t *testing.T) {
tests := []struct {
name string
retryAfter string
wantSeconds int64
wantNextRetryAt string
}{
{name: "delta seconds", retryAfter: "45", wantSeconds: 45},
{name: "http date", retryAfter: "Thu, 30 Jul 2026 04:05:06 GMT", wantNextRetryAt: "2026-07-30T04:05:06Z"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.Header().Set("Retry-After", tt.retryAfter)
w.Header().Set("X-Trace-Id", "header-trace")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if calls != 1 {
t.Fatalf("HTTP calls = %d, want one-shot client", calls)
}
if apiErr.Code != "HTTP_429" || apiErr.HTTPStatus != http.StatusTooManyRequests || apiErr.TraceID != "header-trace" {
t.Fatalf("API error = %#v", apiErr)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", apiErr.Retryable)
}
if tt.wantSeconds > 0 {
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != tt.wantSeconds {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
}
if tt.wantNextRetryAt != "" {
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != tt.wantNextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
}
})
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticHelpersHandleNilAndMalformedInputs(t *testing.T) {
if got := withHTTPResponseDetails(nil, http.MethodPost, "/subscription/user", nil, "request-1", "trace-1"); got != nil {
t.Fatalf("withHTTPResponseDetails(nil) = %#v, want nil", got)
}
apiErr := &APIError{Code: "SYSTEM_ERROR", Message: "failed"}
got := withHTTPResponseDetails(apiErr, http.MethodPost, "/subscription/user", nil, "request-1", " trace-1 ")
if got != apiErr {
t.Fatalf("withHTTPResponseDetails() returned a different error: got %#v, want %#v", got, apiErr)
}
if got.HTTPStatus != 0 || got.TraceID != "trace-1" {
t.Fatalf("diagnostics = status %d trace %q", got.HTTPStatus, got.TraceID)
}
if got.Details["request_id"] != "request-1" {
t.Fatalf("details = %#v", got.Details)
}
if got := firstArgumentString([]json.RawMessage{json.RawMessage(`{`)}); got != "" {
t.Fatalf("firstArgumentString(malformed) = %q, want empty", got)
}
}
func TestCrossPlatformCoverageClientCreateSubscriptionRejectsErrorsWithSubscribeID(t *testing.T) {
for _, code := range []string{
"SYSTEM_ERROR",
"DUP",
"DUPLICATE_SUBSCRIPTION",
"SUBSCRIPTION_ALREADY_EXISTS",
"SUBSCRIPTION_ALREADY_EXIST",
"ALREADY_SUBSCRIBED",
"DUPLICATE",
} {
t.Run(code, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": code,
"message": "registration failed",
"details": map[string]any{"subscribe_id": "pending-sub"},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
if err == nil || sub != nil {
t.Fatalf("subscription = %#v, error = %v; API errors must stay failures", sub, err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != code {
t.Fatalf("error = %#v", err)
}
})
}
}
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 != "" {
@@ -219,8 +219,8 @@ func TestCrossPlatformCoveragePersonalClientHelpersAndOperations(t *testing.T) {
}
base.HTTPClient = personalHTTPClient(400, `{"error":{"code":"DUP","details":{"subscribe_id":"existing"}}}`)
created, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{EventKey: "e", RuleType: "r"})
if err != nil || created.SubscribeID != "existing" {
t.Fatalf("duplicate create = %#v, %v", created, err)
if err == nil || created != nil {
t.Fatalf("duplicate error create = %#v, %v", created, err)
}
request := base.buildCreateRequest(CreateSubscriptionRequest{
EventKey: "e", RuleType: "r", Name: "n", RuleParam: map[string]any{"bad": make(chan int)},
+32
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
stderrors "errors"
"io"
"reflect"
"strings"
"testing"
@@ -651,6 +652,37 @@ func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) {
}
}
type devAppFailingCountRunner struct {
calls int
err error
}
func (r *devAppFailingCountRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
return executor.Result{Invocation: invocation}, r.err
}
func TestCrossPlatformCoverageDevAppEventSubscribeRunnerFailureIsNotRetried(t *testing.T) {
wantErr := stderrors.New("event subscription failed")
runner := &devAppFailingCountRunner{err: wantErr}
root := newDevAppTestRoot(runner)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{
"dev", "app", "event", "subscribe",
"--unified-app-id", "u-1",
"--event-codes", "a,b",
"--yes",
})
if err := root.Execute(); !stderrors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want %v", err, wantErr)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
}
func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
+37
View File
@@ -292,6 +292,43 @@ func TestCallToolUsesJSONRPCMethod(t *testing.T) {
}
}
func TestCrossPlatformCoverageCallToolDevAppEventSubscribeRetriesAreBounded(t *testing.T) {
attempts := 0
httpClient := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempts++
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
}),
}
client := NewClient(httpClient)
client.sleep = func(context.Context, time.Duration) error { return nil }
_, err := client.CallTool(
context.Background(),
"https://mcp.example.test/dws",
"subscribe_dev_app_events",
map[string]any{
"unifiedAppId": "u-1",
"eventCodes": []string{"event-a", "event-b"},
},
)
if err == nil {
t.Fatal("CallTool() succeeded after repeated HTTP 503 responses")
}
wantAttempts := client.MaxRetries + 1
if attempts != wantAttempts {
t.Fatalf("HTTP attempts = %d, want configured bound %d", attempts, wantAttempts)
}
if attempts > 2 {
t.Fatalf("dev app event subscribe HTTP attempts = %d, want at most 2 by default", attempts)
}
}
func TestCallToolInjectsAuthHeaders(t *testing.T) {
t.Parallel()
+20
View File
@@ -171,6 +171,26 @@ dws event stop --all --yes
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先预览 `dws event stop <subscribe_id> --dry-run`、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
- 一个 consume 可监听多个兼容 event key,每个事件仍有独立订阅和 consumer,共用一个 bus、远程连接及输出。`event stop <subscribe_id>` 只移除目标事件,最后一个被移除后进程退出。
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
### 本地保护状态运维
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
## Output parsing
- 推荐 `--flatten -f ndjson`:顶层业务字段,一行一个事件 JSON,适合 Agent 管道读取。
+20
View File
@@ -74,6 +74,26 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
### 本地保护状态运维
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
## Call flow
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
@@ -369,6 +369,26 @@ dws event stop --all --yes
裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。多事件进程中,停止一个 `subscribe_id` 只移除对应逻辑 consumer,其余事件继续监听;最后一个被移除时进程退出。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
### 本地保护状态运维
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
## Troubleshooting
- 没有输出:单事件确认 stderr 已出现 `ready event_key=...`;多事件确认已出现 `ready event_count=...`,不要把某条 `subscription` 行误认为整体就绪。
+125
View File
@@ -130,6 +130,131 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
}
}
func TestCrossPlatformCoverageEventSkillPinsSubscriptionRetryOrchestrationContract(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
filepath.Join(root, "docs", "event-subprocess-contract.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
normalizedText := strings.Join(strings.Fields(text), " ")
for _, required := range []string{
"16",
"--profile",
"Agent/host",
"0/2/1",
"retryable=false",
"max_additional_attempts=0",
"retryable=true",
"max_additional_attempts=2",
"retryable=unknown",
"max_additional_attempts=1",
"retry_after_seconds",
"next_retry_at",
"in_flight",
"cooldown",
"terminal_hold",
"subscribe_id",
"trace_id",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing subscription retry orchestration contract %q", path, required)
}
}
if path == filepath.Join(root, "docs", "event-subprocess-contract.md") {
for _, required := range []string{
"not a CLI-enforced persisted total-attempt cap",
"performs no in-process automatic retry",
"does not persist or enforce the Agent/host attempt count",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s overstates CLI retry enforcement; missing %q", path, required)
}
}
continue
}
for _, required := range []string{
"不是 CLI 持久化硬总次数上限",
"进程内不会自动重试",
"不持久化或计算跨调用的 Agent/host 尝试次数",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s overstates CLI retry enforcement; missing %q", path, required)
}
}
}
}
func TestCrossPlatformCoverageEventSkillDocumentsSubscriptionGuardOperations(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
filepath.Join(root, "docs", "event-subprocess-contract.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
normalizedText := strings.Join(strings.Fields(text), " ")
for _, required := range []string{
"~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json",
"DWS_CONFIG_DIR",
"personal_subscription_attempts.json",
"personal_subscription_attempts.lock",
"0700",
"0600",
"24h",
"1h",
"terminal_hold",
"next_retry_at",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing subscription guard operations %q", path, required)
}
}
if path == filepath.Join(root, "docs", "event-subprocess-contract.md") {
for _, required := range []string{
"Delete only",
"never the lock file",
"every protection record for that identity",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s missing emergency guard-clear warning %q", path, required)
}
}
continue
}
for _, required := range []string{
"只删除 `personal_subscription_attempts.json`",
"不要删除 lock 文件",
"该 identity 的全部保护记录",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s missing emergency guard-clear warning %q", path, required)
}
}
}
}
func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {