Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
b2ba6b2
test(event): freeze the registered EventKey baseline
leave330 Jul 31, 2026
9ec5981
test(event): pin list and schema output with golden files
leave330 Jul 31, 2026
e3b39fa
test(event): gate list and schema JSON against runtime-field leaks
leave330 Jul 31, 2026
a930c0d
test(event): snapshot per-key processed stdout as the compat baseline
leave330 Jul 31, 2026
20f9cfd
test(event): pin the classic PreConsume first/last lifecycle on a rea…
leave330 Jul 31, 2026
5783518
refactor(event): extract the canonical event value type into a model …
leave330 Jul 31, 2026
4987cc3
feat(event): parse tenant identity once at the websocket ingress
leave330 Jul 31, 2026
1166b27
feat(event): carry full canonical metadata across the local bus frame
leave330 Jul 31, 2026
6e760b9
fix(event): stop backfilling upstream event time from the local clock
leave330 Jul 31, 2026
dbca488
fix(event): fail closed when a payload header conflicts with canonica…
leave330 Jul 31, 2026
42897d3
feat(event): negotiate bus capabilities before any consume side effect
leave330 Jul 31, 2026
0599501
fix(event): drop malformed payloads instead of leaking raw envelopes …
leave330 Jul 31, 2026
a5d30fe
fix(event): scope whiteboard subscriptions by whiteboard id
leave330 Jul 31, 2026
5a3b112
refactor(event): compile event declarations into a validated immutabl…
leave330 Jul 31, 2026
77c8255
feat(event): aggregate domain declarations through an explicit events…
leave330 Jul 31, 2026
83873a2
refactor(event): route every catalog reader through the compiled snap…
leave330 Jul 31, 2026
1a9ee4a
feat(event): add a consume application service with an immutable deci…
leave330 Jul 31, 2026
cfa862f
refactor(event): run consume preparation behind an injectable applica…
leave330 Jul 31, 2026
22c54f1
feat(event): add event consume --dry-run with a provable no-side-effe…
leave330 Jul 31, 2026
9a9f8da
feat(event): add a domain filter to event list
leave330 Jul 31, 2026
085765f
refactor(event): let the composition root inject event sources into t…
leave330 Jul 31, 2026
e83a24f
refactor(event): move the protocol package under the localbus adapter
leave330 Jul 31, 2026
dafcaad
refactor(event): move the transport package under the localbus adapter
leave330 Jul 31, 2026
f7a821b
refactor(event): move the busctl package under the localbus adapter
leave330 Jul 31, 2026
faa822e
refactor(event): move the busdiscover package under the localbus adapter
leave330 Jul 31, 2026
c3e84cd
refactor(event): move the websocket source behind the lark adapter bo…
leave330 Jul 31, 2026
4e07c6b
docs(event): document dry-run, domain filter and preconsume cleanup c…
leave330 Jul 31, 2026
5f43dd7
test(event): gate dependency direction with self-checking import dete…
leave330 Jul 31, 2026
1da3223
refactor(event): read canonical metadata instead of re-parsing payloa…
leave330 Jul 31, 2026
f6f7781
refactor(event): fold duplicated domain preparation and projection he…
leave330 Jul 31, 2026
f6d7c1d
chore(event): normalize import grouping after the adapter moves
leave330 Jul 31, 2026
db00903
docs(event): point the vc topic index at the full seven-key catalog
leave330 Aug 1, 2026
b44aaa1
docs(event): tell agents to execute the dry-run preview, not just cit…
leave330 Aug 1, 2026
8a5701d
feat(event): reject processless custom schemas and garbage raw schema…
leave330 Aug 1, 2026
0ab9b1a
fix(event): honest dry-run preconditions and single-run param normali…
leave330 Aug 1, 2026
569a5d1
test(event): close the review's gate gaps and defensive holes
leave330 Aug 1, 2026
88a9325
docs(event): tighten the dry-run guidance the eval verified
leave330 Aug 1, 2026
d9ac2fa
feat(event): list valid domains in the --domain flag help
leave330 Aug 1, 2026
591fc5c
ci: allowlist dotted event identifiers in gitleaks
leave330 Aug 3, 2026
edc2b79
test(event): remove unused mock source name accessor
leave330 Aug 3, 2026
aa4a682
fix(event): normalize observed_at to utc and warn when it fails to parse
leave330 Aug 3, 2026
585ec87
fix(event): preserve error causes and guard blocked decisions
leave330 Aug 3, 2026
11d9535
fix(event): report unknown params deterministically
leave330 Aug 3, 2026
626f5d2
test(event): harden schema, snapshot, and frame test assertions
leave330 Aug 3, 2026
115c1b6
docs(event): correct websocket package comment
leave330 Aug 3, 2026
f70f06f
ci: scope the event identifier allowlist by rule and path
leave330 Aug 3, 2026
faf31a3
ci: allowlist two test-only hostnames used in unit tests
leave330 Aug 3, 2026
da3dd77
refactor(event): keep the subscription hook in its own domain
leave330 Aug 3, 2026
2822d8e
test(event): assert the error contract of the subscription hook
leave330 Aug 3, 2026
fc76f1f
Merge remote-tracking branch 'origin/main' into feat/event-arch-refactor
leave330 Aug 3, 2026
b8ccd4d
Merge remote-tracking branch 'origin/main' into feat/event-arch-refactor
leave330 Aug 3, 2026
a43e228
fix(event): stop a type-flipped header claim from disabling arbitration
leave330 Aug 4, 2026
a314518
fix(event): keep each whiteboard consumer to its own board
leave330 Aug 4, 2026
82925ce
feat(event): degrade instead of refusing a bus without canonical meta…
leave330 Aug 4, 2026
2de1fc4
test(event): replay a legacy frame through the real pipeline
leave330 Aug 4, 2026
58ca432
fix(event): give the frame limit room above the payload limit
leave330 Aug 4, 2026
972fe1e
fix(event): start the stdin watcher only once the stream does
leave330 Aug 4, 2026
8b5cc03
test(event): pin the seams between deciding and running a consume
leave330 Aug 4, 2026
74101ef
feat(event): carry recovery information in the consume preview
leave330 Aug 4, 2026
5650608
test(event): pin the decision-to-host wiring where it is written
leave330 Aug 4, 2026
db4363d
fix(event): satisfy the framework lint rules in the new test helper
leave330 Aug 4, 2026
c8ec4a8
docs(event): drop dry-run guidance and name the domains
leave330 Aug 5, 2026
e3dbd1d
Merge remote-tracking branch 'origin/main' into feat/event-arch-refactor
leave330 Aug 5, 2026
8454c1f
docs(event): state the --quiet drop-diagnostic blind spot
leave330 Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ title = "lark-cli gitleaks config"
[extend]
useDefault = true

