mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-02 05:47:31 +08:00
## Summary
Adds a side-effect-free DataFlow canvas **debug (dry-run) mode** plus a
**debug run log with a "View result" panel**, so a canvas can be
executed synchronously and inspected end-to-end (per-component progress
and parsed chunks) without persisting anything.
### Dry-run execution (inline parsed chunks)
- `task/debug.go`: `NewDebugTaskContext` builds an in-memory
`TaskContext` with **`KB.ID == ""` — the single debug signal used across
the ingestion pipeline**. A canvas debug run has no knowledgebase, and
production ingestion always supplies one, so `kb_id == ""` occurs ONLY
in debug mode. Components gate their own side effects on this signal
without any dedicated debug vocabulary (the former `CANVAS_DEBUG_DOC_ID`
marker constant is removed).
- `task/pipeline_executor.go`: `validateTaskContext` no longer requires
a KB when `KB.ID == ""` (debug); debug runs return `collectDebugOutput`
(chunks) instead of a no-op; uploaded bytes are delivered as
`inputs['binary']` for doc-less runs; `injectDebugPageCap` caps the
parser to the first pages for a fast preview via the production
`override_params` channel (Parser cpnID + family).
- `component/tokenizer.go`: `shouldHaveEmbedding` skips embedding when
`kb_id == ""` — the embedder is configured on the knowledgebase, so a
debug run has nothing to resolve against and stays side-effect free.
- `chunker/register.go`: chunk images are uploaded to MinIO only when a
KB is present (persist run). **In debug mode the raw image bytes are
intentionally dropped (`delete(ck, "image")`)** — the debug preview does
not render chunk images, and dropping the bytes keeps them out of memory
and out of the Redis-stored debug log. This is a deliberate trade-off,
not an oversight.
- `component/file.go`: pass through in-memory binary bytes, skipping
`doc_id` -> storage resolution.
- `handler/agent.go` + `agent_webhook.go`: detect `dataflow_canvas` and
run a sync debug returning chunks inline on the existing
chat/completions endpoint; reject DataFlow canvases from webhooks (fixes
the previously dead `== "DataFlow"` check; mirrors Python
`agent_api.py`).
- `parser_dispatch.go`: export `ParserFileFamily` for the executor's
page-cap injection.
### Debug run log + "View result"
Mirrors Python's debug-log contract so the front-end can replay each
component's progress and parsed output:
- `task/debug_log_sink.go`: a `DebugLogSink` records every component's
lifecycle into a `[{component_id, trace}]` array (each trace entry
carries `message`, `progress`, `timestamp`, `elapsed_time`). `Flush`
appends a terminal `END` marker whose first trace message is non-empty
so the front-end detects completion. On failure the END marker is
prefixed `[ERROR]` yet still carries the run, so the failure timeline
renders instead of being stuck empty. Timestamps and `elapsed_time` are
in seconds (matching the rest of the app).
- `task/debug_result_dsl.go`: `BuildDebugResultDSL` builds the `dsl` the
END marker carries — the Go analogue of Python's `Graph.__str__` +
END-marker `dsl` in `rag/flow/pipeline.py`. It combines the static DSL
structure (component_name / downstream / params / graph.nodes) with the
run output map (`output["state"][<id>]`) to emit, per component,
`obj.params.outputs[<format>].value` (chunks / text / json / html /
markdown) — the exact keys the front-end `dataflow-result` page reads to
render each step's parsed chunks. Raw embedding vectors (including the
dimension-scoped `q_<dim>_vec` keys) are stripped so the stored log
stays Python-scale.
- `task/pipeline_executor.go`: after the run, attach the built `dsl` to
the END marker via the `ResultSink` capability.
- `handler/agent.go`: `runCanvasPipelineDebug` generates a stable
`message_id` up-front and always flushes the log (success or failure);
`respondWithDebugResult` returns `message_id` in **both** the success
and the error envelope so the front-end can poll the log. The debug-log
endpoint `GET /agents/:id/logs/:message_id` serves the array.
- `web/src/pages/agent/hooks/use-run-dataflow.ts`: on a run failure,
also surface `message_id` via `setMessageId` so the log sheet renders
the failure timeline (the `[ERROR]` END marker is already written).
Guarded by `if (msgId)`, so it is a safe no-op when the back-end does
not return an id.
## Behavioral notes
- Debug parses only the first pages (`debugPageCapPages`) for a fast
preview; an explicit `pages` cap already present in the ParserConfig is
respected.
- Debug mode does not keep chunk images (see above) and does not compute
embeddings — it exercises parse + chunk only.
## Test plan
- Go: `debug_test.go`, `debug_log_sink_test.go` (trace pairing, END
marker, `[ERROR]` prefix, fractional-second timestamp/elapsed_time, size
caps, and a real-pipeline test asserting the END-marker `dsl` carries
non-empty per-component `params.outputs` with chunks),
`debug_result_dsl_test.go` (flat and real nested `output["state"]`
shapes, vector stripping, format priority),
`debug_pages_integration_test.go`, `pipeline_executor_persist_test.go`,
`handler/agent_pipeline_debug_test.go`, `handler/agent_logs_test.go`
(incl. `TestRunCanvasPipelineDebug_ErrorStillExposesMessageID` /
`TestRespondWithDebugResult_ErrorCarriesMessageID` locking `message_id`
on failure), plus updates to `agent_test.go` / `agent_webhook_test.go` /
`chunker/image_upload_test.go` / `tokenizer*.go`.
- `go build ./...` and `./build.sh --test` for affected packages.
🤖 Generated with [CodeBuddy Code](https://cnb.cool/codebuddy)
---------
Co-authored-by: CodeBuddy Code <noreply@tencent.com>
767 lines
28 KiB
Go
767 lines
28 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package task
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gorm.io/gorm"
|
|
"ragflow/internal/agent/runtime"
|
|
"ragflow/internal/ingestion/pipeline"
|
|
)
|
|
|
|
// capturedStore is a fake DebugLogStore that records every write so tests can
|
|
// assert the exact JSON the sink would persist to Redis — no live Redis needed.
|
|
type capturedStore struct {
|
|
mu sync.Mutex
|
|
data map[string]string
|
|
}
|
|
|
|
func (c *capturedStore) Set(key, value string, ttl time.Duration) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.data == nil {
|
|
c.data = map[string]string{}
|
|
}
|
|
c.data[key] = value
|
|
return true
|
|
}
|
|
|
|
func (c *capturedStore) get(key string) string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.data[key]
|
|
}
|
|
|
|
// clientConsidersComplete mirrors the front-end completion predicate in
|
|
// web/src/pages/agent/hooks/use-fetch-pipeline-log.ts: the run is done only
|
|
// when the LAST array element has component_id == "END" and its first trace
|
|
// message is non-empty.
|
|
func clientConsidersComplete(arr []map[string]any) bool {
|
|
if len(arr) == 0 {
|
|
return false
|
|
}
|
|
last := arr[len(arr)-1]
|
|
if last["component_id"] != "END" {
|
|
return false
|
|
}
|
|
trace, ok := last["trace"].([]any)
|
|
if !ok || len(trace) == 0 {
|
|
return false
|
|
}
|
|
first, ok := trace[0].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
msg, _ := first["message"].(string)
|
|
return strings.TrimSpace(msg) != ""
|
|
}
|
|
|
|
func loadArray(t *testing.T, raw string) []map[string]any {
|
|
t.Helper()
|
|
var arr []map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
|
t.Fatalf("stored value is not a JSON array: %v body=%s", err, raw)
|
|
}
|
|
return arr
|
|
}
|
|
|
|
// TestDebugLogSink_RecordsTraceAndEndMarker covers the core shape contract:
|
|
// same-component events merge into one element's trace, a distinct component
|
|
// opens a new element, and Flush appends an END marker so the front-end can
|
|
// detect completion.
|
|
func TestDebugLogSink_RecordsTraceAndEndMarker(t *testing.T) {
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c1", "m1", store)
|
|
|
|
sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{
|
|
Component: "File", Message: "File Started", Phase: phaseEnter,
|
|
})
|
|
sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{
|
|
Component: "File", Message: "File Done", Phase: phaseExit,
|
|
})
|
|
sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{
|
|
Component: "Chunker", Message: "Chunker Done", Phase: phaseExit,
|
|
})
|
|
|
|
sink.Flush(context.Background(), nil)
|
|
|
|
raw := store.get("c1-m1-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected key c1-m1-logs to be written")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
// File (merged, 2 trace entries) + Chunker + END = 3 elements.
|
|
if len(arr) != 3 {
|
|
t.Fatalf("want 3 elements, got %d: %s", len(arr), raw)
|
|
}
|
|
if arr[0]["component_id"] != "File" {
|
|
t.Errorf("elem0.component_id=%v want File", arr[0]["component_id"])
|
|
}
|
|
fileTrace, _ := arr[0]["trace"].([]any)
|
|
if len(fileTrace) != 2 {
|
|
t.Errorf("File trace should merge 2 entries, got %d: %s", len(fileTrace), raw)
|
|
}
|
|
if arr[1]["component_id"] != "Chunker" {
|
|
t.Errorf("elem1.component_id=%v want Chunker", arr[1]["component_id"])
|
|
}
|
|
if arr[2]["component_id"] != "END" {
|
|
t.Errorf("last component_id=%v want END", arr[2]["component_id"])
|
|
}
|
|
endTrace, _ := arr[2]["trace"].([]any)
|
|
endFirst, _ := endTrace[0].(map[string]any)
|
|
if strings.TrimSpace(endFirst["message"].(string)) == "" {
|
|
t.Errorf("END message must be non-empty for completion signal: %s", raw)
|
|
}
|
|
if !clientConsidersComplete(arr) {
|
|
t.Errorf("client would NOT consider run complete: %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_ErrorPrefix verifies that error-phase events get a [ERROR]
|
|
// prefix (so the front-end timeline renders them red) and that Flush with a
|
|
// non-nil error yields an END marker carrying the error, keeping the run
|
|
// detectable as finished.
|
|
func TestDebugLogSink_ErrorPrefix(t *testing.T) {
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c1", "m1", store)
|
|
|
|
sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{
|
|
Component: "Tokenizer", Message: "Tokenizer: boom", Phase: phaseError,
|
|
})
|
|
sink.Flush(context.Background(), errors.New("boom"))
|
|
|
|
raw := store.get("c1-m1-logs")
|
|
arr := loadArray(t, raw)
|
|
|
|
// First element is the errored component; its message must carry [ERROR].
|
|
erroredTrace, _ := arr[0]["trace"].([]any)
|
|
erroredFirst, _ := erroredTrace[0].(map[string]any)
|
|
if !strings.HasPrefix(erroredFirst["message"].(string), "[ERROR] ") {
|
|
t.Errorf("error trace message should be prefixed [ERROR] , got %q", erroredFirst["message"])
|
|
}
|
|
|
|
// Last element is END with the error surfaced, so the run still completes.
|
|
lastTrace, _ := arr[len(arr)-1]["trace"].([]any)
|
|
lastFirst, _ := lastTrace[0].(map[string]any)
|
|
if !strings.HasPrefix(lastFirst["message"].(string), "[ERROR] ") {
|
|
t.Errorf("END message on failure should be prefixed [ERROR] , got %q", lastFirst["message"])
|
|
}
|
|
if !clientConsidersComplete(arr) {
|
|
t.Errorf("failed run must still be detectable as complete: %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_OnComponentTotalIsNoOp ensures the denominator callback
|
|
// (used by the DB-backed sink for progress aggregation) is a safe no-op here:
|
|
// the debug log array shape does not use it.
|
|
func TestDebugLogSink_OnComponentTotalIsNoOp(t *testing.T) {
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c1", "m1", store)
|
|
// Must not panic and must not write.
|
|
sink.OnComponentTotal(context.Background(), "task-1", 5)
|
|
if len(store.data) != 0 {
|
|
t.Errorf("OnComponentTotal must not write to the store, wrote %v", store.data)
|
|
}
|
|
}
|
|
|
|
// TestPipelineExecutor_DebugRunWritesLogViaSink drives the real
|
|
// PipelineExecutor debug branch (KB.ID == "") with an injected fake pipeline
|
|
// that emits progress through the sink. It proves the end-to-end wiring inside
|
|
// the executor: a ProgressSink attached via WithProgressSink is forwarded to the
|
|
// pipeline and its events land in the persisted debug-log array (with the END
|
|
// marker), without touching the DB/index persist path.
|
|
func TestPipelineExecutor_DebugRunWritesLogViaSink(t *testing.T) {
|
|
ctx := context.Background()
|
|
taskCtx := NewDebugTaskContext("tenant-1", "c1", "doc.pdf", nil)
|
|
exec, err := NewPipelineExecutor(taskCtx, "c1", 0)
|
|
if err != nil {
|
|
t.Fatalf("NewPipelineExecutor: %v", err)
|
|
}
|
|
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c1", "m1", store)
|
|
exec.WithProgressSink(sink)
|
|
|
|
// Fake the canvas/DSL boundary: loadDSLFunc returns a dummy DSL and
|
|
// runPipelineFunc emits progress through the (now attached) sink instead of
|
|
// running a real pipeline.
|
|
exec.loadDSLFunc = func(_ context.Context, _ string) (string, string, error) {
|
|
return "dummy-dsl", "", nil
|
|
}
|
|
exec.runPipelineFunc = func(ctx context.Context, _ string) (map[string]any, string, error) {
|
|
exec.progressSink.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: "File", Message: "File Done", Phase: phaseExit,
|
|
})
|
|
exec.progressSink.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: "Chunker", Message: "Chunker Done", Phase: phaseExit,
|
|
})
|
|
return map[string]any{}, "", nil
|
|
}
|
|
|
|
if _, err := exec.Execute(ctx); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
// Mirrors runCanvasPipelineDebug: flush (incl. END marker) after the run.
|
|
sink.Flush(ctx, nil)
|
|
|
|
raw := store.get("c1-m1-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written to c1-m1-logs")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
if arr[len(arr)-1]["component_id"] != "END" {
|
|
t.Errorf("last element must be END marker, got %s", raw)
|
|
}
|
|
if !clientConsidersComplete(arr) {
|
|
t.Errorf("client would not consider run complete: %s", raw)
|
|
}
|
|
}
|
|
|
|
// traceStubComponent is a no-op runtime.Component used to exercise the REAL
|
|
// pipeline → TrackProgress → DebugLogSink wiring (the enter→exit pairing) without
|
|
// a live DB or any real ingestion work.
|
|
type traceStubComponent struct{}
|
|
|
|
func (traceStubComponent) Invoke(_ context.Context, _ *gorm.DB, _ map[string]any) (map[string]any, error) {
|
|
return map[string]any{"ok": true}, nil
|
|
}
|
|
|
|
// traceChunkComponent emits a real `chunks` output so the end-to-end wiring can
|
|
// be asserted to populate the END-marker DSL's params.outputs — the exact
|
|
// contract the front-end "View result" needs and the one the nested-state bug
|
|
// broke (empty Result tabs).
|
|
type traceChunkComponent struct{}
|
|
|
|
func (traceChunkComponent) Invoke(_ context.Context, _ *gorm.DB, _ map[string]any) (map[string]any, error) {
|
|
// Use the real component output shape ([]map[string]any as produced by
|
|
// ChunkDocsToMaps) so the end-to-end strip/recurse path for []map[string]any
|
|
// is actually exercised — a []any stub hides the deepCopyStrip type gap.
|
|
return map[string]any{
|
|
"chunks": []map[string]any{
|
|
{"text": "real chunk", "vector": []float64{0.5}},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// TestDebugLogSink_RealPipeline_EachComponentTraceHasStartedAndDone drives a
|
|
// REAL pipeline (not the fake runPipelineFunc used elsewhere) through the same
|
|
// wiring a dataflow debug run uses: NewPipelineFromDSL + WithProgressSink
|
|
// (DebugLogSink) + Run. The canvas framework wraps each component in
|
|
// realComponentBody → runtime.TrackProgress, which MUST emit BOTH PhaseEnter
|
|
// ("<comp> Started", progress 0) and PhaseExit ("<comp> Done", progress 1) on
|
|
// success.
|
|
//
|
|
// This locks the contract the front-end depends on: every component ends with a
|
|
// Done line, not just a Started line. It is the regression guard for the
|
|
// observation that a debug run's polled log appeared to stay at progress 0 —
|
|
// per the code, each component's trace must contain [Started, Done].
|
|
func TestDebugLogSink_RealPipeline_EachComponentTraceHasStartedAndDone(t *testing.T) {
|
|
const (
|
|
compA = "trace.RealStubA"
|
|
compB = "trace.RealStubB"
|
|
)
|
|
runtime.MustRegister(compA, runtime.CategoryIngestion,
|
|
func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil },
|
|
runtime.Metadata{Version: "1.0.0"})
|
|
runtime.MustRegister(compB, runtime.CategoryIngestion,
|
|
func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil },
|
|
runtime.Metadata{Version: "1.0.0"})
|
|
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c-trace", "m-trace", store)
|
|
|
|
dsl := []byte(`{"dsl":{"components":{
|
|
"begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["a"]},
|
|
"a":{"obj":{"component_name":"` + compA + `","params":{}},"upstream":["begin"],"downstream":["b"]},
|
|
"b":{"obj":{"component_name":"` + compB + `","params":{}},"upstream":["a"]}
|
|
},"path":["begin","a","b"],"graph":{"nodes":[]}}}`)
|
|
|
|
pipe, err := pipeline.NewPipelineFromDSL(dsl, "task-trace",
|
|
pipeline.WithProgressSink(sink), pipeline.WithDocumentID("doc-trace"))
|
|
if err != nil {
|
|
t.Fatalf("NewPipelineFromDSL: %v", err)
|
|
}
|
|
if _, err := pipe.Run(context.Background(), map[string]any{"name": "doc-trace"}, nil); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
sink.Flush(context.Background(), nil)
|
|
|
|
raw := store.get("c-trace-m-trace-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written to c-trace-m-trace-logs")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
|
|
// Completion predicate must hold: END is last with a non-empty message.
|
|
if arr[len(arr)-1]["component_id"] != "END" {
|
|
t.Fatalf("last element must be END marker: %s", raw)
|
|
}
|
|
if !clientConsidersComplete(arr) {
|
|
t.Fatalf("client would not consider run complete: %s", raw)
|
|
}
|
|
|
|
// Every real component (a, b) must show BOTH Started (progress 0) and Done
|
|
// (progress 1) within its own trace — proving TrackProgress emitted the full
|
|
// enter→exit lifecycle, not just enter.
|
|
for _, comp := range []string{"a", "b"} {
|
|
var gotStart, gotDone bool
|
|
for _, el := range arr {
|
|
if el["component_id"] != comp {
|
|
continue
|
|
}
|
|
traces, _ := el["trace"].([]any)
|
|
for _, tr := range traces {
|
|
tm, _ := tr.(map[string]any)
|
|
if tm == nil {
|
|
continue
|
|
}
|
|
prog, _ := tm["progress"].(float64)
|
|
msg, _ := tm["message"].(string)
|
|
switch {
|
|
case prog == 0 && msg == comp+" Started":
|
|
gotStart = true
|
|
case prog == 1 && msg == comp+" Done":
|
|
gotDone = true
|
|
}
|
|
}
|
|
}
|
|
if !gotStart || !gotDone {
|
|
t.Fatalf("component %q missing Started/Done in trace: %s", comp, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_RealPipeline_EndMarkerCarriesDSL locks the end-to-end
|
|
// contract the front-end "View result" depends on: the debug log's END marker
|
|
// trace[0] MUST carry a `dsl` (built by BuildDebugResultDSL) whose `components`
|
|
// map mirrors the canvas. The front-end `useFetchPipelineResult` picks
|
|
// `latest.trace.at(0)` where `latest.component_id == "END"` and reads
|
|
// `dsl.components[<id>].obj` to render parsed chunks (parser.tsx:41). Without
|
|
// this, "View result" shows only the file preview — the bug this change fixes.
|
|
//
|
|
// It drives a REAL pipeline (same wiring as a dataflow debug run) so the `dsl`
|
|
// is built from an actual run output map, not a hand-built one.
|
|
func TestDebugLogSink_RealPipeline_EndMarkerCarriesDSL(t *testing.T) {
|
|
const (
|
|
compC = "trace.RealStubC"
|
|
compD = "trace.RealStubD"
|
|
)
|
|
runtime.MustRegister(compC, runtime.CategoryIngestion,
|
|
func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil },
|
|
runtime.Metadata{Version: "1.0.0"})
|
|
runtime.MustRegister(compD, runtime.CategoryIngestion,
|
|
func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil },
|
|
runtime.Metadata{Version: "1.0.0"})
|
|
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c-dsl", "m-dsl", store)
|
|
|
|
dsl := `{"dsl":{"components":{
|
|
"begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["c"]},
|
|
"c":{"obj":{"component_name":"` + compC + `","params":{}},"upstream":["begin"],"downstream":["d"]},
|
|
"d":{"obj":{"component_name":"` + compD + `","params":{}},"upstream":["c"]}
|
|
},"path":["begin","c","d"],"graph":{"nodes":[{"id":"begin","data":{"name":"开始"}},{"id":"c","data":{"name":"解析"}},{"id":"d","data":{"name":"分词"}}]}}}`
|
|
|
|
pipe, err := pipeline.NewPipelineFromDSL([]byte(dsl), "task-dsl",
|
|
pipeline.WithProgressSink(sink), pipeline.WithDocumentID("doc-dsl"))
|
|
if err != nil {
|
|
t.Fatalf("NewPipelineFromDSL: %v", err)
|
|
}
|
|
output, err := pipe.Run(context.Background(), map[string]any{"name": "doc-dsl"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
// The executor would call this after Run; here we invoke it directly.
|
|
resultDSL, err := BuildDebugResultDSL(dsl, output)
|
|
if err != nil {
|
|
t.Fatalf("BuildDebugResultDSL: %v", err)
|
|
}
|
|
sink.SetResult(resultDSL)
|
|
sink.Flush(context.Background(), nil)
|
|
|
|
raw := store.get("c-dsl-m-dsl-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written to c-dsl-m-dsl-logs")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
|
|
// END must be the last element.
|
|
if arr[len(arr)-1]["component_id"] != "END" {
|
|
t.Fatalf("last element must be END marker: %s", raw)
|
|
}
|
|
endTrace, _ := arr[len(arr)-1]["trace"].([]any)
|
|
if len(endTrace) == 0 {
|
|
t.Fatalf("END trace is empty: %s", raw)
|
|
}
|
|
endFirst, _ := endTrace[0].(map[string]any)
|
|
dslVal := endFirst["dsl"]
|
|
if dslVal == nil {
|
|
t.Fatalf("END marker trace[0] must carry a non-empty dsl: %s", raw)
|
|
}
|
|
|
|
// json.RawMessage decodes into a generic map as map[string]any (not a
|
|
// string), so accept either form.
|
|
var dslDoc map[string]any
|
|
switch v := dslVal.(type) {
|
|
case string:
|
|
if err := json.Unmarshal([]byte(v), &dslDoc); err != nil {
|
|
t.Fatalf("END dsl is not valid JSON: %v body=%s", err, v)
|
|
}
|
|
case map[string]any:
|
|
dslDoc = v
|
|
default:
|
|
t.Fatalf("END dsl unexpected type %T: %s", v, raw)
|
|
}
|
|
components, ok := dslDoc["components"].(map[string]any)
|
|
if !ok || len(components) == 0 {
|
|
t.Fatalf("END dsl.components missing or empty: %s", raw)
|
|
}
|
|
// The dsl must mirror every component in the canvas (begin, c, d).
|
|
for _, id := range []string{"begin", "c", "d"} {
|
|
if _, exists := components[id]; !exists {
|
|
t.Errorf("END dsl.components missing component %q: %s", id, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_RealPipeline_EndMarkerDSLShowsChunks is the strongest
|
|
// regression guard for the nested-state bug: it drives a REAL pipeline whose
|
|
// component "c" emits actual `chunks`, then asserts the END-marker DSL carries
|
|
// a non-empty params.outputs for that component. With the bug (reading
|
|
// output[id] at the top level instead of output["state"][id]), the run output
|
|
// is nested and params.outputs stays empty — the front-end "View result" shows
|
|
// blank tabs. The companion TestDebugLogSink_RealPipeline_EndMarkerCarriesDSL
|
|
// only checks component keys exist (its stubs return {"ok":true}, no chunks),
|
|
// so it would not catch this. This test closes that end-to-end gap.
|
|
//
|
|
// It also pins the inverse: component "d" (stub returning {"ok":true}) has NO
|
|
// recognized output key, so its params.outputs must be absent — the safe-empty
|
|
// contract the front-end renders as a blank step.
|
|
func TestDebugLogSink_RealPipeline_EndMarkerDSLShowsChunks(t *testing.T) {
|
|
const (
|
|
compC = "trace.RealStubChunks"
|
|
compD = "trace.RealStubD2"
|
|
)
|
|
runtime.MustRegister(compC, runtime.CategoryIngestion,
|
|
func(_ string, _ map[string]any) (runtime.Component, error) { return traceChunkComponent{}, nil },
|
|
runtime.Metadata{Version: "1.0.0"})
|
|
runtime.MustRegister(compD, runtime.CategoryIngestion,
|
|
func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil },
|
|
runtime.Metadata{Version: "1.0.0"})
|
|
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c-chunk", "m-chunk", store)
|
|
|
|
dsl := `{"dsl":{"components":{
|
|
"begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["c"]},
|
|
"c":{"obj":{"component_name":"` + compC + `","params":{"setups":{"pdf":{"parse_method":"general"}}}},"upstream":["begin"],"downstream":["d"]},
|
|
"d":{"obj":{"component_name":"` + compD + `","params":{}},"upstream":["c"]}
|
|
},"path":["begin","c","d"],"graph":{"nodes":[{"id":"begin","data":{"name":"开始"}},{"id":"c","data":{"name":"解析"}},{"id":"d","data":{"name":"分词"}}]}}}`
|
|
|
|
pipe, err := pipeline.NewPipelineFromDSL([]byte(dsl), "task-chunk",
|
|
pipeline.WithProgressSink(sink), pipeline.WithDocumentID("doc-chunk"))
|
|
if err != nil {
|
|
t.Fatalf("NewPipelineFromDSL: %v", err)
|
|
}
|
|
output, err := pipe.Run(context.Background(), map[string]any{"name": "doc-chunk"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
resultDSL, err := BuildDebugResultDSL(dsl, output)
|
|
if err != nil {
|
|
t.Fatalf("BuildDebugResultDSL: %v", err)
|
|
}
|
|
sink.SetResult(resultDSL)
|
|
sink.Flush(context.Background(), nil)
|
|
|
|
raw := store.get("c-chunk-m-chunk-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
if arr[len(arr)-1]["component_id"] != "END" {
|
|
t.Fatalf("last element must be END marker: %s", raw)
|
|
}
|
|
endTrace, _ := arr[len(arr)-1]["trace"].([]any)
|
|
endFirst, _ := endTrace[0].(map[string]any)
|
|
|
|
var dslDoc map[string]any
|
|
switch v := endFirst["dsl"].(type) {
|
|
case string:
|
|
if err := json.Unmarshal([]byte(v), &dslDoc); err != nil {
|
|
t.Fatalf("END dsl not valid JSON: %v", err)
|
|
}
|
|
case map[string]any:
|
|
dslDoc = v
|
|
default:
|
|
t.Fatalf("END dsl unexpected type %T", v)
|
|
}
|
|
components, _ := dslDoc["components"].(map[string]any)
|
|
|
|
// Component "c" emits chunks -> params.outputs MUST be populated.
|
|
cParams := components["c"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)
|
|
cOutputs, ok := cParams["outputs"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("REGRESSION: chunk-emitting component c has empty params.outputs; "+
|
|
"the front-end 'View result' would render blank tabs. params=%#v", cParams)
|
|
}
|
|
if of, _ := cOutputs["output_format"].(map[string]any); of["value"] != "chunks" {
|
|
t.Errorf("c output_format=%#v want {value:\"chunks\"}", cOutputs["output_format"])
|
|
}
|
|
chunksVal := cOutputs["chunks"].(map[string]any)["value"].([]any)
|
|
if len(chunksVal) != 1 {
|
|
t.Fatalf("c chunks.value len=%d want 1", len(chunksVal))
|
|
}
|
|
chunk0 := chunksVal[0].(map[string]any)
|
|
if chunk0["text"] != "real chunk" {
|
|
t.Errorf("chunk text=%v want 'real chunk'", chunk0["text"])
|
|
}
|
|
if _, exists := chunk0["vector"]; exists {
|
|
t.Errorf("vector must be stripped from chunk payload")
|
|
}
|
|
if _, ok := cParams["setups"].(map[string]any); !ok {
|
|
t.Errorf("c params.setups must be carried from DSL: %#v", cParams)
|
|
}
|
|
|
|
// Component "d" emits {"ok":true} -> no recognized format -> outputs absent.
|
|
dParams := components["d"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)
|
|
if _, exists := dParams["outputs"]; exists {
|
|
t.Errorf("d has no recognized output, outputs must be absent: %#v", dParams)
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_ElapsedTimeIsInSeconds locks the unit contract for
|
|
// `elapsed_time`: it MUST be expressed in SECONDS, not the raw UnixMicro delta.
|
|
// This is the regression guard for the dataflow timeline showing values like
|
|
// "19951s" — the sink stored microseconds while the front-end renders the number
|
|
// verbatim with an "s" suffix (dataflow-timeline.tsx) and the rest of the app
|
|
// (webhook/workflow timelines) gates on `elapsed_time < 0.000001`, i.e. seconds.
|
|
//
|
|
// The test drives two progress events ~20ms apart and asserts the recorded
|
|
// elapsed_time is close to the measured wall-clock gap in seconds (not 1e6x
|
|
// larger, which is what an unfixed microsecond delta would produce).
|
|
func TestDebugLogSink_ElapsedTimeIsInSeconds(t *testing.T) {
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c-elapsed", "m-elapsed", store)
|
|
ctx := context.Background()
|
|
|
|
t0 := time.Now()
|
|
sink.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: "A", Message: "A Started", Phase: phaseEnter,
|
|
})
|
|
time.Sleep(20 * time.Millisecond)
|
|
t1 := time.Now()
|
|
sink.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: "A", Message: "A Done", Phase: phaseExit,
|
|
})
|
|
sink.Flush(ctx, nil)
|
|
|
|
raw := store.get("c-elapsed-m-elapsed-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written to c-elapsed-m-elapsed-logs")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
if len(arr) < 2 {
|
|
t.Fatalf("expected at least A + END elements, got %d: %s", len(arr), raw)
|
|
}
|
|
aTrace, _ := arr[0]["trace"].([]any)
|
|
if len(aTrace) < 2 {
|
|
t.Fatalf("A trace should have [Started, Done], got %d entries: %s", len(aTrace), raw)
|
|
}
|
|
doneTrace, _ := aTrace[1].(map[string]any)
|
|
elapsed, ok := doneTrace["elapsed_time"].(float64)
|
|
if !ok {
|
|
t.Fatalf("elapsed_time missing or wrong type: %s", raw)
|
|
}
|
|
|
|
gapSeconds := t1.Sub(t0).Seconds()
|
|
// Tolerance absorbs call-overhead jitter; the key is that elapsed is in the
|
|
// same SECONDS scale as gapSeconds, not 1e6x larger (microseconds).
|
|
// With an unfixed microsecond delta, elapsed would be ~gapSeconds*1e6 and
|
|
// this assertion fails.
|
|
if math.Abs(elapsed-gapSeconds) > math.Max(gapSeconds*0.5, 0.01) {
|
|
t.Fatalf("elapsed_time=%v want ~%v seconds (sink must divide the UnixMicro delta by 1e6)", elapsed, gapSeconds)
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_TimestampIsInSeconds locks the unit contract for the
|
|
// `timestamp` field: it MUST be a Unix epoch in SECONDS, not the raw UnixMicro
|
|
// value. Although the dataflow timeline currently renders the `datetime` string
|
|
// rather than `timestamp`, the field is part of the stable log contract the
|
|
// front-end can rely on; emitting microseconds would put it 1e6 off from any
|
|
// consumer expecting a Unix timestamp. This matches the SECONDS convention
|
|
// already established by elapsed_time and the other (webhook/workflow) timeline
|
|
// views.
|
|
func TestDebugLogSink_TimestampIsInSeconds(t *testing.T) {
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c-ts", "m-ts", store)
|
|
ctx := context.Background()
|
|
|
|
before := time.Now().Unix()
|
|
sink.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: "A", Message: "A Started", Phase: phaseEnter,
|
|
})
|
|
sink.Flush(ctx, nil)
|
|
|
|
raw := store.get("c-ts-m-ts-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written to c-ts-m-ts-logs")
|
|
}
|
|
arr := loadArray(t, raw)
|
|
aTrace, _ := arr[0]["trace"].([]any)
|
|
if len(aTrace) == 0 {
|
|
t.Fatalf("A trace is empty: %s", raw)
|
|
}
|
|
firstTrace, _ := aTrace[0].(map[string]any)
|
|
ts, ok := firstTrace["timestamp"].(float64)
|
|
if !ok {
|
|
t.Fatalf("timestamp missing or wrong type: %s", raw)
|
|
}
|
|
|
|
// If the sink emitted UnixMicro, ts would be ~1.7e15; a Unix-seconds value
|
|
// is ~1.7e9 and must be within a few seconds of `before`.
|
|
diff := math.Abs(ts - float64(before))
|
|
if diff > 5 {
|
|
t.Fatalf("timestamp=%v want ~%d (Unix seconds, not UnixMicro)", ts, before)
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_RespectsCaps pins the size guards: a pathological run that
|
|
// emits far more components / trace lines / over-long messages than any
|
|
// legitimate canvas ever would must be clamped, while the END completion marker
|
|
// and the front-end's completion predicate stay intact. This is the regression
|
|
// guard for the unbounded-memory / oversized-Redis-entry DoS surface.
|
|
//
|
|
// Counts are deliberately bounded so the test stays fast: we only need to exceed
|
|
// each cap by a small margin to prove clamping, not replay millions of events.
|
|
func TestDebugLogSink_RespectsCaps(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Component cap: more distinct components than maxLogEntries (each one trace,
|
|
// one over-long message that must be truncated to the rune cap).
|
|
storeA := &capturedStore{}
|
|
sinkA := NewDebugLogSink("c1", "mA", storeA)
|
|
for i := 0; i < maxLogEntries+200; i++ {
|
|
sinkA.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: fmt.Sprintf("Comp-%d", i),
|
|
Message: strings.Repeat("x", 5000), // truncated to maxMessageRunes
|
|
Phase: phaseExit,
|
|
})
|
|
}
|
|
sinkA.Flush(ctx, nil)
|
|
|
|
arrA := loadArray(t, storeA.get("c1-mA-logs"))
|
|
// Flush always appends the terminal END marker, so count only real components.
|
|
var compCountA int
|
|
for _, el := range arrA {
|
|
if el["component_id"] == "END" {
|
|
continue
|
|
}
|
|
compCountA++
|
|
}
|
|
if compCountA > maxLogEntries {
|
|
t.Fatalf("components=%d want <= %d", compCountA, maxLogEntries)
|
|
}
|
|
for _, el := range arrA {
|
|
traces, _ := el["trace"].([]any)
|
|
for _, tr := range traces {
|
|
tm, _ := tr.(map[string]any)
|
|
if tm == nil {
|
|
continue
|
|
}
|
|
msg, _ := tm["message"].(string)
|
|
if utf8.RuneCountInString(msg) > maxMessageRunes {
|
|
t.Fatalf("message rune len=%d want <= %d", utf8.RuneCountInString(msg), maxMessageRunes)
|
|
}
|
|
}
|
|
}
|
|
if !clientConsidersComplete(arrA) {
|
|
t.Errorf("clamped log must still satisfy completion predicate; raw=%s", storeA.get("c1-mA-logs"))
|
|
}
|
|
|
|
// Trace cap: a single component with more traces than maxTracePerEntry.
|
|
storeB := &capturedStore{}
|
|
sinkB := NewDebugLogSink("c1", "mB", storeB)
|
|
for j := 0; j < maxTracePerEntry+50; j++ {
|
|
sinkB.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: "Busy",
|
|
Message: "ok",
|
|
Phase: phaseExit,
|
|
})
|
|
}
|
|
sinkB.Flush(ctx, nil)
|
|
|
|
arrB := loadArray(t, storeB.get("c1-mB-logs"))
|
|
// Busy (the only real component) + the END completion marker = 2 elements.
|
|
if len(arrB) != 2 {
|
|
t.Fatalf("Busy + END expected, got %d elements: %s", len(arrB), storeB.get("c1-mB-logs"))
|
|
}
|
|
busyTrace, _ := arrB[0]["trace"].([]any)
|
|
if len(busyTrace) > maxTracePerEntry {
|
|
t.Fatalf("Busy trace len=%d want <= %d", len(busyTrace), maxTracePerEntry)
|
|
}
|
|
if !clientConsidersComplete(arrB) {
|
|
t.Errorf("trace-capped log must still satisfy completion predicate; raw=%s", storeB.get("c1-mB-logs"))
|
|
}
|
|
}
|
|
|
|
// TestDebugLogSink_PayloadHardCap pins the absolute byte ceiling on the JSON
|
|
// written to Redis. It fills to the per-entry caps (maxLogEntries components x
|
|
// maxTracePerEntry traces) — which pre-trim is ~16 MiB — and asserts the stored
|
|
// value never exceeds maxPayloadBytes, and still ends with the END marker.
|
|
func TestDebugLogSink_PayloadHardCap(t *testing.T) {
|
|
store := &capturedStore{}
|
|
sink := NewDebugLogSink("c1", "m1", store)
|
|
ctx := context.Background()
|
|
|
|
for i := 0; i < maxLogEntries; i++ {
|
|
comp := fmt.Sprintf("Comp-%d", i)
|
|
for j := 0; j < maxTracePerEntry; j++ {
|
|
sink.OnComponentProgress(ctx, pipeline.ProgressEvent{
|
|
Component: comp,
|
|
Message: "short",
|
|
Phase: phaseExit,
|
|
})
|
|
}
|
|
}
|
|
sink.Flush(ctx, nil)
|
|
|
|
raw := store.get("c1-m1-logs")
|
|
if raw == "" {
|
|
t.Fatalf("expected debug log written to c1-m1-logs")
|
|
}
|
|
if len(raw) > maxPayloadBytes {
|
|
t.Fatalf("stored payload bytes=%d want <= %d", len(raw), maxPayloadBytes)
|
|
}
|
|
arr := loadArray(t, raw)
|
|
if !clientConsidersComplete(arr) {
|
|
t.Errorf("hard-capped log must still satisfy completion predicate; arr=%s", raw)
|
|
}
|
|
}
|