Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82d945f54 | ||
|
|
6846326445 | ||
|
|
54c2054a5c | ||
|
|
e5bf332b05 | ||
|
|
9ed55978d9 | ||
|
|
7ffbbc4a51 | ||
|
|
2db73a8185 | ||
|
|
a62332be93 | ||
|
|
1083093cbc | ||
|
|
c6ebe307cd | ||
|
|
3ee66d4373 | ||
|
|
a0be395ccc | ||
|
|
c1a549cd64 | ||
|
|
5a414999ef | ||
|
|
7aa8240629 | ||
|
|
37b9a1dc31 | ||
|
|
2ab8748c4d | ||
|
|
f041275811 | ||
|
|
f486105836 | ||
|
|
e14de2b4c2 | ||
|
|
fe2f3ca92f | ||
|
|
8e4519cacd | ||
|
|
16abb481e8 | ||
|
|
7ad82bbf0a | ||
|
|
afb90009f6 | ||
|
|
e3553fe7a5 | ||
|
|
067aff179f | ||
|
|
353454abb2 | ||
|
|
e742a6c269 | ||
|
|
22649e96ef | ||
|
|
f68a11f11d | ||
|
|
abe5129306 |
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
editionName := editionNameOrDefault()
|
||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||
return nil
|
||||
|
||||
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
|
||||
@@ -52,6 +52,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
@@ -63,7 +65,7 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
|
||||
@@ -361,13 +361,18 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
||||
}
|
||||
return nil, net.ErrClosed
|
||||
}}
|
||||
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
|
||||
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)}
|
||||
d.acceptLoop(context.Background())
|
||||
d.shuttingDown.Store(true)
|
||||
d.acceptLoop(context.Background())
|
||||
d.triggerShutdown("test")
|
||||
if !l.closed {
|
||||
t.Fatal("trigger did not close listener")
|
||||
select {
|
||||
case reason := <-d.stopReq:
|
||||
if reason != "test" {
|
||||
t.Fatalf("stop reason = %q", reason)
|
||||
}
|
||||
default:
|
||||
t.Fatal("trigger did not notify lifecycle loop")
|
||||
}
|
||||
|
||||
d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
|
||||
|
||||
@@ -186,6 +186,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
dedup: dd,
|
||||
started: time.Now().UTC(),
|
||||
idleStop: make(chan struct{}),
|
||||
stopReq: make(chan string, 1),
|
||||
}
|
||||
|
||||
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
|
||||
@@ -263,6 +264,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
case <-d.idleStop:
|
||||
log.Info("bus: idle timeout reached, shutting down")
|
||||
case reason := <-d.stopReq:
|
||||
shutdownReason = reason
|
||||
log.Info("bus: shutdown requested via IPC", "reason", reason)
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
@@ -292,6 +296,7 @@ type daemon struct {
|
||||
shutdownMu sync.Mutex
|
||||
shuttingDown atomic.Bool
|
||||
idleStop chan struct{}
|
||||
stopReq chan string
|
||||
|
||||
credentialHandoffMu sync.Mutex
|
||||
terminalMu sync.RWMutex
|
||||
@@ -749,15 +754,17 @@ func (d *daemon) idleWatch(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus.
|
||||
// It works by closing the listener (which unblocks Run's select via the
|
||||
// source error path, indirectly). For v1 a full ctx-cancellation hook is
|
||||
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
|
||||
// shutdown path.
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus. The
|
||||
// buffered request wakes Run's lifecycle select, which then cancels the cloud
|
||||
// source and performs the same graceful shutdown used for signals and idle
|
||||
// expiry. It is intentionally non-blocking so repeated stop requests cannot
|
||||
// strand connection handlers.
|
||||
func (d *daemon) triggerShutdown(reason string) {
|
||||
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
|
||||
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
|
||||
// Best-effort: a future version wires a context.CancelFunc here.
|
||||
select {
|
||||
case d.stopReq <- reason:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown performs the graceful tear-down sequence:
|
||||
|
||||
@@ -141,6 +141,67 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) {
|
||||
workDir := shortTempDir(t)
|
||||
identityHash := dwsevent.IdentityHash(workDir)
|
||||
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash)
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(context.Background(), Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: "ding_ipc_stop_test",
|
||||
Edition: "open",
|
||||
SourceKind: dwsevent.SourceKindAppStream,
|
||||
IdentityHash: identityHash,
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
}()
|
||||
|
||||
var conn net.Conn
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var err error
|
||||
conn, err = transport.Dial(endpoint)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("bus IPC endpoint did not become ready")
|
||||
}
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
Role: transport.HelloRoleStop,
|
||||
}); err != nil {
|
||||
t.Fatalf("write stop hello: %v", err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
t.Fatalf("read stop response: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
|
||||
t.Fatalf("stop response = %#v", bye)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned after IPC stop: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not return after IPC stop")
|
||||
}
|
||||
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 {
|
||||
t.Fatalf("bus lock retained pid %d after IPC stop", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket dial")
|
||||
workDir := shortTempDir(t)
|
||||
|
||||
@@ -114,6 +114,47 @@ func ReadHolderPID(path string) int {
|
||||
return parsePID(b)
|
||||
}
|
||||
|
||||
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
|
||||
// path. If the lock can be acquired, no daemon owns it: the PID payload is
|
||||
// stale and is cleared while the probe holds the lock. The PID is read again
|
||||
// after a busy result so a daemon replacement cannot be mistaken for the
|
||||
// original owner.
|
||||
//
|
||||
// Callers use this immediately before sending a process-level termination
|
||||
// fallback. A live PID by itself is not proof of ownership because operating
|
||||
// systems may reuse PIDs after an unclean daemon exit.
|
||||
func ValidateHolderOwnership(path string, pid int) (bool, error) {
|
||||
if pid <= 0 || ReadHolderPID(path) != pid {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
probe, err := busTryAcquire(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBusy) {
|
||||
return ReadHolderPID(path) == pid, nil
|
||||
}
|
||||
return false, fmt.Errorf("bus: verify lock owner: %w", err)
|
||||
}
|
||||
defer probe.Close()
|
||||
|
||||
// We own the lock, so the recorded PID cannot own it. Clear only the PID
|
||||
// we validated above; a changed payload is left untouched defensively.
|
||||
f := probe.File()
|
||||
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
|
||||
return false, fmt.Errorf("bus: seek stale lock: %w", err)
|
||||
}
|
||||
current, err := busReadAll(f)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("bus: read stale lock: %w", err)
|
||||
}
|
||||
if parsePID(current) == pid {
|
||||
if err := truncateAndWritePID(f, 0); err != nil {
|
||||
return false, fmt.Errorf("bus: clear stale PID: %w", err)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Close releases the flock and best-effort blanks the PID body so a stale
|
||||
// reader (e.g. `event status` racing our shutdown) does not see our
|
||||
// long-dead PID and try to signal it. The lock file itself is NOT removed
|
||||
|
||||
@@ -16,9 +16,13 @@ package bus
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestAcquire_WritesOurPID(t *testing.T) {
|
||||
@@ -112,6 +116,109 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
held, err := busTryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("hold lock: %v", err)
|
||||
}
|
||||
defer held.Close()
|
||||
if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil {
|
||||
t.Fatalf("write holder PID: %v", err)
|
||||
}
|
||||
|
||||
owned, err := ValidateHolderOwnership(path, os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHolderOwnership: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("held lock was not attributed to its recorded PID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
// The current PID is alive but deliberately does not hold this lock. This
|
||||
// models a stale daemon PID that the OS has reassigned to another process.
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatalf("write stale PID: %v", err)
|
||||
}
|
||||
|
||||
owned, err := ValidateHolderOwnership(path, os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHolderOwnership: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("live reused PID without the bus lock was accepted as owner")
|
||||
}
|
||||
if got := ReadHolderPID(path); got != 0 {
|
||||
t.Fatalf("stale PID after validation = %d, want cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, err := ValidateHolderOwnership(path, 456)
|
||||
if err != nil || owned {
|
||||
t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) {
|
||||
errInjected := errors.New("injected ownership validation failure")
|
||||
writeLivePID := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("probe", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) {
|
||||
return nil, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("probe error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seek", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) {
|
||||
return 0, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("seek error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) {
|
||||
return nil, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busTruncate, func(*os.File, int64) error {
|
||||
return errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("clear error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l1, err := Acquire(path)
|
||||
|
||||
@@ -284,11 +284,13 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
|
||||
origRead := stopReadHolderPID
|
||||
origValidateOwner := stopValidateHolderOwner
|
||||
origAlive := stopAlive
|
||||
origFind := stopFindProcess
|
||||
origSignal := stopSignalProcess
|
||||
t.Cleanup(func() {
|
||||
stopReadHolderPID = origRead
|
||||
stopValidateHolderOwner = origValidateOwner
|
||||
stopAlive = origAlive
|
||||
stopFindProcess = origFind
|
||||
stopSignalProcess = origSignal
|
||||
@@ -307,6 +309,7 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||
stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil }
|
||||
stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
|
||||
if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
|
||||
t.Fatalf("done signal = %v", err)
|
||||
|
||||
@@ -112,8 +112,9 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
|
||||
}
|
||||
_, spawnErr := cfg.Spawn(SpawnConfig{
|
||||
ClientID: cfg.ClientID,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
ClientID: cfg.ClientID,
|
||||
IPCEndpoint: cfg.IPCEndpoint,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
})
|
||||
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
|
||||
// Hard error (couldn't even exec the child). Stop here — no bus
|
||||
|
||||
@@ -122,7 +122,10 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
|
||||
closer()
|
||||
}
|
||||
})
|
||||
fakeSpawn := func(SpawnConfig) (int, error) {
|
||||
fakeSpawn := func(cfg SpawnConfig) (int, error) {
|
||||
if cfg.IPCEndpoint != sock {
|
||||
t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock)
|
||||
}
|
||||
closer = startStubBus(t, sock)
|
||||
return 12345, nil
|
||||
}
|
||||
|
||||
@@ -43,18 +43,14 @@ var (
|
||||
spawnPipe = os.Pipe
|
||||
)
|
||||
|
||||
// ErrSpawnFailed is returned when the child reports startup failure via
|
||||
// the ready pipe ('E' byte). The child's exit error / log file holds the
|
||||
// actual cause; this sentinel just lets the caller distinguish "ready
|
||||
// pipe said no" from "ready pipe timed out / closed early".
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
|
||||
// ErrSpawnFailed is returned when the child reports startup failure through
|
||||
// the Unix ready pipe or exits before binding the Windows named pipe.
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure")
|
||||
|
||||
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
|
||||
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
|
||||
|
||||
// SpawnConfig describes one spawn attempt. ClientID is the only field
|
||||
// inspected by the child; the rest govern process attributes the parent
|
||||
// applies before exec.
|
||||
// SpawnConfig describes one spawn attempt.
|
||||
type SpawnConfig struct {
|
||||
// ExecPath is the dws binary to exec. Default os.Executable().
|
||||
ExecPath string
|
||||
@@ -62,18 +58,23 @@ type SpawnConfig struct {
|
||||
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
|
||||
ClientID string
|
||||
|
||||
// IPCEndpoint is the bus endpoint the child binds. Windows uses the
|
||||
// existing named pipe as its readiness handshake because os/exec does not
|
||||
// support ExtraFiles there. Unix keeps the inherited ready-pipe protocol.
|
||||
IPCEndpoint string
|
||||
|
||||
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
|
||||
// pass `--extra-flag-for-test` etc.
|
||||
ExtraArgs []string
|
||||
|
||||
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
|
||||
// entry is appended automatically.
|
||||
// Env to pass to the child. Defaults to os.Environ(). Unix appends the
|
||||
// ReadyFDEnv entry automatically; Windows removes any inherited copy.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
|
||||
// waits for it to signal readiness via the ready pipe. Returns the child's
|
||||
// PID on success — the caller can then dial the bus IPC endpoint.
|
||||
// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a
|
||||
// detached `dws event _bus --client-id <id>` child and waits for the inherited
|
||||
// ready pipe. Windows has a named-pipe implementation in spawn_windows.go.
|
||||
//
|
||||
// stdio detach (plan invariant #7):
|
||||
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
|
||||
@@ -89,7 +90,7 @@ type SpawnConfig struct {
|
||||
// Parent (this function):
|
||||
// - Holds the read end open until either 1 byte is read or ReadyTimeout
|
||||
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) {
|
||||
if cfg.ClientID == "" {
|
||||
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
|
||||
@@ -35,7 +37,8 @@ import (
|
||||
// env-marker pattern is what Go's own os/exec tests use and stays
|
||||
// confined to this file.
|
||||
const (
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
|
||||
// values:
|
||||
// "ready" — write 'R' then sleep 30s (parent should see ready)
|
||||
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
|
||||
@@ -70,6 +73,26 @@ func TestMain(m *testing.M) {
|
||||
writeReady('R')
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "windows-ready":
|
||||
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
|
||||
if err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
defer listener.Close()
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
os.Exit(4)
|
||||
}
|
||||
_ = conn.Close()
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "windows-fail":
|
||||
os.Exit(5)
|
||||
case "windows-exit":
|
||||
os.Exit(0)
|
||||
case "windows-stall":
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Spawn starts the detached bus and waits for its inherited ready pipe.
|
||||
// ExtraFiles is intentionally confined to Unix: Go does not support it on
|
||||
// Windows.
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
return spawnWithReadyPipe(cfg)
|
||||
}
|
||||
|
||||
// applyDetach configures the child to live past parent death and not share
|
||||
// the parent's controlling terminal. Setsid puts the child in a new
|
||||
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
|
||||
|
||||
@@ -16,14 +16,101 @@
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
|
||||
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
|
||||
const createNewProcessGroup = 0x00000200
|
||||
|
||||
var (
|
||||
spawnWindowsDial = transport.Dial
|
||||
spawnWindowsPollInterval = 25 * time.Millisecond
|
||||
)
|
||||
|
||||
// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by
|
||||
// Go on Windows). Readiness is confirmed by dialing the bus's existing named
|
||||
// pipe. The child is reaped in the background after readiness, while an early
|
||||
// process exit is surfaced as ErrSpawnFailed.
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
|
||||
return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows")
|
||||
}
|
||||
if cfg.ExecPath == "" {
|
||||
execPath, err := spawnExecutable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("busctl: locate executable: %w", err)
|
||||
}
|
||||
cfg.ExecPath = execPath
|
||||
}
|
||||
if cfg.Env == nil {
|
||||
cfg.Env = os.Environ()
|
||||
}
|
||||
|
||||
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
|
||||
cmd := exec.Command(cfg.ExecPath, args...)
|
||||
cmd.Env = withoutReadyFDEnv(cfg.Env)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
applyDetach(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
|
||||
}
|
||||
pid = cmd.Process.Pid
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
timer := time.NewTimer(ReadyTimeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(spawnWindowsPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil {
|
||||
_ = conn.Close()
|
||||
return pid, nil
|
||||
}
|
||||
select {
|
||||
case waitErr := <-waitDone:
|
||||
if waitErr == nil {
|
||||
return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed)
|
||||
}
|
||||
return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr)
|
||||
case <-timer.C:
|
||||
_ = cmd.Process.Kill()
|
||||
select {
|
||||
case <-waitDone:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
return pid, ErrSpawnTimeout
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withoutReadyFDEnv(env []string) []string {
|
||||
prefix := strings.ToUpper(ReadyFDEnv) + "="
|
||||
out := make([]string, 0, len(env))
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(strings.ToUpper(entry), prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: createNewProcessGroup,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnUsesNamedPipeReadiness(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-test-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn() = %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("Spawn() pid = %d", pid)
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnReportsEarlyExit(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-fail-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
_, err := spawnWithMarker(t, "windows-fail", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
})
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnReportsCleanEarlyExit(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-exit-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
_, err := spawnWithMarker(t, "windows-exit", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
})
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnResolvesExecutable(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-resolve-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
testseam.Swap(t, &spawnExecutable, func() (string, error) { return os.Args[0], nil })
|
||||
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
|
||||
cfg.ExecPath = ""
|
||||
cfg.IPCEndpoint = endpoint
|
||||
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn() = %v", err)
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnValidationAndStartErrors(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-errors-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
if _, err := Spawn(SpawnConfig{ClientID: "client"}); err == nil {
|
||||
t.Fatal("missing endpoint unexpectedly succeeded")
|
||||
}
|
||||
testseam.Swap(t, &spawnExecutable, func() (string, error) {
|
||||
return "", errors.New("executable unavailable")
|
||||
})
|
||||
if _, err := Spawn(SpawnConfig{ClientID: "client", IPCEndpoint: endpoint}); err == nil {
|
||||
t.Fatal("executable lookup unexpectedly succeeded")
|
||||
}
|
||||
if _, err := Spawn(SpawnConfig{
|
||||
ClientID: "client",
|
||||
IPCEndpoint: endpoint,
|
||||
ExecPath: `C:\\definitely-missing-dws.exe`,
|
||||
}); err == nil {
|
||||
t.Fatal("missing executable unexpectedly started")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnReadyTimeoutTerminatesChild(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-stall-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
testseam.Swap(t, &ReadyTimeout, 100*time.Millisecond)
|
||||
_, err := spawnWithMarker(t, "windows-stall", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
})
|
||||
if !errors.Is(err, ErrSpawnTimeout) {
|
||||
t.Fatalf("Spawn() error = %v, want ErrSpawnTimeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnRemovesInheritedReadyFD(t *testing.T) {
|
||||
env := withoutReadyFDEnv([]string{
|
||||
"PATH=C:\\Windows",
|
||||
ReadyFDEnv + "=3",
|
||||
strings.ToLower(ReadyFDEnv) + "=4",
|
||||
})
|
||||
if len(env) != 1 || !strings.HasPrefix(env[0], "PATH=") {
|
||||
t.Fatalf("withoutReadyFDEnv() = %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsUnixReadyPipeValidation(t *testing.T) {
|
||||
if _, err := spawnWithReadyPipe(SpawnConfig{}); err == nil {
|
||||
t.Fatal("spawnWithReadyPipe() unexpectedly accepted an empty client ID")
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to
|
||||
// exit after the signal is sent. 5s covers the bus's own graceful tear-down
|
||||
// (broadcast Bye → consumer goroutines drain → cleanup) with margin.
|
||||
// DefaultStopTimeout is the wall-clock budget Stop waits for each graceful or
|
||||
// fallback termination phase. 5s covers the bus's own tear-down (broadcast
|
||||
// Bye → consumer goroutines drain → cleanup) with margin.
|
||||
const DefaultStopTimeout = 5 * time.Second
|
||||
|
||||
// ErrNotRunning indicates bus.lock either does not exist or its recorded
|
||||
@@ -33,20 +35,32 @@ const DefaultStopTimeout = 5 * time.Second
|
||||
// distinguish "nothing to stop" from "failed to stop".
|
||||
var ErrNotRunning = errors.New("busctl: bus is not running")
|
||||
|
||||
// ErrOwnerUnverified indicates the PID from bus.lock is alive but no longer
|
||||
// owns that lock. Stop refuses to send a process-level signal in this state
|
||||
// because the operating system may have reused the stale PID.
|
||||
var ErrOwnerUnverified = errors.New("busctl: bus process ownership could not be verified")
|
||||
|
||||
var (
|
||||
stopReadHolderPID = bus.ReadHolderPID
|
||||
stopAlive = process.Alive
|
||||
stopFindProcess = os.FindProcess
|
||||
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
|
||||
stopReadHolderPID = bus.ReadHolderPID
|
||||
stopValidateHolderOwner = bus.ValidateHolderOwnership
|
||||
stopAlive = process.Alive
|
||||
stopFindProcess = os.FindProcess
|
||||
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
|
||||
stopDial = transport.Dial
|
||||
stopRequest = requestBusStop
|
||||
stopWaitForBusExit = waitForBusExit
|
||||
)
|
||||
|
||||
// StopConfig identifies the target bus and tunes timing.
|
||||
type StopConfig struct {
|
||||
// WorkDir holds bus.lock; Stop reads the PID from there.
|
||||
WorkDir string
|
||||
// Timeout is the total wall-clock budget for graceful exit. After this,
|
||||
// Stop returns an error; it does NOT escalate to SIGKILL — leave that
|
||||
// to the operator.
|
||||
// IPCEndpoint enables the cross-platform graceful stop RPC. Callers that
|
||||
// know the bus identity should always provide it. Empty preserves the
|
||||
// legacy signal-only fallback for compatibility and focused tests.
|
||||
IPCEndpoint string
|
||||
// Timeout is the wall-clock budget for graceful exit and, if required,
|
||||
// the subsequent platform termination fallback.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
@@ -54,13 +68,12 @@ type StopConfig struct {
|
||||
// for the process to actually die. Returns ErrNotRunning if no bus is
|
||||
// running for that work dir.
|
||||
//
|
||||
// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop
|
||||
// watches its parent ctx for cancellation; the cobra `event _bus` command
|
||||
// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful
|
||||
// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which
|
||||
// the Go runtime maps to TerminateProcess for processes outside our
|
||||
// console group; for v1 that's acceptable (Windows graceful shutdown is
|
||||
// future work — plan §16 v2).
|
||||
// Stop first asks the bus to shut down through its owner-only IPC endpoint.
|
||||
// This is the normal path on every platform and lets the daemon cancel its
|
||||
// cloud source, notify consumers, and release its lock. If the endpoint is
|
||||
// unavailable (for example an older bus) or graceful shutdown times out, the
|
||||
// platform signal is used as a compatibility fallback: SIGTERM on Unix and
|
||||
// TerminateProcess via os.Kill on Windows.
|
||||
func Stop(cfg StopConfig) error {
|
||||
if cfg.WorkDir == "" {
|
||||
return errors.New("busctl: StopConfig.WorkDir is required")
|
||||
@@ -81,6 +94,32 @@ func Stop(cfg StopConfig) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: find process %d: %w", pid, err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.IPCEndpoint) != "" {
|
||||
if err := stopRequest(cfg.IPCEndpoint); err == nil && stopWaitForBusExit(pid, cfg.Timeout) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// The bus may finish shutting down immediately after the graceful wait
|
||||
// reaches its deadline (or after a failed IPC request). Recheck before
|
||||
// inspecting ownership so a completed stop is not reported as stale.
|
||||
if !stopAlive(pid) {
|
||||
return nil
|
||||
}
|
||||
owner, err := stopValidateHolderOwner(LockPath(cfg.WorkDir), pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: verify bus pid=%d ownership: %w", pid, err)
|
||||
}
|
||||
if !owner {
|
||||
// ValidateHolderOwnership observes a released lock both for a stale,
|
||||
// reused PID and for a bus that exits during the ownership probe. Only
|
||||
// the still-alive case is unsafe to signal.
|
||||
if !stopAlive(pid) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: pid=%d does not own %s; stale PID was not signalled", ErrOwnerUnverified, pid, LockPath(cfg.WorkDir))
|
||||
}
|
||||
|
||||
if err := stopSignalProcess(proc, stopSignal()); err != nil {
|
||||
// On many Unix platforms Signal returns "process already finished"
|
||||
// when the bus has just exited on its own — treat that as success.
|
||||
@@ -90,13 +129,45 @@ func Stop(cfg StopConfig) error {
|
||||
return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Poll for actual exit.
|
||||
deadline := time.Now().Add(cfg.Timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !stopAlive(pid) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if stopWaitForBusExit(pid, cfg.Timeout) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
|
||||
}
|
||||
|
||||
func requestBusStop(endpoint string) error {
|
||||
conn, err := stopDial(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: dial bus for stop: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
Role: transport.HelloRoleStop,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("busctl: write stop hello: %w", err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
return fmt.Errorf("busctl: read stop response: %w", err)
|
||||
}
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
|
||||
return fmt.Errorf("busctl: unexpected stop response type=%q reason=%q", bye.Type, bye.Reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForBusExit(pid int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !stopAlive(pid) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return !stopAlive(pid)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package busctl
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -23,7 +24,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestStop_NotRunningWhenLockMissing(t *testing.T) {
|
||||
@@ -34,7 +38,7 @@ func TestStop_NotRunningWhenLockMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStop_NotRunningWhenPIDDead(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
// Write a definitely-dead PID into bus.lock.
|
||||
if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil {
|
||||
@@ -46,6 +50,269 @@ func TestStop_NotRunningWhenPIDDead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopPrefersGracefulIPC(t *testing.T) {
|
||||
const pid = 4242
|
||||
stopped := false
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return !stopped })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(endpoint string) error {
|
||||
if endpoint != "test-endpoint" {
|
||||
t.Fatalf("stop endpoint = %q", endpoint)
|
||||
}
|
||||
stopped = true
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := Stop(StopConfig{
|
||||
WorkDir: "test-workdir",
|
||||
IPCEndpoint: "test-endpoint",
|
||||
Timeout: time.Second,
|
||||
}); err != nil {
|
||||
t.Fatalf("Stop() = %v", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("graceful IPC stop used %d process signals", signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopDoesNotSignalUnverifiedReusedPID(t *testing.T) {
|
||||
const pid = 4242
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return true })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(string) error { return errors.New("stale endpoint") })
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(path string, gotPID int) (bool, error) {
|
||||
if path != LockPath("test-workdir") || gotPID != pid {
|
||||
t.Fatalf("ownership check path=%q pid=%d", path, gotPID)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
err = Stop(StopConfig{
|
||||
WorkDir: "test-workdir",
|
||||
IPCEndpoint: "stale-endpoint",
|
||||
Timeout: time.Millisecond,
|
||||
})
|
||||
if !errors.Is(err, ErrOwnerUnverified) {
|
||||
t.Fatalf("Stop() error = %v, want ErrOwnerUnverified", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("unverified reused PID received %d signals", signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopAcceptsExitAtGracefulTimeoutBoundary(t *testing.T) {
|
||||
const pid = 4242
|
||||
|
||||
t.Run("before ownership validation", func(t *testing.T) {
|
||||
exited := false
|
||||
validated := false
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(string) error { return nil })
|
||||
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool {
|
||||
exited = true
|
||||
return false
|
||||
})
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
|
||||
validated = true
|
||||
return false, nil
|
||||
})
|
||||
|
||||
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
|
||||
t.Fatalf("Stop() = %v, want success after bus exit", err)
|
||||
}
|
||||
if validated {
|
||||
t.Fatal("ownership was validated after the bus had already exited")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("during ownership validation", func(t *testing.T) {
|
||||
exited := false
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(string) error { return nil })
|
||||
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { return false })
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
|
||||
exited = true
|
||||
return false, nil
|
||||
})
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
|
||||
t.Fatalf("Stop() = %v, want success after exit during ownership validation", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("exited bus received %d fallback signals", signals)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopReportsOwnershipValidationError(t *testing.T) {
|
||||
const pid = 4242
|
||||
errInjected := errors.New("ownership validation failed")
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return true })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
|
||||
return false, errInjected
|
||||
})
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
err = Stop(StopConfig{WorkDir: "test-workdir"})
|
||||
if !errors.Is(err, errInjected) {
|
||||
t.Fatalf("Stop() error = %v, want injected ownership error", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("ownership validation error sent %d signals", signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequestBusStopProtocol(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
endpoint := dwsevent.IPCEndpoint(
|
||||
dir,
|
||||
"open",
|
||||
dwsevent.SourceKindAppStream,
|
||||
dwsevent.IdentityHash(dir),
|
||||
)
|
||||
listener, err := transport.Listen(endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := transport.NewReader(conn)
|
||||
w := transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
if hello.Type != transport.FrameTypeHello || hello.Role != transport.HelloRoleStop {
|
||||
serverDone <- errors.New("unexpected stop hello")
|
||||
return
|
||||
}
|
||||
serverDone <- w.WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "stop_request",
|
||||
})
|
||||
}()
|
||||
|
||||
if err := requestBusStop(endpoint); err != nil {
|
||||
t.Fatalf("requestBusStop() = %v", err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("stop protocol server = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequestBusStopErrors(t *testing.T) {
|
||||
t.Run("dial", func(t *testing.T) {
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return nil, errors.New("dial failed")
|
||||
})
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write", func(t *testing.T) {
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return &queryErrorConn{failAt: 1}, nil
|
||||
})
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return &queryErrorConn{}, nil
|
||||
})
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unexpected response", func(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
})
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return client, nil
|
||||
})
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
var hello transport.Hello
|
||||
if err := transport.NewReader(server).ReadJSON(&hello); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
serverDone <- transport.NewWriter(server).WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "unexpected",
|
||||
})
|
||||
}()
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("stop protocol server = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStop_SignalsLiveProcess(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
@@ -63,6 +330,7 @@ func TestStop_SignalsLiveProcess(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
pid := cmd.Process.Pid
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
|
||||
|
||||
// Reap the child in background so Wait doesn't leave a zombie.
|
||||
waited := make(chan error, 1)
|
||||
@@ -111,6 +379,7 @@ func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) {
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
pid := cmd.Process.Pid
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
|
||||
|
||||
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -17,8 +17,7 @@ package busctl
|
||||
|
||||
import "os"
|
||||
|
||||
// stopSignal returns the graceful-shutdown signal for Windows. The Go
|
||||
// runtime maps os.Interrupt to TerminateProcess for non-console-group
|
||||
// processes — not truly graceful, but acceptable for v1 (Windows graceful
|
||||
// shutdown via Ctrl+Break is in the v2 backlog, plan §16).
|
||||
func stopSignal() os.Signal { return os.Interrupt }
|
||||
// stopSignal is only the fallback after the graceful IPC stop path fails or
|
||||
// times out. Go maps os.Kill to TerminateProcess on Windows; os.Interrupt is
|
||||
// unsupported and returns syscall.EWINDOWS.
|
||||
func stopSignal() os.Signal { return os.Kill }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWindowsStopFallbackUsesKill(t *testing.T) {
|
||||
if stopSignal() != os.Kill {
|
||||
t.Fatalf("stopSignal() = %v, want os.Kill", stopSignal())
|
||||
}
|
||||
}
|
||||
+281
-6
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -492,10 +493,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
|
||||
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
|
||||
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
|
||||
// 2. HTTP PUT file content to OSS
|
||||
// 3. insert_document_block with attachment element
|
||||
// 4. list_document_blocks → prove the uploaded resource is visible in the document
|
||||
func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -551,7 +553,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: get upload credentials (uploadUrl + resourceId)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
|
||||
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
@@ -569,7 +571,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
// Step 2: HTTP PUT file to OSS
|
||||
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
|
||||
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
|
||||
|
||||
ossHeaders := map[string]string{
|
||||
"Content-Type": mimeType,
|
||||
@@ -592,7 +594,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
// Step 3: insert block into document
|
||||
deps.Out.PrintInfo("[3/3] 插入块到文档...")
|
||||
deps.Out.PrintInfo("[3/4] 插入块到文档...")
|
||||
|
||||
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
@@ -644,7 +646,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
insertArgs["referenceBlockId"] = v
|
||||
}
|
||||
|
||||
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
|
||||
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
@@ -665,6 +668,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
insertResult := map[string]any{}
|
||||
if strings.TrimSpace(insertText) != "" {
|
||||
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
|
||||
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
|
||||
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
|
||||
}
|
||||
}
|
||||
insertedBlockID := insertedDocBlockID(insertResult)
|
||||
|
||||
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
|
||||
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
|
||||
if verifyErr != nil {
|
||||
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
|
||||
}
|
||||
if insertedBlockID == "" {
|
||||
insertedBlockID = verifiedBlockID
|
||||
}
|
||||
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
@@ -674,16 +694,271 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
|
||||
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||||
"inserted": true, "verified": true,
|
||||
},
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
{"name": "verify", "status": "success"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_insert_verification_failed"),
|
||||
apperrors.WithFailureStage("verify"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
{"name": "verify", "status": "failed"},
|
||||
},
|
||||
}),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
var docMediaVerifyWait = waitForDocVerification
|
||||
|
||||
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
|
||||
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
lastErr = nil
|
||||
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
|
||||
return found, nil
|
||||
}
|
||||
}
|
||||
if attempt < len(delays) {
|
||||
if err := docMediaVerifyWait(ctx, delays[attempt]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
|
||||
}
|
||||
|
||||
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
|
||||
const pageSize = 50
|
||||
const maxItems = 5000
|
||||
all := make([]any, 0, pageSize)
|
||||
seenPageIdentities := map[string]bool{}
|
||||
for start := 0; start < maxItems; start += pageSize {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||||
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||||
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
|
||||
}
|
||||
payload = nestedDocMap(payload)
|
||||
blocks, ok := payload["blocks"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
|
||||
}
|
||||
pageIdentity := docBlockPageIdentity(blocks)
|
||||
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
|
||||
return nil, fmt.Errorf("list_document_blocks 分页停滞")
|
||||
}
|
||||
if pageIdentity != "" {
|
||||
seenPageIdentities[pageIdentity] = true
|
||||
}
|
||||
all = append(all, blocks...)
|
||||
hasMore, hasMoreKnown := payload["hasMore"].(bool)
|
||||
if hasMoreKnown && !hasMore {
|
||||
return all, nil
|
||||
}
|
||||
if !hasMoreKnown {
|
||||
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
if !hasMoreKnown && len(blocks) < pageSize {
|
||||
return all, nil
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("文档块超过安全回读上限")
|
||||
}
|
||||
|
||||
func docBlockPageIdentity(blocks []any) string {
|
||||
if len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
ids := make([]string, 0, len(blocks))
|
||||
for _, value := range blocks {
|
||||
id := ""
|
||||
switch block := value.(type) {
|
||||
case map[string]any:
|
||||
id = directDocBlockIdentity(block)
|
||||
if id == "" {
|
||||
if element, ok := block["element"].(map[string]any); ok {
|
||||
id = directDocBlockIdentity(element)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
if len(block) > 1 {
|
||||
if attributes, ok := block[1].(map[string]any); ok {
|
||||
id = directDocBlockIdentity(attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
encoded, _ := json.Marshal(ids)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func directDocBlockIdentity(block map[string]any) string {
|
||||
for _, key := range []string{"blockId", "id", "uuid", "elementId"} {
|
||||
if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nestedDocMap(data map[string]any) map[string]any {
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
return nestedDocMap(nested)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func nestedDocString(value any, keys ...string) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range keys {
|
||||
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
orderedKeys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
orderedKeys = append(orderedKeys, key)
|
||||
}
|
||||
sort.Strings(orderedKeys)
|
||||
for _, key := range orderedKeys {
|
||||
if text := nestedDocString(typed[key], keys...); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if text := nestedDocString(child, keys...); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// insertedDocBlockID only accepts explicit block IDs from the insert result or
|
||||
// known response wrappers. Arbitrary IDs may belong to the document, operator,
|
||||
// or request and must not become a hard constraint for the media readback.
|
||||
func insertedDocBlockID(data map[string]any) string {
|
||||
for _, key := range []string{"blockId", "elementId"} {
|
||||
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data", "content"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
if text := insertedDocBlockID(inner); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
|
||||
for _, value := range blocks {
|
||||
candidateID := nestedDocString(value, "blockId", "id", "uuid")
|
||||
if candidateID == "" || (blockID != "" && candidateID != blockID) {
|
||||
continue
|
||||
}
|
||||
mediaValue := docMediaReadbackValue(value)
|
||||
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
|
||||
return candidateID
|
||||
}
|
||||
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
|
||||
return candidateID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func docMediaReadbackValue(value any) any {
|
||||
block, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
encoded, ok := block["jsonml"].(string)
|
||||
if !ok || strings.TrimSpace(encoded) == "" {
|
||||
return value
|
||||
}
|
||||
var decoded any
|
||||
if json.Unmarshal([]byte(encoded), &decoded) != nil {
|
||||
return value
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func docNumberAsInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed >= 0 && typed == float64(int(typed)) {
|
||||
return int(typed), true
|
||||
}
|
||||
case int:
|
||||
return typed, typed >= 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
|
||||
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
|
||||
var data map[string]any
|
||||
|
||||
@@ -3,7 +3,9 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -74,6 +77,8 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
oldPut, oldGet := httpPutFile, httpGetFile
|
||||
t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
|
||||
testseam.Swap(t, &helperSleep, func(time.Duration) {})
|
||||
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
file := filepath.Join(t.TempDir(), "file.txt")
|
||||
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -193,6 +198,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
|
||||
t.Fatal("media insert failure returned nil")
|
||||
}
|
||||
})
|
||||
t.Run("media insert response parse failure", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`},
|
||||
{text: `{`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
|
||||
t.Fatal("invalid media insert response returned nil")
|
||||
}
|
||||
})
|
||||
t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
|
||||
{text: `{"blockId":"media-block"}`},
|
||||
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
|
||||
}
|
||||
if caller.args["format"] != "jsonml" {
|
||||
t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"])
|
||||
}
|
||||
})
|
||||
t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
|
||||
{text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`},
|
||||
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run("large image becomes attachment", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
large := filepath.Join(t.TempDir(), "large.png")
|
||||
@@ -202,7 +248,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
|
||||
if err := os.Truncate(large, 21*1024*1024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
|
||||
{text: `{}`},
|
||||
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, large, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,6 +290,181 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) {
|
||||
testseam.Swap(t, &helperSleep, func(time.Duration) {})
|
||||
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
ctx := context.Background()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
}{
|
||||
{"call failure", []scriptedToolStep{{err: errors.New("read")}}},
|
||||
{"invalid json", []scriptedToolStep{{text: `{`}}},
|
||||
{"missing blocks", []scriptedToolStep{{text: `{}`}}},
|
||||
{"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}},
|
||||
{"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
|
||||
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
|
||||
t.Fatal("defensive readback returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("total count and nested payload", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}})
|
||||
blocks, err := readAllDocBlocksForVerification(ctx, "node")
|
||||
if err != nil || len(blocks) != 1 {
|
||||
t.Fatalf("blocks=%#v err=%v", blocks, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) {
|
||||
blocks := make([]any, 50)
|
||||
for index := range blocks {
|
||||
blocks[index] = map[string]any{"blockType": "paragraph"}
|
||||
}
|
||||
first, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": true, "totalCount": 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": false, "totalCount": 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: string(first)}, {text: string(second)}}}
|
||||
installScriptedCaller(t, caller)
|
||||
got, err := readAllDocBlocksForVerification(ctx, "node")
|
||||
if err != nil || len(got) != 100 || caller.calls != 2 {
|
||||
t.Fatalf("blocks=%d calls=%d err=%v, want 100 blocks from two pages", len(got), caller.calls, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"blocks":[{"id":"first"}],"hasMore":true,"totalCount":1}`},
|
||||
{text: `{"blocks":[{"id":"second"}],"hasMore":false,"totalCount":2}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
got, err := readAllDocBlocksForVerification(ctx, "node")
|
||||
if err != nil || len(got) != 2 || caller.calls != 2 {
|
||||
t.Fatalf("blocks=%d calls=%d err=%v, want both explicitly advertised pages", len(got), caller.calls, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page identity accepts nested and JSONML block IDs", func(t *testing.T) {
|
||||
if got := docBlockPageIdentity([]any{map[string]any{"element": map[string]any{"blockId": "nested-block"}}}); got != `["nested-block"]` {
|
||||
t.Fatalf("nested block page identity = %q", got)
|
||||
}
|
||||
if got := docBlockPageIdentity([]any{[]any{"p", map[string]any{"uuid": "jsonml-block"}}}); got != `["jsonml-block"]` {
|
||||
t.Fatalf("JSONML block page identity = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown pagination short page", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}})
|
||||
if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 {
|
||||
t.Fatalf("blocks=%#v err=%v", blocks, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bounded retry failure", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}})
|
||||
if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil {
|
||||
t.Fatal("missing media unexpectedly verified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient read failure retries", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{err: errors.New("temporary read failure")},
|
||||
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "")
|
||||
if err != nil || blockID != "media-block" || caller.calls != 2 {
|
||||
t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block identity alone does not verify media", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}})
|
||||
if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil {
|
||||
t.Fatal("matching block ID with a different resource unexpectedly verified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block read safety limit", func(t *testing.T) {
|
||||
steps := make([]scriptedToolStep, 100)
|
||||
for index := range steps {
|
||||
steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)}
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: steps})
|
||||
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
|
||||
t.Fatal("oversized block read returned nil")
|
||||
}
|
||||
})
|
||||
|
||||
if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true {
|
||||
t.Fatalf("nested map=%#v", got)
|
||||
}
|
||||
if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" {
|
||||
t.Fatal("nested string traversal failed")
|
||||
}
|
||||
if got := nestedDocString(map[string]any{"z": map[string]any{"id": "last"}, "a": map[string]any{"id": "first"}}, "id"); got != "first" {
|
||||
t.Fatalf("nested string traversal = %q, want deterministic key order", got)
|
||||
}
|
||||
if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" {
|
||||
t.Fatalf("trusted inserted block ID = %q, want block", got)
|
||||
}
|
||||
if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" {
|
||||
t.Fatalf("untrusted inserted block ID = %q, want empty", got)
|
||||
}
|
||||
blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}}
|
||||
if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" {
|
||||
t.Fatal("media block matching failed")
|
||||
}
|
||||
if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" {
|
||||
t.Fatal("invalid JSONML unexpectedly verified")
|
||||
}
|
||||
if got := docMediaReadbackValue("plain"); got != "plain" {
|
||||
t.Fatalf("non-object media readback = %#v, want unchanged value", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false},
|
||||
} {
|
||||
_, ok := docNumberAsInt(tc.value)
|
||||
if ok != tc.want {
|
||||
t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocMediaReadbackStopsOnCancellation(t *testing.T) {
|
||||
if err := waitForDocVerification(nil, time.Nanosecond); err != nil {
|
||||
t.Fatalf("completed verification wait = %v", err)
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled verification wait = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if _, err := verifyInsertedDocMedia(cancelled, "node", "", "missing", ""); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled media verification = %v, want context.Canceled", err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("cancelled media verification calls = %d, want 1", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
|
||||
oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -194,6 +195,7 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
|
||||
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
@@ -733,7 +733,7 @@ var RecordQuery = shortcut.Shortcut{
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
return rt.CallMCP("query_records", params)
|
||||
return executeRecordQuery(rt, params)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
step.Status = "unknown"
|
||||
step.Error = writeErr.Error()
|
||||
}
|
||||
remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch)
|
||||
remaining, verifyErr := queryDeletedRecordsByIDs(rt, baseID, tableID, batch)
|
||||
if verifyErr == nil && len(remaining) > 0 {
|
||||
verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ","))
|
||||
}
|
||||
@@ -303,20 +303,33 @@ func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batc
|
||||
}
|
||||
|
||||
func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
|
||||
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids),
|
||||
})
|
||||
window, err := queryRecordWindow(rt, map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": ids,
|
||||
}, len(ids))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, found := findRecords(data)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("query_records read-back is missing records")
|
||||
// Exact-ID verification below compares every requested ID with the returned
|
||||
// records. The service can publish a continuation even after all requested
|
||||
// IDs are present, so hasMore is not evidence that this bounded read-back is
|
||||
// incomplete.
|
||||
return window.Records, nil
|
||||
}
|
||||
|
||||
func queryDeletedRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
|
||||
remaining := make([]map[string]any, 0)
|
||||
for offset := 0; offset < len(ids); offset += recordQueryServicePageSize {
|
||||
end := minInt(offset+recordQueryServicePageSize, len(ids))
|
||||
chunk := ids[offset:end]
|
||||
window, err := queryRecordWindow(rt, map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": chunk,
|
||||
}, len(chunk))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remaining = append(remaining, window.Records...)
|
||||
}
|
||||
if responseHasMore(data) {
|
||||
return nil, fmt.Errorf("query_records read-back is incomplete")
|
||||
}
|
||||
return records, nil
|
||||
return remaining, nil
|
||||
}
|
||||
|
||||
func recordIDs(records []map[string]any) []string {
|
||||
|
||||
@@ -10,13 +10,245 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// query_records is backed by a service pager whose stable typed-envelope
|
||||
// boundary is 20 records. Asking that layer to aggregate more than one page can
|
||||
// drop the envelope fields used by the record projector, turning a successful
|
||||
// read into an empty collection. Keep every remote request on one service page
|
||||
// and aggregate only after each page has passed the CLI's shape validation.
|
||||
const (
|
||||
recordQueryServicePageSize = 20
|
||||
recordQueryMaxConsecutiveEmptyPages = 3
|
||||
)
|
||||
|
||||
type recordQueryWindow struct {
|
||||
Records []map[string]any
|
||||
HasMore bool
|
||||
NextCursor string
|
||||
Pages int
|
||||
TotalCount any
|
||||
HasTotalCount bool
|
||||
}
|
||||
|
||||
func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit int) (recordQueryWindow, error) {
|
||||
if limit <= 0 {
|
||||
return recordQueryWindow{}, fmt.Errorf("record query limit must be positive, got %d", limit)
|
||||
}
|
||||
request := cloneAnyMap(params)
|
||||
cursor, _ := request["cursor"].(string)
|
||||
cursor = strings.TrimSpace(cursor)
|
||||
seen := map[string]bool{}
|
||||
if cursor != "" {
|
||||
seen[cursor] = true
|
||||
}
|
||||
window := recordQueryWindow{Records: make([]map[string]any, 0, limit)}
|
||||
consecutiveEmptyPages := 0
|
||||
|
||||
for len(window.Records) < limit {
|
||||
pageSize := minInt(recordQueryServicePageSize, limit-len(window.Records))
|
||||
request["limit"] = pageSize
|
||||
if cursor == "" {
|
||||
delete(request, "cursor")
|
||||
} else {
|
||||
request["cursor"] = cursor
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "query_records", request)
|
||||
if err != nil {
|
||||
return recordQueryWindow{}, err
|
||||
}
|
||||
if !window.HasTotalCount {
|
||||
window.TotalCount, window.HasTotalCount = responseTotalCount(data)
|
||||
}
|
||||
records, found := findRecords(data)
|
||||
if !found {
|
||||
if explicitEmptyRecordQuery(data) {
|
||||
window.Pages++
|
||||
window.HasMore = false
|
||||
window.NextCursor = ""
|
||||
return window, nil
|
||||
}
|
||||
return recordQueryWindow{}, fmt.Errorf("query_records page %d is missing the records collection", window.Pages+1)
|
||||
}
|
||||
window.Records = append(window.Records, records...)
|
||||
window.Pages++
|
||||
|
||||
if !responseHasMore(data) {
|
||||
window.HasMore = false
|
||||
window.NextCursor = ""
|
||||
return window, nil
|
||||
}
|
||||
if len(records) == 0 {
|
||||
consecutiveEmptyPages++
|
||||
if consecutiveEmptyPages >= recordQueryMaxConsecutiveEmptyPages {
|
||||
return recordQueryWindow{}, fmt.Errorf(
|
||||
"query_records made no progress for %d consecutive pages",
|
||||
consecutiveEmptyPages,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
consecutiveEmptyPages = 0
|
||||
}
|
||||
next := responseCursor(data)
|
||||
if next == "" {
|
||||
return recordQueryWindow{}, fmt.Errorf("query_records page %d reports more data but no next cursor", window.Pages)
|
||||
}
|
||||
if seen[next] {
|
||||
return recordQueryWindow{}, fmt.Errorf("query_records cursor cycle detected at %q", next)
|
||||
}
|
||||
seen[next] = true
|
||||
cursor = next
|
||||
window.HasMore = true
|
||||
window.NextCursor = next
|
||||
}
|
||||
|
||||
return window, nil
|
||||
}
|
||||
|
||||
// explicitEmptyRecordQuery recognizes the service's reviewed zero-match wire
|
||||
// shape. It is deliberately stricter than "records is absent": only a complete
|
||||
// success envelope with an empty data object is accepted as an empty set.
|
||||
func explicitEmptyRecordQuery(data map[string]any) bool {
|
||||
if data == nil || data["success"] != true || data["status"] != "success" {
|
||||
return false
|
||||
}
|
||||
payload, ok := data["data"].(map[string]any)
|
||||
if !ok || len(payload) != 0 {
|
||||
return false
|
||||
}
|
||||
if rawError, exists := data["error"]; exists && rawError != nil {
|
||||
errorObject, ok := rawError.(map[string]any)
|
||||
if !ok || len(errorObject) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) error {
|
||||
limit := 100
|
||||
if rt.Changed("limit") {
|
||||
limit = rt.Int("limit")
|
||||
}
|
||||
if limit < 1 || limit > recordBatchSize {
|
||||
return fmt.Errorf("--limit must be in [1,%d], got %d", recordBatchSize, limit)
|
||||
}
|
||||
requestedIDs, exactIDQuery, err := recordQueryRequestedIDs(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exactIDQuery {
|
||||
if len(requestedIDs) > recordBatchSize {
|
||||
return fmt.Errorf("--record-ids accepts at most %d unique IDs, got %d", recordBatchSize, len(requestedIDs))
|
||||
}
|
||||
params = cloneAnyMap(params)
|
||||
params["recordIds"] = requestedIDs
|
||||
limit = minInt(limit, len(requestedIDs))
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": "query_records",
|
||||
"arguments": params,
|
||||
})
|
||||
}
|
||||
window, err := queryRecordWindow(rt, params, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exactIDQuery {
|
||||
complete, err := validateExactRecordQuery(window.Records, requestedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if complete {
|
||||
// The service may advertise unrelated continuation after every
|
||||
// requested ID is already present. Exact-ID completion is stronger
|
||||
// evidence than that residual pager state.
|
||||
window.HasMore = false
|
||||
window.NextCursor = ""
|
||||
}
|
||||
}
|
||||
records := make([]any, 0, len(window.Records))
|
||||
for _, record := range window.Records {
|
||||
records = append(records, record)
|
||||
}
|
||||
data := map[string]any{
|
||||
"records": records,
|
||||
"hasMore": window.HasMore,
|
||||
"page": window.Pages,
|
||||
"size": len(window.Records),
|
||||
}
|
||||
if window.NextCursor != "" {
|
||||
data["nextCursor"] = window.NextCursor
|
||||
}
|
||||
if window.HasTotalCount {
|
||||
data["totalCount"] = window.TotalCount
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func responseTotalCount(data map[string]any) (any, bool) {
|
||||
for {
|
||||
if totalCount, exists := data["totalCount"]; exists {
|
||||
return totalCount, true
|
||||
}
|
||||
nested, ok := data["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
data = nested
|
||||
}
|
||||
}
|
||||
|
||||
func recordQueryRequestedIDs(params map[string]any) ([]string, bool, error) {
|
||||
raw, exists := params["recordIds"]
|
||||
if !exists {
|
||||
return nil, false, nil
|
||||
}
|
||||
values, ok := raw.([]string)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("recordIds must be a string list, got %T", raw)
|
||||
}
|
||||
ids, err := parseRecordIDs(values)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return ids, true, nil
|
||||
}
|
||||
|
||||
func validateExactRecordQuery(records []map[string]any, requestedIDs []string) (bool, error) {
|
||||
wanted := make(map[string]bool, len(requestedIDs))
|
||||
for _, id := range requestedIDs {
|
||||
wanted[id] = true
|
||||
}
|
||||
seen := make(map[string]bool, len(records))
|
||||
for index, record := range records {
|
||||
id := recordID(record)
|
||||
if id == "" {
|
||||
return false, fmt.Errorf("query_records exact-ID result %d is missing recordId", index)
|
||||
}
|
||||
if !wanted[id] {
|
||||
return false, fmt.Errorf("query_records exact-ID result contains unexpected recordId %q", id)
|
||||
}
|
||||
if seen[id] {
|
||||
return false, fmt.Errorf("query_records exact-ID result contains duplicate recordId %q", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return len(seen) == len(wanted), nil
|
||||
}
|
||||
|
||||
func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
cursor := ""
|
||||
seen := map[string]bool{}
|
||||
for page := 0; ; page++ {
|
||||
request := cloneAnyMap(params)
|
||||
request["limit"] = recordBatchSize
|
||||
request["limit"] = recordQueryServicePageSize
|
||||
if cursor != "" {
|
||||
request["cursor"] = cursor
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -265,6 +266,446 @@ func recordListJSON(t *testing.T, records []map[string]any) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func pagedRecordQueryResponse(t *testing.T, records []map[string]any, args map[string]any) string {
|
||||
t.Helper()
|
||||
limit, ok := args["limit"].(int)
|
||||
if !ok || limit < 1 || limit > recordQueryServicePageSize {
|
||||
t.Fatalf("query_records limit = %#v, want 1..%d", args["limit"], recordQueryServicePageSize)
|
||||
}
|
||||
offset := 0
|
||||
if cursor, _ := args["cursor"].(string); cursor != "" {
|
||||
if _, err := fmt.Sscanf(cursor, "offset-%d", &offset); err != nil {
|
||||
t.Fatalf("invalid test cursor %q: %v", cursor, err)
|
||||
}
|
||||
}
|
||||
end := minInt(offset+limit, len(records))
|
||||
pageRecords := records[offset:end]
|
||||
data := map[string]any{"records": pageRecords, "hasMore": end < len(records), "totalCount": len(records)}
|
||||
if end < len(records) {
|
||||
data["nextCursor"] = fmt.Sprintf("offset-%d", end)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"success": true,
|
||||
"hasMore": end < len(records),
|
||||
"page": offset/recordQueryServicePageSize + 1,
|
||||
"size": limit,
|
||||
"data": data,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func runRecordQueryShortcutCLI(t *testing.T, caller *upsertByKeyCaller, limit int, extra ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
args := []string{"aitable", "+record-query", "--base-id", "base", "--table-id", "table", "--limit", fmt.Sprint(limit)}
|
||||
root.SetArgs(append(args, extra...))
|
||||
err := root.Execute()
|
||||
if stdout.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("decode record query output %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) {
|
||||
for _, size := range []int{20, 21, 22, 100} {
|
||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||
records := updateFixtureRecords(0, size, "可见")
|
||||
caller := &upsertByKeyCaller{}
|
||||
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return pagedRecordQueryResponse(t, records, args), nil
|
||||
}
|
||||
payload, err := runRecordQueryShortcutCLI(t, caller, size)
|
||||
if err != nil {
|
||||
t.Fatalf("record query size %d: %v", size, err)
|
||||
}
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok || payload["success"] != true || data["hasMore"] != false || data["totalCount"] != float64(size) || len(data["records"].([]any)) != size {
|
||||
t.Fatalf("record query size %d payload = %#v", size, payload)
|
||||
}
|
||||
wantCalls := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
|
||||
if len(caller.calls) != wantCalls || data["page"] != float64(wantCalls) || data["size"] != float64(size) {
|
||||
t.Fatalf("record query size %d calls=%d payload=%#v, want calls=%d", size, len(caller.calls), payload, wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryDryRunStopsBeforeTransportE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
payload, err := runRecordQueryShortcutCLI(t, caller, 5, "--query", "needle", "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("record query dry-run error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("record query dry-run crossed transport: %#v", caller.calls)
|
||||
}
|
||||
arguments, _ := payload["arguments"].(map[string]any)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "query_records" ||
|
||||
arguments["baseId"] != "base" || arguments["tableId"] != "table" || arguments["keyword"] != "needle" || arguments["limit"] != float64(5) {
|
||||
t.Fatalf("record query dry-run payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryDryRunRejectsInvalidLocalPlanE2E(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
limit int
|
||||
extra []string
|
||||
want string
|
||||
}{
|
||||
{name: "zero limit", limit: 0, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"},
|
||||
{name: "excessive limit", limit: recordBatchSize + 1, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"},
|
||||
{name: "empty record IDs", limit: 100, extra: []string{"--record-ids", " ", "--dry-run"}, want: "至少包含一个非空 recordId"},
|
||||
{
|
||||
name: "excessive record IDs",
|
||||
limit: 100,
|
||||
extra: []string{"--record-ids", strings.Join(recordIDFixtures(recordBatchSize+1), ","), "--dry-run"},
|
||||
want: "at most 100 unique IDs",
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
payload, err := runRecordQueryShortcutCLI(t, caller, testCase.limit, testCase.extra...)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("record query dry-run error = %v, want %q (payload=%#v)", err, testCase.want, payload)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid record query dry-run crossed transport: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryExactIDsIgnoreResidualContinuationE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
if call != 0 {
|
||||
return "", errors.New("exact-ID query followed residual continuation")
|
||||
}
|
||||
if args["limit"] != 2 {
|
||||
t.Fatalf("exact-ID service limit = %#v, want 2", args["limit"])
|
||||
}
|
||||
ids, ok := args["recordIds"].([]string)
|
||||
if !ok || !slices.Equal(ids, []string{"r1", "r2"}) {
|
||||
t.Fatalf("exact-ID request IDs = %#v", args["recordIds"])
|
||||
}
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"},{"recordId":"r2"}],"hasMore":true,"nextCursor":"residual"}}`, nil
|
||||
}}
|
||||
payload, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r2,r1,r2")
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if err != nil || len(caller.calls) != 1 || data["hasMore"] != false || data["size"] != float64(2) {
|
||||
t.Fatalf("exact-ID payload=%#v calls=%d err=%v", payload, len(caller.calls), err)
|
||||
}
|
||||
if _, exists := data["nextCursor"]; exists {
|
||||
t.Fatalf("exact-ID payload retained residual cursor: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testing.T) {
|
||||
t.Run("non-positive window limit", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
if _, err := queryRecordWindow(rt, nil, 0); err == nil {
|
||||
t.Fatal("non-positive record query window limit accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial cursor cycle", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":true,"nextCursor":"seed"}}`, nil
|
||||
}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
if _, err := queryRecordWindow(rt, map[string]any{"cursor": " seed "}, 2); err == nil || !strings.Contains(err.Error(), "cursor cycle") {
|
||||
t.Fatalf("initial cursor cycle error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("strict explicit empty shape", func(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "status": "success", "data": map[string]any{"unexpected": true}},
|
||||
{"success": true, "status": "success", "data": map[string]any{}, "error": "bad"},
|
||||
} {
|
||||
if explicitEmptyRecordQuery(data) {
|
||||
t.Fatalf("invalid empty-query envelope accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit empty page", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`, nil
|
||||
}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
window, err := queryRecordWindow(rt, map[string]any{}, 1)
|
||||
if err != nil || len(window.Records) != 0 || window.Pages != 1 || window.HasMore || window.NextCursor != "" {
|
||||
t.Fatalf("explicit empty window=%#v err=%v", window, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing records collection", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
|
||||
return `{}`, nil
|
||||
}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "missing the records collection") {
|
||||
t.Fatalf("missing records error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changing cursors cannot hide sustained empty pages", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"empty-%d"}}`,
|
||||
call,
|
||||
), nil
|
||||
}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "no progress") {
|
||||
t.Fatalf("sustained empty pages error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != recordQueryMaxConsecutiveEmptyPages {
|
||||
t.Fatalf("query calls = %d, want %d", len(caller.calls), recordQueryMaxConsecutiveEmptyPages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid shortcut limit", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "query"}
|
||||
cmd.Flags().Int("limit", 0, "")
|
||||
if err := cmd.Flags().Set("limit", "0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := shortcut.RuntimeContextForTest(cmd, RecordQuery)
|
||||
if err := executeRecordQuery(rt, map[string]any{}); err == nil {
|
||||
t.Fatal("invalid shortcut limit accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid exact-ID request", func(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
params map[string]any
|
||||
want string
|
||||
}{
|
||||
{name: "wrong type", params: map[string]any{"recordIds": 1}, want: "string list"},
|
||||
{name: "empty", params: map[string]any{"recordIds": []string{" "}}, want: "至少包含一个非空 recordId"},
|
||||
{name: "over service limit", params: map[string]any{"recordIds": recordIDFixtures(recordBatchSize + 1)}, want: "at most 100 unique IDs"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
err := executeRecordQuery(rt, testCase.params)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("exact-ID request error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid exact-ID request made %d service calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid exact-ID response", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return `{"success":true,"data":{"records":[{"recordId":"other"}]}}`, nil
|
||||
}}
|
||||
if _, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r1"); err == nil || !strings.Contains(err.Error(), "unexpected recordId") {
|
||||
t.Fatalf("unexpected exact-ID response error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := validateExactRecordQuery([]map[string]any{{}}, []string{"r1"}); err == nil || !strings.Contains(err.Error(), "missing recordId") {
|
||||
t.Fatalf("missing exact-ID response error = %v", err)
|
||||
}
|
||||
duplicate := []map[string]any{{"recordId": "r1"}, {"recordId": "r1"}}
|
||||
if _, err := validateExactRecordQuery(duplicate, []string{"r1", "r2"}); err == nil || !strings.Contains(err.Error(), "duplicate recordId") {
|
||||
t.Fatalf("duplicate exact-ID response error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("shortcut query failure", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
|
||||
return "", errors.New("query unavailable")
|
||||
}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
|
||||
if err := executeRecordQuery(rt, map[string]any{}); err == nil || !strings.Contains(err.Error(), "query unavailable") {
|
||||
t.Fatalf("shortcut query failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bounded continuation is published", func(t *testing.T) {
|
||||
records := updateFixtureRecords(0, 21, "visible")
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return pagedRecordQueryResponse(t, records, args), nil
|
||||
}}
|
||||
payload, err := runRecordQueryShortcutCLI(t, caller, 20)
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if err != nil || data["hasMore"] != true || data["nextCursor"] != "offset-20" {
|
||||
t.Fatalf("bounded continuation payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
if call == 1 {
|
||||
if _, exists := args["cursor"]; exists {
|
||||
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
|
||||
}
|
||||
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
|
||||
}
|
||||
if args["cursor"] != "c1" {
|
||||
t.Fatalf("continued deletion readback cursor = %#v", args["cursor"])
|
||||
}
|
||||
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || out == "" || len(caller.calls) != 3 {
|
||||
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
if call == 1 {
|
||||
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
|
||||
}
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
|
||||
t.Fatalf("delete continued remaining error = %#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
|
||||
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
|
||||
t.Fatalf("delete stalled continuation error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *testing.T) {
|
||||
for _, operation := range []string{"update", "upsert", "delete"} {
|
||||
for _, size := range []int{21, 22, 100} {
|
||||
t.Run(fmt.Sprintf("%s_%d", operation, size), func(t *testing.T) {
|
||||
records := updateFixtureRecords(0, size, "完成")
|
||||
caller := &upsertByKeyCaller{}
|
||||
queryCalls := 0
|
||||
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return `{"success":true}`, nil
|
||||
}
|
||||
queryCalls++
|
||||
if operation == "delete" {
|
||||
ids := args["recordIds"].([]string)
|
||||
empty := make([]map[string]any, len(ids))
|
||||
response := pagedRecordQueryResponse(t, empty, args)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(response), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := payload["data"].(map[string]any)
|
||||
page["records"] = []any{}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return string(raw), nil
|
||||
}
|
||||
response := pagedRecordQueryResponse(t, records, args)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(response), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := payload["data"].(map[string]any)
|
||||
if len(page["records"].([]any))+recordQueryServicePageSize*(queryCalls-1) >= size {
|
||||
// The live service can retain a continuation after it has
|
||||
// returned every requested record ID. Exact-ID verification
|
||||
// must use ID coverage, not this advisory bit.
|
||||
page["hasMore"] = true
|
||||
page["nextCursor"] = "ignored-after-complete-id-set"
|
||||
payload["hasMore"] = true
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
var out string
|
||||
var err error
|
||||
if operation == "delete" {
|
||||
out, err = runRecordDeleteCLI(t, caller, recordIDs(records))
|
||||
} else {
|
||||
out, err = runRecordBatchCLI(t, caller, "+record-"+operation, records)
|
||||
}
|
||||
if err != nil || out == "" {
|
||||
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
|
||||
}
|
||||
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
|
||||
if queryCalls != wantQueries {
|
||||
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) {
|
||||
records := updateFixtureRecords(0, 101, "完成")
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
@@ -395,6 +836,10 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
}}
|
||||
@@ -407,7 +852,7 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
|
||||
t.Fatalf("delete output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" {
|
||||
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
|
||||
t.Fatalf("delete call sequence = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
@@ -421,6 +866,17 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || !strings.Contains(out, `"status": "verified_absent"`) {
|
||||
t.Fatalf("delete explicit empty success = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing records contract is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
@@ -450,6 +906,10 @@ func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
|
||||
}}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -18,6 +22,9 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,6 +35,21 @@ var (
|
||||
docMkdirTemp = os.MkdirTemp
|
||||
docRemoveAll = os.RemoveAll
|
||||
docDownload = localio.Download
|
||||
docVerifyWait = waitForDocVerification
|
||||
docVerifyDelays = []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||||
docMarkdown = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Table),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
docMarkdownConvert = func(source []byte, writer io.Writer) error {
|
||||
return docMarkdown.Convert(source, writer)
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
docBlockReadPageSize = 50
|
||||
docBlockReadMaxItems = 5000
|
||||
docMarkdownVerifyMax = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
var Create = shortcut.Shortcut{
|
||||
@@ -133,7 +155,9 @@ var Create = shortcut.Shortcut{
|
||||
verifyTool = "get_document_content"
|
||||
verifyParams["format"] = format
|
||||
}
|
||||
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
|
||||
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
|
||||
return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
@@ -422,7 +446,9 @@ var CheckpointUpdate = shortcut.Shortcut{
|
||||
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "update", "status": "success"})
|
||||
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
|
||||
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool {
|
||||
return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "markdown")
|
||||
})
|
||||
if err != nil {
|
||||
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
|
||||
append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
@@ -539,7 +565,7 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
referenceBlockID := rt.Str("after-block-id")
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node,
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat},
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
|
||||
func(result, data map[string]any) bool {
|
||||
return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format"))
|
||||
})
|
||||
@@ -553,14 +579,14 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
|
||||
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
|
||||
}
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node,
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat},
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
|
||||
func(_, data map[string]any) bool {
|
||||
return blockContentEquals(data, blockID, content, rt.Str("doc-format"))
|
||||
})
|
||||
case "block_delete":
|
||||
blockID := rt.Str("block-id")
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node,
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": "element"},
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": "element", "__allBlocks": true},
|
||||
func(_, data map[string]any) bool { return findBlock(data, blockID) == nil })
|
||||
case "str_replace":
|
||||
return executePlainTextReplace(rt, node)
|
||||
@@ -609,7 +635,7 @@ func nestedRevision(value any) (int, bool) {
|
||||
}
|
||||
|
||||
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
|
||||
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -643,12 +669,12 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
blockID := matches[0].blockID
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block",
|
||||
map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID,
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"},
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
|
||||
func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") })
|
||||
}
|
||||
|
||||
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
|
||||
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -664,7 +690,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
referenceBlockID := rt.Str("after-block-id")
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block",
|
||||
map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID,
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"},
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
|
||||
func(result, data map[string]any) bool {
|
||||
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown")
|
||||
})
|
||||
@@ -684,7 +710,9 @@ func executeVerifiedDocMutation(
|
||||
return docUnknownWriteError(operation, tool, nodeID, err)
|
||||
}
|
||||
steps[0]["status"] = "success"
|
||||
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
|
||||
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
|
||||
return verify == nil || verify(result, data)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
@@ -732,7 +760,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": stepName, "status": "success"})
|
||||
}
|
||||
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format})
|
||||
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool {
|
||||
return verifyUpdatedDocumentContent(data, content, mode, format)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
@@ -745,6 +775,180 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
|
||||
}, steps...))
|
||||
}
|
||||
|
||||
func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) {
|
||||
params := cloneMap(rawParams)
|
||||
allBlocks, _ := params["__allBlocks"].(bool)
|
||||
delete(params, "__allBlocks")
|
||||
var last map[string]any
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(docVerifyDelays); attempt++ {
|
||||
var data map[string]any
|
||||
var err error
|
||||
if allBlocks && tool == "list_document_blocks" {
|
||||
data, err = readAllDocumentBlocks(rt, params)
|
||||
} else {
|
||||
data, err = rt.CallMCPData(productDoc, tool, params)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
last = data
|
||||
lastErr = nil
|
||||
if verify == nil || verify(data) {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
if attempt < len(docVerifyDelays) {
|
||||
if err := docVerifyWait(rt.Command().Context(), docVerifyDelays[attempt]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) {
|
||||
all := make([]any, 0, docBlockReadPageSize)
|
||||
seenPageIdentities := map[string]bool{}
|
||||
for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize {
|
||||
params := cloneMap(base)
|
||||
params["startIndex"] = start
|
||||
params["endIndex"] = start + docBlockReadPageSize - 1
|
||||
page, err := rt.CallMCPData(productDoc, "list_document_blocks", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, ok := documentBlockEntries(page)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
|
||||
}
|
||||
pageIdentity := documentBlockPageIdentity(blocks)
|
||||
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
|
||||
return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整")
|
||||
}
|
||||
if pageIdentity != "" {
|
||||
seenPageIdentities[pageIdentity] = true
|
||||
}
|
||||
all = append(all, blocks...)
|
||||
hasMore, known, _ := docPageState(page)
|
||||
if known && !hasMore {
|
||||
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
|
||||
}
|
||||
if !known {
|
||||
if total, ok := nestedNonNegativeInt(page, "totalCount", "total_count"); ok && len(all) >= total {
|
||||
return map[string]any{"blocks": all, "hasMore": false, "totalCount": total}, nil
|
||||
}
|
||||
}
|
||||
if !known && len(blocks) < docBlockReadPageSize {
|
||||
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空,无法证明回读完整")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems)
|
||||
}
|
||||
|
||||
func documentBlockPageIdentity(blocks []any) string {
|
||||
if len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
ids := make([]string, 0, len(blocks))
|
||||
for _, value := range blocks {
|
||||
id := ""
|
||||
switch block := value.(type) {
|
||||
case map[string]any:
|
||||
id = blockIdentity(block, "")
|
||||
if id == "" {
|
||||
if element, ok := block["element"].(map[string]any); ok {
|
||||
id = blockIdentity(element, "")
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
id = jsonMLBlockIdentity(block)
|
||||
}
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
encoded, _ := json.Marshal(ids)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func documentBlockEntries(value any) ([]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"blocks", "items"} {
|
||||
if blocks, ok := typed[key].([]any); ok {
|
||||
return blocks, true
|
||||
}
|
||||
}
|
||||
if encoded, ok := typed["jsonml"].(string); ok {
|
||||
var decoded any
|
||||
if json.Unmarshal([]byte(encoded), &decoded) == nil {
|
||||
blocks := orderedJSONMLBlocks(decoded)
|
||||
values := make([]any, len(blocks))
|
||||
for index := range blocks {
|
||||
values[index] = blocks[index]
|
||||
}
|
||||
return values, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if nested, ok := typed[key]; ok {
|
||||
if blocks, found := documentBlockEntries(nested); found {
|
||||
return blocks, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func nestedNonNegativeInt(value any, keys ...string) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range keys {
|
||||
if raw, ok := typed[key]; ok {
|
||||
switch number := raw.(type) {
|
||||
case float64:
|
||||
if number >= 0 && number == float64(int(number)) {
|
||||
return int(number), true
|
||||
}
|
||||
case int:
|
||||
if number >= 0 {
|
||||
return number, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if result, ok := nestedNonNegativeInt(typed[key], keys...); ok {
|
||||
return result, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func splitDocMarkdown(content string, maxRunes int) []string {
|
||||
if maxRunes <= 0 {
|
||||
return []string{content}
|
||||
@@ -796,11 +1000,17 @@ func containsText(value any, needle string) bool {
|
||||
}
|
||||
|
||||
func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool {
|
||||
expected = normalizeDocumentContentForVerification(expected, format)
|
||||
expectedRaw := expected
|
||||
expected = normalizeDocumentContentForVerification(expectedRaw, format)
|
||||
for _, candidate := range documentContentCandidates(value, format) {
|
||||
actual := normalizeDocumentContentForVerification(candidate, format)
|
||||
actualRaw := candidate
|
||||
actual := normalizeDocumentContentForVerification(actualRaw, format)
|
||||
if mode == "overwrite" {
|
||||
if actual == expected {
|
||||
if actual == expected || (format == "markdown" && stripReadbackDocumentTitle(actual) == expected) {
|
||||
return true
|
||||
}
|
||||
if format == "markdown" && (markdownSemanticallyEquivalent(actualRaw, expectedRaw) ||
|
||||
markdownSemanticallyEquivalent(stripReadbackDocumentTitle(actualRaw), expectedRaw)) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
@@ -808,10 +1018,48 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool
|
||||
if actual == expected || strings.HasSuffix(actual, "\n"+expected) {
|
||||
return true
|
||||
}
|
||||
if format == "markdown" && markdownSemanticallyEndsWith(actualRaw, expectedRaw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownSemanticallyEquivalent(left, right string) bool {
|
||||
leftFingerprint, leftOK := markdownSemanticFingerprint(left)
|
||||
rightFingerprint, rightOK := markdownSemanticFingerprint(right)
|
||||
return leftOK && rightOK && leftFingerprint == rightFingerprint
|
||||
}
|
||||
|
||||
func markdownSemanticallyEndsWith(content, suffix string) bool {
|
||||
contentFingerprint, contentOK := markdownSemanticFingerprint(content)
|
||||
suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix)
|
||||
return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint)
|
||||
}
|
||||
|
||||
func markdownSemanticFingerprint(source string) (string, bool) {
|
||||
if len(source) > docMarkdownVerifyMax {
|
||||
return "", false
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := docMarkdownConvert([]byte(source), &rendered); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return rendered.String(), true
|
||||
}
|
||||
|
||||
func stripReadbackDocumentTitle(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") {
|
||||
return content
|
||||
}
|
||||
lines = lines[1:]
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool {
|
||||
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format)
|
||||
}
|
||||
@@ -848,6 +1096,11 @@ func blockContentEquals(data map[string]any, blockID, expected, format string) b
|
||||
}
|
||||
|
||||
func canonicalBlockContent(value any, format string) string {
|
||||
if values, ok := value.(map[string]any); ok {
|
||||
if element, ok := values["element"].(map[string]any); ok {
|
||||
value = element
|
||||
}
|
||||
}
|
||||
if format == "jsonml" {
|
||||
if values, ok := value.(map[string]any); ok {
|
||||
if encoded, ok := values["jsonml"].(string); ok {
|
||||
@@ -871,7 +1124,7 @@ func canonicalBlockContent(value any, format string) string {
|
||||
return
|
||||
}
|
||||
for key, child := range typed {
|
||||
if key == "id" || key == "blockId" || key == "uuid" {
|
||||
if key == "id" || key == "blockId" || key == "uuid" || key == "blockType" {
|
||||
continue
|
||||
}
|
||||
walk(child)
|
||||
@@ -1005,6 +1258,10 @@ func orderedDocumentBlocks(value any) []map[string]any {
|
||||
walk = func(current any) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
if element, ok := typed["element"].(map[string]any); ok && blockIdentity(element, "") != "" {
|
||||
blocks = append(blocks, element)
|
||||
return
|
||||
}
|
||||
if blockIdentity(typed, "") != "" {
|
||||
blocks = append(blocks, typed)
|
||||
return
|
||||
@@ -1065,9 +1322,23 @@ func normalizeDocumentContentForVerification(raw, format string) string {
|
||||
func normalizeMarkdownForVerification(raw string) string {
|
||||
raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")
|
||||
lines := make([]string, 0, strings.Count(raw, "\n")+1)
|
||||
inFence := false
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
inFence = !inFence
|
||||
lines = append(lines, trimmed)
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
lines = append(lines, strings.TrimRight(line, " \t"))
|
||||
continue
|
||||
}
|
||||
line = trimmed
|
||||
if line == "" {
|
||||
if len(lines) > 0 && lines[len(lines)-1] != "" {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "|") {
|
||||
@@ -1081,6 +1352,9 @@ func normalizeMarkdownForVerification(raw string) string {
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -1089,33 +1363,108 @@ func normalizeJSONMLForVerification(raw string) string {
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return normalizeMarkdownForVerification(raw)
|
||||
}
|
||||
var tokens []string
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
var normalize func(any) any
|
||||
normalize = func(current any) any {
|
||||
switch typed := current.(type) {
|
||||
case []any:
|
||||
start := 0
|
||||
if len(typed) > 0 {
|
||||
if tag, ok := typed[0].(string); ok {
|
||||
tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">")
|
||||
start = 1
|
||||
if len(typed) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
tag, isElement := typed[0].(string)
|
||||
if !isElement {
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, child := range typed {
|
||||
out = append(out, normalize(child))
|
||||
}
|
||||
return out
|
||||
}
|
||||
start := 1
|
||||
attrs := map[string]any{}
|
||||
if len(typed) > 1 {
|
||||
if declared, ok := typed[1].(map[string]any); ok {
|
||||
attrs, _ = normalize(declared).(map[string]any)
|
||||
attrs = removeGeneratedJSONMLDefaults(tag, attrs)
|
||||
start = 2
|
||||
}
|
||||
}
|
||||
children := make([]any, 0, len(typed)-start)
|
||||
for _, child := range typed[start:] {
|
||||
walk(child)
|
||||
normalized := normalize(child)
|
||||
if normalized != nil {
|
||||
children = append(children, normalized)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(tag, "span") && isGeneratedTextSpan(attrs) {
|
||||
if len(children) == 1 {
|
||||
return children[0]
|
||||
}
|
||||
return children
|
||||
}
|
||||
out := []any{strings.ToLower(tag), attrs}
|
||||
out = append(out, children...)
|
||||
return out
|
||||
case map[string]any:
|
||||
// JSONML maps contain element attributes. Server-generated UUIDs and
|
||||
// default attributes do not change the authored document content.
|
||||
return
|
||||
case string:
|
||||
if text := normalizeMarkdownForVerification(typed); text != "" {
|
||||
tokens = append(tokens, text)
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, child := range typed {
|
||||
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
|
||||
if normalizedKey == "uuid" || normalizedKey == "blockid" || normalizedKey == "elementid" || normalizedKey == "index" {
|
||||
continue
|
||||
}
|
||||
out[normalizedKey] = normalize(child)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.ReplaceAll(strings.ReplaceAll(typed, "\r\n", "\n"), "\r", "\n")
|
||||
}
|
||||
return current
|
||||
}
|
||||
walk(value)
|
||||
return strings.Join(tokens, "\n")
|
||||
// normalize only receives values decoded by encoding/json, so the resulting
|
||||
// tree is always JSON-marshalable.
|
||||
encoded, _ := json.Marshal(normalize(value))
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
var generatedJSONMLAttributeDefaults = map[string]map[string]any{
|
||||
"hr": {
|
||||
"sz": float64(1),
|
||||
},
|
||||
"tc": {
|
||||
"colspan": float64(1), "rowspan": float64(1), "valign": "middle",
|
||||
},
|
||||
"code": {
|
||||
"code": "", "syntax": "plaintext", "theme": "default",
|
||||
"wrap": true, "showlinenumber": true, "fold": false,
|
||||
},
|
||||
}
|
||||
|
||||
// removeGeneratedJSONMLDefaults drops only defaults declared by the reviewed
|
||||
// JSONML schema, plus empty server style objects. Other attributes remain part
|
||||
// of the semantic fingerprint so links, formatting, and table layout stay
|
||||
// strict.
|
||||
func removeGeneratedJSONMLDefaults(tag string, attrs map[string]any) map[string]any {
|
||||
defaults := generatedJSONMLAttributeDefaults[strings.ToLower(tag)]
|
||||
out := make(map[string]any, len(attrs))
|
||||
for key, value := range attrs {
|
||||
if object, ok := value.(map[string]any); ok && len(object) == 0 {
|
||||
continue
|
||||
}
|
||||
if defaultValue, ok := defaults[key]; ok && reflect.DeepEqual(value, defaultValue) {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isGeneratedTextSpan(attrs map[string]any) bool {
|
||||
if len(attrs) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(attrs) != 1 {
|
||||
return false
|
||||
}
|
||||
value, ok := attrs["datatype"].(string)
|
||||
return ok && (value == "text" || value == "leaf")
|
||||
}
|
||||
|
||||
func executeExport(rt *shortcut.RuntimeContext) error {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "old"}, {"markdown": "old\nnew"}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "append", "--content", "new", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := 0
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "get_document_content" {
|
||||
reads++
|
||||
}
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("readback calls = %d, want 2; history=%#v", reads, caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocReadbackStopsOnCancellation(t *testing.T) {
|
||||
if err := waitForDocVerification(nil, time.Nanosecond); err != nil {
|
||||
t.Fatalf("completed verification wait = %v", err)
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled verification wait = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "verify"}
|
||||
cmd.SetContext(cancelled)
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "stale"}},
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, Update)
|
||||
_, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": "n"}, func(map[string]any) bool { return false })
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled document verification = %v, want context.Canceled", err)
|
||||
}
|
||||
if len(caller.history) != 1 {
|
||||
t.Fatalf("cancelled document verification calls = %d, want 1", len(caller.history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocDeleteReadbackConsumesEveryPage(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
|
||||
firstPage := make([]any, 50)
|
||||
for index := range firstPage {
|
||||
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": "body"}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_document_blocks": {
|
||||
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
|
||||
{"blocks": []any{map[string]any{"id": "target", "text": "stale"}}, "hasMore": false, "totalCount": 51},
|
||||
{"blocks": firstPage, "hasMore": false, "totalCount": 50},
|
||||
},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starts := []int{}
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "list_document_blocks" {
|
||||
starts = append(starts, call.params["startIndex"].(int))
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(starts) != "[0 50 0]" {
|
||||
t.Fatalf("pagination starts = %v, want [0 50 0]", starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocReplacePreflightIsGloballyUnique(t *testing.T) {
|
||||
firstPage := make([]any, 50)
|
||||
for index := range firstPage {
|
||||
text := "body"
|
||||
if index == 0 {
|
||||
text = "unique needle"
|
||||
}
|
||||
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": text}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_document_blocks": {
|
||||
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
|
||||
{"blocks": []any{map[string]any{"id": "block-50", "text": "another needle"}}, "hasMore": false, "totalCount": 51},
|
||||
},
|
||||
}}
|
||||
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "str_replace", "--old", "needle", "--new", "changed", "--yes")
|
||||
if err == nil {
|
||||
t.Fatal("str_replace accepted a second match on a later page")
|
||||
}
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "update_document_block" {
|
||||
t.Fatalf("ambiguous replace executed a write: %#v", caller.history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocVerificationPreservesMeaning(t *testing.T) {
|
||||
expected := "[\"p\",{},\"text\"]"
|
||||
serverExpanded := "[\"p\",{\"uuid\":\"generated\",\"style\":{}},[\"span\",{\"data-type\":\"text\"},[\"span\",{\"data-type\":\"leaf\"},\"text\"]]]"
|
||||
if normalizeJSONMLForVerification(expected) != normalizeJSONMLForVerification(serverExpanded) {
|
||||
t.Fatal("generated JSONML text wrappers should not change document meaning")
|
||||
}
|
||||
defaultFree := `[["hr",{}],["code",{}],["table",{},["tr",{},["tc",{},["p",{},"cell"]]]]]`
|
||||
serverDefaulted := `[["hr",{"sz":1}],["code",{"code":"","syntax":"plaintext","theme":"default","wrap":true,"showLineNumber":true,"fold":false}],["table",{},["tr",{},["tc",{"colSpan":1,"rowSpan":1,"vAlign":"middle"},["p",{},"cell"]]]]]`
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"jsonml": serverDefaulted}, defaultFree, "overwrite", "jsonml") {
|
||||
t.Fatal("server-generated JSONML schema defaults should not fail readback verification")
|
||||
}
|
||||
linkA := "[\"a\",{\"href\":\"https://example.com/a\"},\"text\"]"
|
||||
linkB := "[\"a\",{\"href\":\"https://example.com/b\"},\"text\"]"
|
||||
if normalizeJSONMLForVerification(linkA) == normalizeJSONMLForVerification(linkB) {
|
||||
t.Fatal("semantic JSONML attributes were ignored")
|
||||
}
|
||||
tableA := `[["table",{"jc":"center"},["tr",{},["tc",{},["p",{},"cell"]]]]]`
|
||||
tableB := `[["table",{"jc":"right"},["tr",{},["tc",{},["p",{},"cell"]]]]]`
|
||||
if normalizeJSONMLForVerification(tableA) == normalizeJSONMLForVerification(tableB) {
|
||||
t.Fatal("semantic JSONML table alignment was ignored")
|
||||
}
|
||||
codeA := "~~~go\n return nil\n~~~"
|
||||
codeB := "~~~go\nreturn nil\n~~~"
|
||||
if normalizeMarkdownForVerification(codeA) == normalizeMarkdownForVerification(codeB) {
|
||||
t.Fatal("fenced code indentation was ignored")
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "# Server title\n\nbody"}, "body", "overwrite", "markdown") {
|
||||
t.Fatal("server-generated document title prevented body verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"sales_data.xlsx",
|
||||
"### 1.",
|
||||
"+10.22%",
|
||||
"| name | value |",
|
||||
"| -------- | -------- |",
|
||||
"| sales_data.xlsx | +10.22% |",
|
||||
}, "\n")
|
||||
server := strings.Join([]string{
|
||||
`sales\_data.xlsx`,
|
||||
`### 1\.`,
|
||||
`\+10.22%`,
|
||||
"|name|value|",
|
||||
"|---|---|",
|
||||
`|sales\_data.xlsx|\+10.22%|`,
|
||||
}, "\n")
|
||||
if !markdownSemanticallyEquivalent(input, server) {
|
||||
t.Fatal("server Markdown escaping and table delimiter normalization changed the semantic fingerprint")
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") {
|
||||
t.Fatal("equivalent server Markdown failed overwrite verification")
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "existing\n\n" + server}, input, "append", "markdown") {
|
||||
t.Fatal("equivalent server Markdown failed append verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{name: "emphasis", left: `*important*`, right: `\*important\*`},
|
||||
{name: "inline code", left: "`sales_data`", right: "`sales\\_data`"},
|
||||
{name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"},
|
||||
{name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"},
|
||||
{name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"},
|
||||
{name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if markdownSemanticallyEquivalent(test.left, test.right) {
|
||||
t.Fatal("meaningful Markdown difference was ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
oversized := strings.Repeat("x", docMarkdownVerifyMax+1)
|
||||
if _, ok := markdownSemanticFingerprint(oversized); ok {
|
||||
t.Fatal("oversized Markdown entered semantic verification")
|
||||
}
|
||||
testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") })
|
||||
if _, ok := markdownSemanticFingerprint("body"); ok {
|
||||
t.Fatal("failed Markdown render produced a semantic fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) {
|
||||
wrapper := map[string]any{
|
||||
"blockType": "paragraph",
|
||||
"element": map[string]any{"id": "inserted", "blockType": "paragraph", "paragraph": map[string]any{"text": "body"}},
|
||||
}
|
||||
if got := canonicalBlockContent(wrapper, "markdown"); got != "body" {
|
||||
t.Fatalf("nested element content = %q, want body", got)
|
||||
}
|
||||
blocks := orderedDocumentBlocks(map[string]any{"blocks": []any{wrapper}})
|
||||
if len(blocks) != 1 || blockIdentity(blocks[0], "") != "inserted" {
|
||||
t.Fatalf("nested element blocks = %#v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageVersionRevertRequiresTargetEvidence(t *testing.T) {
|
||||
if revertResultMatchesVersion(map[string]any{"ok": true}, 3) || currentDocumentMatchesRestoredVersion(map[string]any{"version": 99}, 3) {
|
||||
t.Fatal("readability or an unrelated current version must not prove a revert")
|
||||
}
|
||||
if revertResultMatchesVersion(map[string]any{"version": 3}, 3) {
|
||||
t.Fatal("the request version parameter must not prove its own revert")
|
||||
}
|
||||
if revertResultMatchesVersion(map[string]any{"data": map[string]any{"request": map[string]any{"targetVersion": 3}}}, 3) {
|
||||
t.Fatal("request echo containers must not provide version evidence")
|
||||
}
|
||||
for _, failed := range []map[string]any{
|
||||
{"data": map[string]any{"success": "false", "revertedToVersion": 3}},
|
||||
{"data": map[string]any{"ok": false, "revertedToVersion": 3}},
|
||||
{"data": map[string]any{"status": "FAILED", "revertedToVersion": 3}},
|
||||
{"data": map[string]any{"state": "failure", "revertedToVersion": 3}},
|
||||
{"data": []any{map[string]any{"error_code": "REVERT_FAILED", "revertedToVersion": 3}}},
|
||||
{"data": map[string]any{"errorCode": 500.0, "revertedToVersion": 3}},
|
||||
{"data": map[string]any{"code": json.Number("500"), "revertedToVersion": 3}},
|
||||
} {
|
||||
if revertResultMatchesVersion(failed, 3) {
|
||||
t.Fatalf("explicit failure %#v must override target-version evidence", failed)
|
||||
}
|
||||
}
|
||||
for _, succeeded := range []map[string]any{
|
||||
{"revertedToVersion": 3},
|
||||
{"status": "SUCCESS", "revertedToVersion": 3},
|
||||
{"state": "succeeded", "revertedToVersion": 3},
|
||||
{"errorCode": "0", "revertedToVersion": 3},
|
||||
{"code": 200, "revertedToVersion": 3},
|
||||
{"code": "204", "revertedToVersion": 3},
|
||||
} {
|
||||
if !revertResultMatchesVersion(succeeded, 3) {
|
||||
t.Fatalf("explicit success %#v suppressed target-version evidence", succeeded)
|
||||
}
|
||||
}
|
||||
for _, absentOrSuccess := range []any{nil, "", "OK", "SUCCESS", json.Number("0"), json.Number("200"), 0.0, 201.0, 0, 202} {
|
||||
if revertErrorCodeIsFailure(absentOrSuccess) {
|
||||
t.Fatalf("success code %#v was treated as an explicit failure", absentOrSuccess)
|
||||
}
|
||||
}
|
||||
for _, failedCode := range []any{json.Number("bad"), json.Number("500"), 1.0, 500.0, 1, 500} {
|
||||
if !revertErrorCodeIsFailure(failedCode) {
|
||||
t.Fatalf("failure code %#v was not treated as an explicit failure", failedCode)
|
||||
}
|
||||
}
|
||||
if revertStatusIsFailure(500) || revertStatusIsFailure("PROCESSING") {
|
||||
t.Fatal("non-failure status was treated as an explicit failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses []map[string]any
|
||||
failAt int
|
||||
}{
|
||||
{"call failure", nil, 1},
|
||||
{"missing blocks", []map[string]any{{"ok": true}}, 0},
|
||||
{"stalled page", []map[string]any{{"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}, {"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}}, 0},
|
||||
{"empty continued page", []map[string]any{{"blocks": []any{}, "hasMore": true}}, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{failAt: tc.failAt, responses: map[string][]map[string]any{"list_document_blocks": tc.responses}}
|
||||
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes")
|
||||
if err == nil {
|
||||
t.Fatal("defensive readback unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
blocks := make([]any, docBlockReadPageSize)
|
||||
for index := range blocks {
|
||||
blocks[index] = map[string]any{"blockType": "paragraph"}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {
|
||||
{"blocks": blocks, "hasMore": true, "totalCount": 2 * docBlockReadPageSize},
|
||||
{"blocks": blocks, "hasMore": false, "totalCount": 2 * docBlockReadPageSize},
|
||||
}}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starts := []int{}
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "list_document_blocks" {
|
||||
starts = append(starts, call.params["startIndex"].(int))
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(starts) != "[0 50]" {
|
||||
t.Fatalf("pagination starts = %v, want [0 50]", starts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("total count terminates pagination", func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"blocks": []any{map[string]any{"id": "other"}}, "totalCount": 1}}}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {
|
||||
{"blocks": []any{map[string]any{"id": "first"}}, "hasMore": true, "totalCount": 1},
|
||||
{"blocks": []any{map[string]any{"id": "second"}}, "hasMore": false, "totalCount": 2},
|
||||
}}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := 0
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "list_document_blocks" {
|
||||
reads++
|
||||
}
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("read calls = %d, want both explicitly advertised pages", reads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block read safety limit", func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
pages := make([]map[string]any, docBlockReadMaxItems/docBlockReadPageSize)
|
||||
for index := range pages {
|
||||
pages[index] = map[string]any{"blocks": []any{map[string]any{"id": fmt.Sprintf("block-%d", index)}}, "hasMore": true}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": pages}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err == nil {
|
||||
t.Fatal("oversized block read returned nil")
|
||||
}
|
||||
})
|
||||
|
||||
if blocks, ok := documentBlockEntries(map[string]any{"jsonml": `["root",{},["p",{"uuid":"a"},"x"]]`}); !ok || len(blocks) == 0 {
|
||||
t.Fatalf("jsonml blocks=%#v ok=%v", blocks, ok)
|
||||
}
|
||||
if _, ok := documentBlockEntries(map[string]any{"jsonml": `{`}); ok {
|
||||
t.Fatal("invalid jsonml produced blocks")
|
||||
}
|
||||
if blocks, ok := documentBlockEntries(map[string]any{"data": map[string]any{"items": []any{"x"}}}); !ok || len(blocks) != 1 {
|
||||
t.Fatalf("nested items=%#v ok=%v", blocks, ok)
|
||||
}
|
||||
if _, ok := documentBlockEntries(nil); ok {
|
||||
t.Fatal("nil produced blocks")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{map[string]any{"totalCount": float64(2)}, true},
|
||||
{map[string]any{"totalCount": float64(-1)}, false},
|
||||
{map[string]any{"totalCount": 2.5}, false},
|
||||
{map[string]any{"data": map[string]any{"total_count": 2}}, true},
|
||||
{map[string]any{"totalCount": -1}, false},
|
||||
{nil, false},
|
||||
} {
|
||||
_, ok := nestedNonNegativeInt(tc.value, "totalCount", "total_count")
|
||||
if ok != tc.want {
|
||||
t.Fatalf("nestedNonNegativeInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{
|
||||
`[]`, `[1,["p",{},"x"]]`, `["span",{},"a","b"]`,
|
||||
`["p",{"block_id":"x","custom":true},"x"]`, `true`,
|
||||
} {
|
||||
if normalizeJSONMLForVerification(raw) == "" {
|
||||
t.Fatalf("empty normalized JSONML for %s", raw)
|
||||
}
|
||||
}
|
||||
if !isGeneratedTextSpan(nil) || isGeneratedTextSpan(map[string]any{"a": 1, "b": 2}) || isGeneratedTextSpan(map[string]any{"data-type": 3}) || isGeneratedTextSpan(map[string]any{"data-type": "other"}) {
|
||||
t.Fatal("generated span classification failed")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -57,7 +58,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m
|
||||
}
|
||||
value := docCoveragePayload(tool)
|
||||
if tool == "revert_doc_version" {
|
||||
value = map[string]any{"version": params["version"]}
|
||||
value = map[string]any{"revertedToVersion": params["version"]}
|
||||
}
|
||||
if queue := f.responses[tool]; len(queue) > 0 {
|
||||
value = queue[0]
|
||||
@@ -112,6 +113,7 @@ func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *do
|
||||
|
||||
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
|
||||
helpers.InitDeps(caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
@@ -491,6 +493,7 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
|
||||
var typed *apperrors.Error
|
||||
@@ -512,6 +515,7 @@ func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "truncated"}},
|
||||
}}
|
||||
@@ -577,11 +581,59 @@ func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testi
|
||||
"revert_doc_version": {{}},
|
||||
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
|
||||
}}
|
||||
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.FailureStage != "verify" || typed.Details["status"] != "partial_success" {
|
||||
t.Fatalf("unproven revert error = %#v", err)
|
||||
}
|
||||
data, _ := typed.Details["data"].(map[string]any)
|
||||
steps, _ := typed.Details["steps"].([]map[string]any)
|
||||
if data["verified"] != false || len(steps) != 3 || steps[2]["status"] != "failed" {
|
||||
t.Fatalf("unproven revert details = %#v", typed.Details)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
current map[string]any
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "explicit server evidence", response: map[string]any{"data": map[string]any{"revertResult": map[string]any{"revertedToVersion": 3}}}, wantOK: true},
|
||||
{name: "bare request parameter echo", response: map[string]any{"version": 3}},
|
||||
{name: "nested request parameter echo", response: map[string]any{"data": map[string]any{"request": map[string]any{"nodeId": "n", "version": 3}}}},
|
||||
{name: "accepted field inside request echo", response: map[string]any{"data": map[string]any{"request": map[string]any{"targetVersion": 3}}}},
|
||||
{name: "nested business failure with request echo", response: map[string]any{"data": map[string]any{"success": false, "errorCode": "REVERT_FAILED", "request": map[string]any{"version": 3}}}},
|
||||
{name: "failed status overrides target evidence", response: map[string]any{"data": map[string]any{"status": "FAILED", "revertResult": map[string]any{"revertedToVersion": 3}}}},
|
||||
{name: "failed code overrides target evidence", response: map[string]any{"data": map[string]any{"code": "REVERT_FAILED", "revertResult": map[string]any{"revertedToVersion": 3}}}},
|
||||
{
|
||||
name: "nested business failure overrides all evidence",
|
||||
response: map[string]any{"data": map[string]any{"state": "FAILURE", "revertResult": map[string]any{"revertedToVersion": 3}}},
|
||||
current: map[string]any{"targetVersion": 3},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
responses := map[string][]map[string]any{
|
||||
"revert_doc_version": {test.response},
|
||||
}
|
||||
if test.current != nil {
|
||||
responses["get_document_info"] = []map[string]any{test.current}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: responses}
|
||||
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
|
||||
if test.wantOK {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.Details["status"] != "partial_success" {
|
||||
t.Fatalf("request echo response %#v produced %#v", test.response, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses []map[string]any
|
||||
|
||||
@@ -178,15 +178,51 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
|
||||
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
|
||||
)
|
||||
}
|
||||
verified := !revertResponseHasExplicitFailure(reverted) &&
|
||||
(revertResultMatchesVersion(reverted, target) || currentDocumentMatchesRestoredVersion(current, target))
|
||||
if !verified {
|
||||
return docPartialWriteError(
|
||||
"doc.history_revert", "doc_history_revert_target_unproven", "verify",
|
||||
fmt.Sprintf("版本 %d 的回滚请求已执行且文档可读,但响应没有提供目标版本证据;不要直接重试回滚", target),
|
||||
fmt.Errorf("回读缺少目标版本 %d 的明确证据", target),
|
||||
map[string]any{
|
||||
"nodeId": nodeID, "version": target, "reverted": true, "verified": false,
|
||||
"revertResult": reverted, "current": current,
|
||||
},
|
||||
[]map[string]any{
|
||||
{"name": "preflight", "status": "success"},
|
||||
{"name": "revert", "status": "success"},
|
||||
{"name": "verify", "status": "failed"},
|
||||
},
|
||||
map[string]any{"available": false, "reason": "the revert may have completed; inspect version history before any further revert"},
|
||||
)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
|
||||
"version": target, "revertResult": reverted, "current": current, "verified": true,
|
||||
"verification": "revert_acknowledged_and_document_readable",
|
||||
"verification": "target_version_proven",
|
||||
},
|
||||
map[string]any{"name": "preflight", "status": "success"},
|
||||
map[string]any{"name": "revert", "status": "success"},
|
||||
map[string]any{"name": "verify", "status": "success"}))
|
||||
}
|
||||
|
||||
func revertResultMatchesVersion(value map[string]any, target int) bool {
|
||||
if revertResponseHasExplicitFailure(value) {
|
||||
return false
|
||||
}
|
||||
return versionEvidenceMatches(value, target, map[string]bool{
|
||||
"targetversion": true, "appliedversion": true,
|
||||
"restoredversion": true, "revertedversion": true, "revertedtoversion": true, "sourceversion": true,
|
||||
})
|
||||
}
|
||||
|
||||
func currentDocumentMatchesRestoredVersion(value map[string]any, target int) bool {
|
||||
return versionEvidenceMatches(value, target, map[string]bool{
|
||||
"restoredfromversion": true, "revertedfromversion": true,
|
||||
"sourceversion": true, "appliedversion": true, "targetversion": true,
|
||||
})
|
||||
}
|
||||
|
||||
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
|
||||
const maxPages = 20
|
||||
cursor := ""
|
||||
@@ -240,6 +276,9 @@ func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool)
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
|
||||
if versionEvidenceRequestEchoKeys[normalized] {
|
||||
continue
|
||||
}
|
||||
if acceptedKeys[normalized] {
|
||||
if versionNumberMatches(child, target) {
|
||||
return true
|
||||
@@ -259,6 +298,87 @@ func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool)
|
||||
return false
|
||||
}
|
||||
|
||||
var versionEvidenceRequestEchoKeys = map[string]bool{
|
||||
"args": true, "arguments": true, "input": true, "inputs": true,
|
||||
"params": true, "parameters": true, "request": true, "requestbody": true,
|
||||
"requestparams": true, "toolargs": true, "toolarguments": true,
|
||||
}
|
||||
|
||||
func revertResponseHasExplicitFailure(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
|
||||
if versionEvidenceRequestEchoKeys[normalized] {
|
||||
continue
|
||||
}
|
||||
if normalized == "success" || normalized == "ok" {
|
||||
if success, ok := child.(bool); ok && !success {
|
||||
return true
|
||||
}
|
||||
if success, ok := child.(string); ok && strings.EqualFold(strings.TrimSpace(success), "false") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (normalized == "status" || normalized == "state") && revertStatusIsFailure(child) {
|
||||
return true
|
||||
}
|
||||
if normalized == "errorcode" || normalized == "code" {
|
||||
if revertErrorCodeIsFailure(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if revertResponseHasExplicitFailure(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if revertResponseHasExplicitFailure(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func revertStatusIsFailure(value any) bool {
|
||||
status, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "fail", "failed", "failure", "error", "errored", "reject", "rejected", "deny", "denied", "cancel", "cancelled", "canceled", "abort", "aborted":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func revertErrorCodeIsFailure(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
normalized := strings.ToLower(strings.TrimSpace(typed))
|
||||
switch normalized {
|
||||
case "", "ok", "success", "succeed", "succeeded":
|
||||
return false
|
||||
}
|
||||
if parsed, err := strconv.Atoi(normalized); err == nil {
|
||||
return parsed != 0 && (parsed < 200 || parsed >= 300)
|
||||
}
|
||||
return true
|
||||
case float64:
|
||||
return typed != 0 && (typed < 200 || typed >= 300)
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return err != nil || (parsed != 0 && (parsed < 200 || parsed >= 300))
|
||||
case int:
|
||||
return typed != 0 && (typed < 200 || typed >= 300)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func versionNumberMatches(value any, target int) bool {
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -618,15 +618,11 @@ func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeou
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传")
|
||||
if !retryable || time.Now().Add(interval).After(deadline) {
|
||||
if !retryable || minutesPollDeadlineReached(deadline, interval) {
|
||||
return nil, attempts, err
|
||||
}
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
timer.Stop()
|
||||
return nil, attempts, rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return nil, attempts, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -372,7 +373,7 @@ func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval
|
||||
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
|
||||
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
|
||||
}
|
||||
if time.Now().Add(interval).After(deadline) {
|
||||
if minutesPollDeadlineReached(deadline, interval) {
|
||||
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
|
||||
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
|
||||
}
|
||||
@@ -422,7 +423,7 @@ func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout,
|
||||
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
|
||||
return payload, callErr
|
||||
}
|
||||
if time.Now().Add(interval).After(deadline) {
|
||||
if minutesPollDeadlineReached(deadline, interval) {
|
||||
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
|
||||
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
|
||||
}
|
||||
@@ -614,6 +615,17 @@ func executeMinutesShare(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
|
||||
if !rt.DryRun() {
|
||||
for _, id := range minutesIDs(rt) {
|
||||
data, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("minutes unshare preflight for %s: %w", id, err)
|
||||
}
|
||||
if _, err := minutesdata.Basic(id, data); err != nil {
|
||||
return fmt.Errorf("minutes unshare preflight for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
|
||||
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
|
||||
})
|
||||
@@ -631,7 +643,11 @@ func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool
|
||||
for index, member := range members {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
|
||||
if err == nil {
|
||||
err = minutesdata.RequireWriteAcknowledgement(operation, data)
|
||||
if operation == "unshare" {
|
||||
err = minutesdata.RequirePermissionMutationAcknowledgement(operation, minutesIDs(rt), []string{member}, data)
|
||||
} else {
|
||||
err = minutesdata.RequireWriteAcknowledgement(operation, data)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
|
||||
@@ -708,7 +724,7 @@ func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []st
|
||||
for {
|
||||
attempts++
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
|
||||
if len(failures) == 0 || time.Now().Add(interval).After(deadline) {
|
||||
if len(failures) == 0 || minutesPollDeadlineReached(deadline, interval) {
|
||||
return bundle, failures, attempts
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
@@ -720,14 +736,22 @@ func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []st
|
||||
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
commandContext := rt.Command().Context()
|
||||
if commandContext == nil {
|
||||
commandContext = context.Background()
|
||||
}
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
return rt.Command().Context().Err()
|
||||
case <-commandContext.Done():
|
||||
return commandContext.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func minutesPollDeadlineReached(deadline time.Time, interval time.Duration) bool {
|
||||
return !time.Now().Add(interval).Before(deadline)
|
||||
}
|
||||
|
||||
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
|
||||
@@ -323,7 +323,10 @@ func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil {
|
||||
t.Fatal("unshare dry-run output failure accepted")
|
||||
}
|
||||
unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}}
|
||||
unshare := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/remove_member_permission": {`{"success":true,"result":{"resultMap":{"u1":["m1"]}}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("unshare payload=%#v err=%v", payload, err)
|
||||
@@ -337,11 +340,31 @@ func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
|
||||
if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 {
|
||||
t.Fatalf("share args=%#v", args)
|
||||
}
|
||||
continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}}
|
||||
continueFailure := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/remove_member_permission": {
|
||||
`{"result":{}}`,
|
||||
`{"success":true,"result":{"resultMap":{"u1":["m2"]}}}`,
|
||||
},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes")
|
||||
if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("continue ledger payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
missing := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, output, err = runMinutesAlignmentCLI(t, missing, "minutes", "+unshare", "--id", "missing", "--member-uids", "m1", "--yes")
|
||||
if err == nil || payload != nil || output != "" || missing.counts["minutes/remove_member_permission"] != 0 {
|
||||
t.Fatalf("missing minutes reached unshare: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missing.counts)
|
||||
}
|
||||
|
||||
preflightFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}}
|
||||
payload, output, err = runMinutesAlignmentCLI(t, preflightFailure, "minutes", "+unshare", "--id", "unavailable", "--member-uids", "m1", "--yes")
|
||||
if err == nil || payload != nil || output != "" || preflightFailure.counts["minutes/remove_member_permission"] != 0 {
|
||||
t.Fatalf("failed preflight reached unshare: payload=%#v output=%q err=%v calls=%#v", payload, output, err, preflightFailure.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) {
|
||||
@@ -419,10 +442,9 @@ func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) {
|
||||
t.Fatalf("cancel failures=%#v", failures)
|
||||
}
|
||||
cmd = &cobra.Command{Use: "wait"}
|
||||
cmd.SetContext(context.Background())
|
||||
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
|
||||
if err := waitMinutesInterval(rt, 0); err != nil {
|
||||
t.Fatalf("timer wait: %v", err)
|
||||
t.Fatalf("timer wait with unset command context: %v", err)
|
||||
}
|
||||
|
||||
cmd.SetOut(minutesFailWriter{})
|
||||
@@ -564,7 +586,7 @@ func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
|
||||
if _, _, _, err := prepareExportTarget("../escape"); err == nil {
|
||||
if _, _, _, err := prepareExportTarget(filepath.Join("..", "escape")); err == nil {
|
||||
t.Fatal("unsafe target accepted")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
@@ -590,7 +612,7 @@ func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
|
||||
}
|
||||
}},
|
||||
{name: "escape", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil })
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return filepath.Join("..", "escape"), nil })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("rel escape accepted")
|
||||
}
|
||||
@@ -602,7 +624,7 @@ func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
|
||||
}
|
||||
}},
|
||||
{name: "parent", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil })
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return filepath.Join("parent", "pack"), nil })
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("unsafe parent accepted")
|
||||
|
||||
@@ -224,6 +224,33 @@ func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) {
|
||||
if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397"}, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"resultMap": map[string]any{"u1": []any{float64(202397)}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("valid permission acknowledgement rejected: %v", err)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false, "errorMsg": "denied"},
|
||||
{"success": "yes"},
|
||||
{"success": true, "result": "bad"},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"202397"}}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": "bad"}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{nil}}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{"other"}}}},
|
||||
} {
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397"}, data); err == nil {
|
||||
t.Fatalf("invalid permission acknowledgement accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397", "other"}, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"resultMap": map[string]any{"u1": []any{"202397"}}},
|
||||
}); err == nil {
|
||||
t.Fatal("incomplete member coverage accepted")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
cmd, id string
|
||||
data map[string]any
|
||||
|
||||
@@ -30,6 +30,61 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
|
||||
return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation)
|
||||
}
|
||||
|
||||
// RequirePermissionMutationAcknowledgement validates the target-level payload
|
||||
// returned by add/remove_member_permission. The backend's top-level
|
||||
// success=true is not sufficient: historically it also accompanied a
|
||||
// resultMap that merely echoed nonexistent task UUIDs.
|
||||
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if success, ok := data["success"].(bool); !ok || !success {
|
||||
return fmt.Errorf("minutes %s response has no explicit success=true", operation)
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("minutes %s response has no result object", operation)
|
||||
}
|
||||
resultMap, ok := result["resultMap"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("minutes %s response has no result.resultMap object", operation)
|
||||
}
|
||||
if len(resultMap) != len(taskUUIDs) {
|
||||
return fmt.Errorf("minutes %s resultMap covers %d task UUIDs, want %d", operation, len(resultMap), len(taskUUIDs))
|
||||
}
|
||||
wantMembers := make(map[string]bool, len(memberUIDs))
|
||||
for _, member := range memberUIDs {
|
||||
wantMembers[strings.TrimSpace(member)] = true
|
||||
}
|
||||
for _, taskUUID := range taskUUIDs {
|
||||
rawMembers, exists := resultMap[taskUUID]
|
||||
if !exists {
|
||||
return fmt.Errorf("minutes %s resultMap is missing task UUID %s", operation, taskUUID)
|
||||
}
|
||||
members, ok := rawMembers.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("minutes %s resultMap[%s] has type %T, want array", operation, taskUUID, rawMembers)
|
||||
}
|
||||
gotMembers := make(map[string]bool, len(members))
|
||||
for index, member := range members {
|
||||
value := stringField(map[string]any{"member": member}, "member")
|
||||
if value == "" {
|
||||
return fmt.Errorf("minutes %s resultMap[%s][%d] is not a member UID", operation, taskUUID, index)
|
||||
}
|
||||
gotMembers[value] = true
|
||||
}
|
||||
if len(gotMembers) != len(wantMembers) {
|
||||
return fmt.Errorf("minutes %s resultMap[%s] member coverage mismatch", operation, taskUUID)
|
||||
}
|
||||
for member := range wantMembers {
|
||||
if !gotMembers[member] {
|
||||
return fmt.Errorf("minutes %s resultMap[%s] is missing member UID %s", operation, taskUUID, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordResult validates the gateway's observed listening-note command result.
|
||||
func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) {
|
||||
if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| delete-old-doc | 1. `doc search --query "<关键词>"` 或 `doc list --folder <FOLDER>` 取 `nodeId`<br>2. **必须先向用户展示要删除的文档标题/路径**,等用户确认<br>3. `doc delete --node <nodeId> --yes`(注意:是 `doc delete`,不是 `doc block delete`;前者删整篇,后者删块)<br>4. 验证:`doc info --node <nodeId>` 应返回 not-found |
|
||||
| export-doc-as-docx | 1. `doc search --query "<关键词>"` → 取 `nodeId`<br>2. `doc export --node <nodeId> --output /tmp/<name>.docx --timeout-sec 600`(CLI 内置渐进式退避轮询,**不要自己拼 GET downloadUrl**)<br>3. 落盘失败兜底:`doc export get --job-id <ID> --output /tmp/<name>.docx` 续等<br>4. (可选)`doc-to-message` 把 docx 路径作为附件发给用户 |
|
||||
| grant-doc-access | 1. `doc search --query "<关键词>"` → 取 `nodeId`<br>2. `contact user search --query "<姓名>"` → 取 `userId`(**注意**: doc permission 用 `userId`,不是 `openDingTalkId`)<br>3. **节点级**授权:`doc permission add --node <nodeId> --user <UID1,UID2> --role EDITOR`<br> role 取值: MANAGER / EDITOR / DOWNLOADER / READER(**不要传 OWNER**)<br> 单次最多 30 个 userId<br>4. 查权限:`doc permission list --node <nodeId>` 确认<br>5. **不要混淆**: 给"知识库"加成员用 `wiki member add --workspace`(容器级);给"单个文档"加权限用 `doc permission add --node`(节点级)|
|
||||
| insert-image-to-doc | 1. `doc search --query "<关键词>"` → 取 `nodeId`<br>2. `doc media insert --node <nodeId> --file ./image.png`(3 步一体化:获取凭证 → PUT OSS → 插入块;图片 ≤20MB 自动作为内联图片,其他作为附件块)<br>3. (可选)`doc read --node <nodeId>` 回读确认<br>**禁止**: 自己拿 uploadUrl 写 PUT —— 90%+ 会因 Content-Type 未清空触发 SignatureDoesNotMatch |
|
||||
| insert-image-to-doc | 1. `doc search --query "<关键词>"` → 取 `nodeId`<br>2. `doc media insert --node <nodeId> --file ./image.png`(4 步一体化:获取凭证 → PUT OSS → 插入块 → 自动回读验证;图片 ≤20MB 自动作为内联图片,其他作为附件块)<br>3. 检查结果中的 `verified=true`;若服务端无法提供明确回读证据,命令会返回 partial success<br>**禁止**: 自己拿 uploadUrl 写 PUT —— 90%+ 会因 Content-Type 未清空触发 SignatureDoesNotMatch |
|
||||
| download-doc-attachment | 1. `doc block list --node <nodeId>` → 找到 `blockType=attachment` 的块取 `resourceId`<br>2. `doc media download --node <nodeId> --resource-id <RID>` → 返回 OSS 临时 URL(**注意 expirationSeconds**)<br>3. 调用方自行 GET 该 URL 落盘 |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user