# Extends the default rule of the same id: the base regex is kept and this
# allowlist is appended to that rule only, leaving every other rule untouched.
[[rules]]
id = "generic-api-key"

[[rules.allowlists]]
description = "EventKey identifiers (dotted lowercase names like minutes.minute.generated_v1) in the event catalog fixtures are public catalog data, not credentials; the entropy heuristic misreads them"
condition = "AND"
paths = ['''cmd/event/testdata/golden/.*''']
regexTarget = "secret"
regexes = ['''^[a-z0-9_]+(\.[a-z0-9_]+){2,}$''']

[[rules]]
id = "lark-bot-app-id"
description = "Detect Lark bot app ids"
Expand Down
1 change: 0 additions & 1 deletion cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
Expand Down
200 changes: 200 additions & 0 deletions cmd/event/blocked_decision_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package event

import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"

"github.com/larksuite/cli/cmd/event/render"
"github.com/larksuite/cli/errs"
appconsume "github.com/larksuite/cli/internal/event/application/consume"
"github.com/larksuite/cli/internal/event/catalog"
)

const blockedTestKey = "im.message.receive_v1"

// blockingPreflight reports one blocked precondition, the shape a real
// preflight produces when credentials cannot be used.
type blockingPreflight struct {
name string
blockErr error
}

func (p *blockingPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) {
return []appconsume.Precondition{
{Name: "console_event_published", Status: appconsume.PreconditionOK},
{
Name: p.name,
Status: appconsume.PreconditionBlocked,
Detail: p.blockErr.Error(),
BlockErr: p.blockErr,
},
}, nil
}

type fixedIdentity string

func (i fixedIdentity) Resolve(context.Context, *catalog.Entry) (string, error) {
return string(i), nil
}

// spyRunner records whether the delivery stream was ever started.
type spyRunner struct{ started bool }

func (r *spyRunner) Run(context.Context, appconsume.PrepareFunc) error {
r.started = true
return nil
}

func blockedDecisionFixture(t *testing.T) (*catalog.Entry, *appconsume.Service, *appconsume.Decision, error, error) {
t.Helper()
snap := compileCatalog()
entry, ok := snap.Resolve(blockedTestKey)
if !ok {
t.Fatalf("catalog has no %s", blockedTestKey)
}
blockErr := errs.NewPermissionError(errs.SubtypeMissingScope,
"missing scopes for %s", blockedTestKey).
WithMissingScopes("im:message", "im:chat:readonly").
WithHint("run `lark-cli auth login --scope im:message` and retry")

svc := &appconsume.Service{
Strategies: appconsume.DefaultRegistry(),
Identity: fixedIdentity("bot"),
Preflight: &blockingPreflight{name: "credentials_available", blockErr: blockErr},
}
decision, err := svc.Decide(context.Background(), entry, appconsume.Request{EventKey: blockedTestKey}, appconsume.ExecutionContext{})
return entry, svc, decision, blockErr, err
}

// The dry-run envelope for a blocked decision is a contract an orchestrator
// reads: ok stays true and the exit code stays 0, because the preview itself
// succeeded, and "would this run" is answered inside the payload. Anything that
// only checks ok or the exit code would treat a refusal as a green light, so
// the status and the blocked precondition must be present and named.
func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) {
_, _, decision, _, err := blockedDecisionFixture(t)
if err != nil {
t.Fatalf("deciding must succeed even when a precondition blocks, got: %v", err)
}

var stdout, stderr bytes.Buffer
if err := render.WriteDecisionJSON(&stdout, &stderr, "bot", decision.View()); err != nil {
t.Fatalf("render: %v", err)
}

var envelope struct {
OK bool `json:"ok"`
DryRun bool `json:"dry_run"`
Data struct {
Decision struct {
Status string `json:"status"`
Preconditions []struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
Subtype string `json:"subtype"`
Hint string `json:"hint"`
MissingScopes []string `json:"missing_scopes"`
} `json:"preconditions"`
WouldWrite []string `json:"would_write"`
} `json:"decision"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String())
}

if !envelope.OK || !envelope.DryRun {
t.Errorf("the preview succeeded, so ok and dry_run stay true; got: %s", stdout.String())
}
if envelope.Data.Decision.Status != "blocked" {
t.Errorf("status = %q, want \"blocked\": this is the only field that tells a caller the real run would refuse",
envelope.Data.Decision.Status)
}

var blocked *struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
Subtype string `json:"subtype"`
Hint string `json:"hint"`
MissingScopes []string `json:"missing_scopes"`
}
for i := range envelope.Data.Decision.Preconditions {
if envelope.Data.Decision.Preconditions[i].Status == "blocked" {
blocked = &envelope.Data.Decision.Preconditions[i]
}
}
if blocked == nil {
t.Fatalf("a blocked decision must name the precondition that blocks it, got: %s", stdout.String())
}
if blocked.Name != "credentials_available" {
t.Errorf("blocked precondition = %q, want the one the preflight reported", blocked.Name)
}
if blocked.Detail == "" {
t.Error("a blocked precondition must carry a detail; without it the caller knows only that something failed")
}
// The preview is read before acting, so it has to say what to do about the
// refusal — the same recovery information a real run puts in its error
// envelope, in the same machine-readable shape.
if blocked.Subtype != string(errs.SubtypeMissingScope) {
t.Errorf("subtype = %q, want the classification callers branch on", blocked.Subtype)
}
if blocked.Hint == "" {
t.Error("a blocked precondition must carry the recovery hint; the preview is the surface an agent reads before acting")
}
if len(blocked.MissingScopes) != 2 {
t.Errorf("missing_scopes = %v, want the concrete scopes to grant", blocked.MissingScopes)
}
// A precondition that passed has nothing to recover from and must stay bare.
for _, pc := range envelope.Data.Decision.Preconditions {
if pc.Status == "ok" && (pc.Subtype != "" || pc.Hint != "" || len(pc.MissingScopes) > 0) {
t.Errorf("a passing precondition must carry no recovery fields, got %+v", pc)
}
}
// Declared write side effects stay declarations in a preview.
if len(envelope.Data.Decision.WouldWrite) == 0 {
t.Error("the preview must still declare what a real run would write")
}
}

// Executing a blocked decision returns the preflight's own error and never
// starts the stream. Everything the command sets up for a live run — the stdin
// EOF watcher included — hangs off the runner for this reason: started earlier,
// it announced "stdin closed — shutting down" on a run that was actually
// refused for an unmet precondition.
func TestBlockedDecision_ExecuteNeverStartsTheStream(t *testing.T) {
entry, svc, decision, blockErr, err := blockedDecisionFixture(t)
if err != nil {
t.Fatalf("decide: %v", err)
}

runner := &spyRunner{}
execErr := svc.Execute(context.Background(), entry, decision, runner, appconsume.ExecutionContext{})

if runner.started {
t.Error("a blocked decision must not start the delivery stream")
}
problem, ok := errs.ProblemOf(execErr)
if !ok {
t.Fatalf("executing a blocked decision must return the preflight's typed error, got: %v", execErr)
}
if problem.Subtype != errs.SubtypeMissingScope {
t.Errorf("subtype = %q, want the preflight's own subtype preserved", problem.Subtype)
}
// The caller must receive the preflight's own error, not a copy of its
// message: the hint is what tells an operator how to recover, and a rewrap
// would drop it.
if !errors.Is(execErr, blockErr) {
t.Errorf("execute returned %v, want the preflight's own error", execErr)
}
if problem.Hint == "" {
t.Error("the recovery hint the preflight attached must survive to the caller")
}
}
14 changes: 11 additions & 3 deletions cmd/event/bus.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/adapter/lark/websocket"
"github.com/larksuite/cli/internal/event/adapter/localbus/transport"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/event/catalog"
)

// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command {
var domain string

cmd := &cobra.Command{
Expand All @@ -44,7 +46,13 @@
}

tr := transport.New()
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
ingress := &websocket.FeishuSource{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: domain,
Logger: logger,

Check warning on line 53 in cmd/event/bus.go

View check run for this annotation

Codecov / codecov/patch

cmd/event/bus.go#L49-L53

Added lines #L49 - L53 were not covered by tests
}
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap, ingress)

Check warning on line 55 in cmd/event/bus.go

View check run for this annotation

Codecov / codecov/patch

cmd/event/bus.go#L55

Added line #L55 was not covered by tests

ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
Expand Down
2 changes: 1 addition & 1 deletion cmd/event/bus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd := NewCmdBus(f, compileCatalog())
cmd.SetArgs([]string{})

err := cmd.Execute()
Expand Down
Loading
Loading