Conversation
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
1 similar comment
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/cmd/status.go (1)
83-90:⚠️ Potential issue | 🟠 MajorUse the actual run ID when fetching real-time status.
When
--run-idis omitted and the latest run is still running,dagRunIDis empty butdagStatus.DAGRunIDis populated. Passing an empty ID can lead to a failed or incorrect realtime lookup. Consider falling back to the run ID from the status.🔧 Proposed fix
- realtimeStatus, err := ctx.DAGRunMgr.GetCurrentStatus(ctx, dag, dagRunID) + runID := dagRunID + if runID == "" { + runID = dagStatus.DAGRunID + } + realtimeStatus, err := ctx.DAGRunMgr.GetCurrentStatus(ctx, dag, runID)
🤖 Fix all issues with AI agents
In `@internal/agent/api.go`:
- Around line 551-570: The SSE CORS header in setupSSEHeaders currently sets
Access-Control-Allow-Origin to "*" which is unsafe for production; change
setupSSEHeaders (and any related SSE use in sendSSEMessage if applicable) to
read an allowed origin from configuration or environment (e.g., API config field
on the API struct) and set Access-Control-Allow-Origin to that value, defaulting
to a safe value or omitting the header when not configured, and ensure the
config is documented/validated on startup so production deployments can restrict
origins.
In `@internal/agent/patch.go`:
- Around line 174-182: The isDAGFile function currently uses strings.HasPrefix
on cleaned paths which can be spoofed (e.g., "/dags-malicious"), so change the
containment check to use filepath.Rel: after cleaning path and dagsDir, call
filepath.Rel(cleanDAGsDir, cleanPath) and ensure the result does not start with
".." and that no error occurred; keep the ".yaml" suffix check unchanged. Update
the isDAGFile function to return true only when filepath.Rel confirms cleanPath
is inside cleanDAGsDir (and path ends with ".yaml").
In `@internal/agent/schema/registry.go`:
- Around line 242-270: mergeAllOf is currently overwriting "required" arrays
when merging subschemas; update mergeAllOf (in navigator) to union all resolved
"required" entries instead of replacing them: for each resolved (from
resolveRef) if resolved["required"] is a slice, iterate and add each field name
to a set (deduplicate), then after processing allOf build a slice from that set
and assign merged["required"] to that slice alongside merged["properties"]; keep
existing behavior for copying non-"properties" keys but skip directly setting
"required" there to avoid overwrite.
In `@internal/agent/web_search_test.go`:
- Around line 41-50: The test creates a real HTTP request by calling
NewWebSearchTool().Run(ToolContext{}, input); update the WebSearch tool to
accept an injectable HTTP client/fetcher (or provide a constructor like
NewWebSearchToolWithClient) and change the test to use httptest.Server (or a
mock fetcher) to return deterministic responses, or alternatively mark the
subtest to skip when not running integration tests; modify references to
NewWebSearchTool and Run and the ToolContext usage in the test to use the
injected client or skip guard so the unit test no longer makes real network
calls.
In `@internal/cmn/fileutil/fileutil.go`:
- Around line 255-264: WriteFileAtomic currently uses a fixed tempPath (filePath
+ ".tmp") which can race when multiple writers run; change WriteFileAtomic to
create a unique temp file in the same directory (use os.CreateTemp or similar)
write the data to that temp file (ensuring file is closed and data
flushed/synced), set permissions if needed, then atomically rename the unique
temp file to filePath with os.Rename, and on any error remove the temp file;
update references to tempPath in the function accordingly and preserve existing
error wrapping.
🧹 Nitpick comments (26)
internal/cmn/config/definition.go (4)
136-139: Consider using Go-idiomatic acronym casing.Go convention prefers
ClientID,ClientURLoverClientId,ClientUrlfor acronyms in exported identifiers. The mapstructure tags can remain lowercase for config file compatibility.♻️ Suggested naming alignment
type AuthOIDCDef struct { - ClientId string `mapstructure:"clientId"` - ClientSecret string `mapstructure:"clientSecret"` - ClientUrl string `mapstructure:"clientUrl"` + ClientID string `mapstructure:"clientId"` + ClientSecret string `mapstructure:"clientSecret"` + ClientURL string `mapstructure:"clientUrl"` Issuer string `mapstructure:"issuer"`
215-215: Inconsistent mapstructure tag casing.The field
SkipTLSVerifyuses the tagskipTlsVerify(lowercase 'l' in 'Tls'). While mapstructure is typically case-insensitive, aligning the tag casing improves consistency, especially sinceRemoteNodeDef.SkipTLSVerifyon line 230 usesskipTLSVerify(uppercase 'TLS').♻️ Suggested fix
- SkipTLSVerify bool `mapstructure:"skipTlsVerify"` + SkipTLSVerify bool `mapstructure:"skipTLSVerify"`
248-249: Consider documenting the expected types forinterface{}fields.Using
interface{}forLabelsandCoordinatorsprovides parsing flexibility, but the brief comments ("String or map", "String or list") could be expanded. Consider adding more explicit documentation about the expected formats and validation that occurs during config loading to help maintainers understand the contract.
254-259: Inconsistent duration field representation.
PostgresPoolDefusesintwith "Seconds" suffix in comments (lines 257-258), whileSchedulerDef(lines 264-266) usesstringfor durations. Consider standardizing on one approach across all definition types for consistency.internal/cmn/config/loader.go (1)
1045-1058: Clarify SectionNone semantics in legacy env mappings.Using
SectionNone(value 0) asrequiresmeans these path mappings are always loaded regardless of service, which is correct since paths are fundamental. However, this relies on the conditionmapping.requires != SectionNonebeing false for these entries.Consider adding a brief comment to clarify this intentional behavior for future maintainers.
📝 Suggested comment for clarity
"DAGU__DATA": { newKey: "DAGU_DATA_DIR", setter: func(c *Config, v string) { c.Paths.DataDir = fileutil.ResolvePathOrBlank(v) }, - requires: SectionNone, + requires: SectionNone, // SectionNone means always loaded (paths are universal) },internal/cmd/status_test.go (2)
42-47: Add a timeout when waiting ondoneto prevent hangs.Line 46 blocks indefinitely if Start never returns. Consider failing fast with a timeout.
💡 Suggested change
func stopDAGAndWait(t *testing.T, th test.Command, dagLocation string, done <-chan struct{}) { t.Helper() th.RunCommand(t, cmd.Stop(), test.CmdTest{Args: []string{"stop", dagLocation}}) - <-done + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for DAG to stop") + } }
409-482: Replace fixed sleeps with eventual waits for sub-run availability.Line 430 and Line 473 rely on fixed sleeps, which can flake under slow CI. Prefer
require.Eventuallyto wait for the parent run/sub-run to appear before assertions and error-message checks.💡 Example approach for StatusSubDAGRun
- time.Sleep(500 * time.Millisecond) - - parentRef := exec.NewDAGRunRef(dagFile.Location, parentRunID) - parentAttempt, err := th.DAGRunStore.FindAttempt(th.Context, parentRef) - require.NoError(t, err) - - parentStatus, err := parentAttempt.ReadStatus(th.Context) - require.NoError(t, err) - require.Len(t, parentStatus.Nodes, 1) - require.NotEmpty(t, parentStatus.Nodes[0].SubRuns) + parentRef := exec.NewDAGRunRef(dagFile.Location, parentRunID) + var parentStatus exec.DAGRunStatus + require.Eventually(t, func() bool { + parentAttempt, err := th.DAGRunStore.FindAttempt(th.Context, parentRef) + if err != nil { + return false + } + status, err := parentAttempt.ReadStatus(th.Context) + if err != nil { + return false + } + if len(status.Nodes) != 1 || len(status.Nodes[0].SubRuns) == 0 { + return false + } + parentStatus = status + return true + }, 5*time.Second, 50*time.Millisecond)internal/cmn/fileutil/atomic_test.go (1)
55-66: Permission test may behave differently on Windows.File permission checks with
info.Mode().Perm()may not work as expected on Windows, where the permission model differs from Unix. Consider adding a build constraint or skip condition.💡 Optional: Skip on Windows
t.Run("sets correct permissions", func(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("file permissions work differently on Windows") + } dir := t.TempDir() filePath := filepath.Join(dir, "test.txt")Add
"runtime"to imports.internal/agent/think.go (1)
33-36: The "thought" parameter is not actually recorded.The function discards the input entirely. While the response says "Thought recorded," the thought content is never logged, stored, or included in the response. Consider either:
- Including the thought in the response so it appears in conversation history
- Logging it for debugging purposes
- Updating the response message to be more accurate
💡 Option: Include thought in response for conversation history
+type thinkInput struct { + Thought string `json:"thought"` +} + // thinkRun acknowledges the thought without performing any action. -func thinkRun(_ ToolContext, _ json.RawMessage) ToolOut { - return ToolOut{Content: "Thought recorded. Continue with your plan."} +func thinkRun(_ ToolContext, input json.RawMessage) ToolOut { + var args thinkInput + if err := json.Unmarshal(input, &args); err != nil { + return ToolOut{Content: "Thought recorded. Continue with your plan."} + } + return ToolOut{Content: fmt.Sprintf("Thought: %s\n\nContinue with your plan.", args.Thought)} }internal/agent/subpub_test.go (1)
31-34: Preferrequirefor test assertions
Switch these (and the otherassert.*calls in this file) torequire.*so failures stop the test and align with repo conventions.Based on learnings: Use stretchr/testify/require and shared fixtures from internal/test instead of duplicating mocks.♻️ Example change
- msg, ok := next() - assert.True(t, ok) - assert.Equal(t, "hello", msg) + msg, ok := next() + require.True(t, ok) + require.Equal(t, "hello", msg)internal/agent/patch_test.go (1)
299-305: Consider adding a test case for the path prefix bypass scenario.Add a test case to verify that paths like
/dags-malicious/file.yamlare correctly rejected whendagsDiris/dags:🧪 Suggested additional test case
{ name: "yaml file with similar prefix but outside dags directory", path: "/dags-malicious/workflow.yaml", dagsDir: "/dags", expected: false, },internal/cmn/config/config.go (1)
385-391: Consider validatingMaxDashboardPageLimitupper bound.The validation ensures the limit is at least 1, but an extremely large value could cause performance issues. Consider adding a reasonable upper bound:
🔧 Optional: Add upper bound validation
func (c *Config) validateUI() error { if c.UI.MaxDashboardPageLimit < 1 { return fmt.Errorf("invalid max dashboard page limit: %d", c.UI.MaxDashboardPageLimit) } + if c.UI.MaxDashboardPageLimit > 1000 { + return fmt.Errorf("max dashboard page limit too high: %d (max: 1000)", c.UI.MaxDashboardPageLimit) + } return nil }Makefile (1)
123-123: Consider consistency withrun-server-httpstarget.The
runtarget now setsDAGU_DEBUG=1, but therun-server-httpstarget (line 148) does not. If debug mode is useful during local development, you may want to apply it consistently across both targets.🔧 Optional: Apply DAGU_DEBUG to run-server-https
.PHONY: run-server-https run-server-https: ${SERVER_CERT_FILE} ${SERVER_KEY_FILE} `@echo` "${COLOR_GREEN}Starting the server with HTTPS...${COLOR_RESET}" - `@DAGU_CERT_FILE`=${SERVER_CERT_FILE} \ + `@DAGU_DEBUG`=1 DAGU_CERT_FILE=${SERVER_CERT_FILE} \ DAGU_KEY_FILE=${SERVER_KEY_FILE} \ go run ./cmd start-allinternal/agent/store_test.go (1)
38-61: Consider usingrequireinstead ofassertper coding guidelines.The coding guidelines recommend using
stretchr/testify/requirefor test assertions. Whileassertworks here,requireprovides fail-fast behavior which is generally preferred.♻️ Optional: Switch to require for fail-fast assertions
import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDefaultConfig(t *testing.T) { t.Parallel() t.Run("returns correct defaults", func(t *testing.T) { t.Parallel() cfg := DefaultConfig() - assert.True(t, cfg.Enabled) - assert.Equal(t, DefaultProvider, cfg.LLM.Provider) - assert.Equal(t, DefaultModel, cfg.LLM.Model) + require.True(t, cfg.Enabled) + require.Equal(t, DefaultProvider, cfg.LLM.Provider) + require.Equal(t, DefaultModel, cfg.LLM.Model) }) // ... apply similar changes throughoutAs per coding guidelines: "Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks"internal/agent/system_prompt.txt (1)
591-603: Consider varying the sentence structure in the final rules.The static analysis flagged three successive "NEVER" sentences at the end. While the emphasis is intentional for security, varying the structure slightly could improve readability without reducing impact.
📝 Optional: Vary sentence structure
- Use `navigate` for UI pages, not CLI status commands. - ALWAYS navigate after creating/modifying DAGs. - ALWAYS validate with `dagu validate` before confirming. - Never run (`dagu start`) unless the user explicitly requests it. - NEVER read environment variables directly (security risk). -- NEVER assume uncertain values; ask when unclear. -- NEVER create DAGs with placeholder/dummy values unless the user explicitly requests dummy data. +- When uncertain about values, ask—do not assume. +- Avoid placeholder/dummy values in DAGs unless the user explicitly requests them for testing.internal/agent/schema/output_test.go (1)
53-68: Consider addingt.Parallel()and using testify for consistency.Other tests in this PR (e.g.,
store_test.go) uset.Parallel()andtestifyassertions. Adding these would improve consistency across the test suite.♻️ Optional: Add parallel execution and testify assertions
+import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + func TestNavigateDeepPaths(t *testing.T) { + t.Parallel() + tests := []struct { // ... } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := DefaultRegistry.Navigate("dag", tt.path) - if (err != nil) != tt.wantErr { - t.Errorf("Navigate() error = %v, wantErr %v", err, tt.wantErr) - return - } - if err != nil { + if tt.wantErr { + require.Error(t, err) return } + require.NoError(t, err) for _, want := range tt.want { - if !strings.Contains(result, want) { - t.Errorf("Navigate() result missing %q\nGot:\n%s", want, result) - } + require.Contains(t, result, want, "Navigate() result missing expected content") } }) } }As per coding guidelines: "Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks"internal/agent/read_test.go (1)
22-24: Consider escaping special characters in path.The
readInputhelper directly interpolates the path into JSON. If a path contains characters like"or\, this could produce invalid JSON.🛠️ Suggested improvement
func readInput(path string) json.RawMessage { - return json.RawMessage(`{"path": "` + path + `"}`) + data, _ := json.Marshal(map[string]string{"path": path}) + return data }internal/agent/api_test.go (2)
401-436: SSE streaming test is reasonable but timing-dependent.The test uses a 50ms sleep which could be flaky on slow CI systems. Consider using a synchronization mechanism or increasing the timeout if this becomes flaky.
438-499: Loop variable capture in Go 1.22+.Lines 489 and 604 have
tc := tcwhich was necessary before Go 1.22 to avoid loop variable capture issues. If this project uses Go 1.22+, these lines are no longer needed.#!/bin/bash # Check Go version in go.mod to determine if tc := tc is still needed cat go.mod | head -5internal/agent/read_schema.go (1)
22-24: Consider: Schema enum is captured at tool creation time.
AvailableSchemas()is called once whenNewReadSchemaTool()is invoked. If schemas are registered after tool creation, they won't appear in the enum. This is likely acceptable if tools are created after all schemas are registered (e.g., at startup), but worth noting for future maintainers.internal/agent/mocks_test.go (1)
13-115: Consider promoting these mocks into shared test fixtures.This reduces duplication and keeps mock behavior consistent across agent tests.
Based on learnings: Usestretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks.internal/agent/schema/registry.go (1)
55-63: Sort schema names for deterministic output.Map iteration order is randomized, so error messages and
AvailableSchemas()output can fluctuate. Sorting improves determinism and test stability.🧭 Suggested change
import ( "encoding/json" "fmt" + "sort" "strings" "sync" ) @@ func (r *Registry) AvailableSchemas() []string { r.mu.RLock() defer r.mu.RUnlock() names := make([]string, 0, len(r.schemas)) for name := range r.schemas { names = append(names, name) } + sort.Strings(names) return names }internal/agent/api.go (1)
319-336: Type assertions from sync.Map are safe but could be more defensive.The type assertions at lines 320 and 325 assume the map only contains
*ConversationManager. While this is true given the current implementation, a defensive check would prevent panics if the invariant is ever violated.♻️ Optional: Add defensive type assertion check
a.conversations.Range(func(key, value any) bool { - mgr := value.(*ConversationManager) + mgr, ok := value.(*ConversationManager) + if !ok { + return true + } if mgr.UserID() != userID { return true } - id := key.(string) + id, ok := key.(string) + if !ok { + return true + }internal/agent/web_search.go (1)
107-135: Retry condition may retry non-retryable errors.The retry condition at lines 112-118 returns
truefor any error, which could cause retries for non-retryable errors like DNS resolution failures or certificate errors. Consider being more selective.♻️ Suggested improvement for retry logic
AddRetryCondition(func(r *resty.Response, err error) bool { - if err != nil { - return true - } + // Only retry on transient network errors, not permanent failures + if err != nil { + // Could check for specific error types if needed + return false + } code := r.StatusCode() return code == 429 || code >= 500 })Alternatively, you could use
resty.Backoffwith more specific conditions, or check fornet.Errortemporary errors.internal/agent/loop.go (1)
264-271: RecursiveprocessLLMRequestcall could cause deep call stacks.The
handleToolCallsmethod recursively callsprocessLLMRequestafter processing tool calls. If the LLM repeatedly returns tool calls, this could lead to deep recursion. Consider converting to an iterative approach.♻️ Suggested iterative approach
-// handleToolCalls processes tool calls from the LLM response. -func (l *Loop) handleToolCalls(ctx context.Context, toolCalls []llm.ToolCall) error { - for _, tc := range toolCalls { - l.logger.Debug("executing tool", "name", tc.Function.Name, "id", tc.ID) - l.recordToolResult(ctx, tc, l.executeTool(ctx, tc)) - } - return l.processLLMRequest(ctx) -} +// handleToolCalls processes tool calls from the LLM response. +// Returns true if another LLM request should be made. +func (l *Loop) handleToolCalls(ctx context.Context, toolCalls []llm.ToolCall) bool { + for _, tc := range toolCalls { + l.logger.Debug("executing tool", "name", tc.Function.Name, "id", tc.ID) + l.recordToolResult(ctx, tc, l.executeTool(ctx, tc)) + } + return true // Signal to continue with another LLM request +}Then modify
processLLMRequestto return a flag and loop in the caller, or restructure as an internal loop withinprocessLLMRequest.internal/agent/conversation.go (1)
458-502: Channel send inSubmitUserResponsemay silently drop response.At lines 496-500, the
selectwithdefaultwill returnfalseif the channel already has a buffered response. Since the channel is created with buffer size 1 (line 461), a second response to the same prompt would be silently dropped.This is likely intentional (first response wins), but consider logging when a response is dropped to aid debugging.
♻️ Optional: Add logging for dropped responses
select { case ch <- response: return true default: + // Response channel is full - prompt already answered return false }Or add debug logging in the ConversationManager's logger if available in this scope.
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/cmd/status.go (1)
83-91:⚠️ Potential issue | 🟠 MajorUse the resolved run ID for realtime status lookup.
When
--run-idis omitted,dagRunIDis empty, so the realtime call can fail or target the wrong run. Use the resolveddagStatus.DAGRunIDinstead.🛠️ Proposed fix
- realtimeStatus, err := ctx.DAGRunMgr.GetCurrentStatus(ctx, dag, dagRunID) + realtimeStatus, err := ctx.DAGRunMgr.GetCurrentStatus(ctx, dag, dagStatus.DAGRunID)internal/core/dag_test.go (1)
19-158:⚠️ Potential issue | 🟡 MinorMake socket path assertions OS-aware.
Line 24 and other assertions in
TestSockAddrhard-code/tmp, butgetSocketPathnow usesos.TempDir()on Windows. This will fail on Windows builds; derive the base dir dynamically and usefilepath.Join/os.PathSeparatorin the assertions.🔧 Proposed fix (apply same pattern to other /tmp literals in this test)
@@ import ( "crypto/md5" "encoding/json" "errors" "fmt" + "os" + "path/filepath" + "runtime" "strings" @@ func TestSockAddr(t *testing.T) { t.Parallel() + baseDir := "/tmp" + if runtime.GOOS == "windows" { + baseDir = os.TempDir() + } + baseDir = filepath.Clean(baseDir) t.Run("Location", func(t *testing.T) { dag := &core.DAG{Location: "testdata/testDag.yml"} - require.Regexp(t, `^/tmp/@dagu_testdata_testDag_yml_[0-9a-f]+\.sock$`, dag.SockAddr("")) + require.True(t, strings.HasPrefix( + dag.SockAddr(""), + filepath.Join(baseDir, "@dagu_testdata_testDag_yml_"), + )) + require.True(t, strings.HasSuffix(dag.SockAddr(""), ".sock")) }) @@ require.Equal( t, - "/tmp/@dagu_testdata_testDagVeryLongNameThat_b92b71.sock", + filepath.Join(baseDir, "@dagu_testdata_testDagVeryLongNameThat_b92b71.sock"), dag.SockAddr(""), ) @@ - require.True(t, strings.HasPrefix(addr, "/tmp/@dagu_")) + require.True(t, strings.HasPrefix(addr, filepath.Join(baseDir, "@dagu_"))) @@ - socketName := strings.TrimPrefix(addr, "/tmp/") + socketName := strings.TrimPrefix(addr, baseDir+string(os.PathSeparator))
🤖 Fix all issues with AI agents
In `@internal/agent/api.go`:
- Around line 319-326: The Range callback for a.conversations uses single-value
type assertions that can panic; change them to the two-value form when casting
value to *ConversationManager and key to string (e.g., v, ok :=
value.(*ConversationManager); if !ok { return true } and k, ok := key.(string);
if !ok { return true }) so unexpected types are skipped safely; keep the
existing logic using mgr.UserID() and activeIDs[id] only after successful
assertions.
In `@internal/agent/ask_user.go`:
- Around line 111-114: Check for a nil context before calling WaitUserResponse:
if ctx.Context is nil, return a descriptive toolError (e.g.,
"ToolContext.Context is nil or uninitialized") instead of calling
ctx.WaitUserResponse to avoid a panic; update the block around response, err :=
ctx.WaitUserResponse(ctx.Context, promptID) to validate ctx.Context first and
only call WaitUserResponse when it is non-nil.
In `@internal/agent/bash.go`:
- Around line 71-123: The code currently assigns full bytes.Buffers to
cmd.Stdout/cmd.Stderr (see stdout, stderr vars and cmd.Stdout = &stdout /
cmd.Stderr = &stderr) which can grow unbounded; change to stream and cap
captured output to maxOutputLength+1 at write time by using
cmd.StdoutPipe()/cmd.StderrPipe() and copying into a bytes.Buffer via io.Copy
with an io.LimitWriter (or a small custom writer) so only up to
maxOutputLength+1 bytes are stored for each of stdout/stderr before truncation;
keep buildOutput, truncateOutput, resolveTimeout and the error/timeout handling
as-is, but replace the direct assignment to cmd.Stdout/cmd.Stderr with
piping+limited-copy logic executed before cmd.Run() (or started via
cmd.Start()/goroutines then cmd.Wait()).
In `@internal/agent/conversation.go`:
- Around line 18-167: The GetConversation CreatedAt/UpdatedAt drift happens
because ConversationManager.lastActivity is only updated on user messages, so
assistant messages don't bump UpdatedAt and the original creation time can
drift; fix by tracking a fixed conversation creation timestamp (add a
conversationCreated field on ConversationManager initialized in
NewConversationManager) and updating lastActivity whenever any message is
appended (update appendMessage / wherever messages are added to set
cm.lastActivity = msg.CreatedAt or time.Now()), ensure each Message has/uses its
own CreatedAt when appending, and change GetConversation to return CreatedAt:
cm.conversationCreated and UpdatedAt: cm.lastActivity; adjust copyMessages if
needed to preserve per-message CreatedAt.
In `@internal/agent/loop.go`:
- Around line 87-102: NewLoop currently initializes Loop.sequenceID to 0 which
causes ID collisions when LoopConfig.History contains preloaded messages; update
NewLoop to seed sequenceID from the existing history (e.g., set sequenceID to
the max SequenceID found in config.History or at minimum the history length) so
subsequent appendToHistory uses strictly increasing IDs; locate NewLoop/Loop
struct initialization and set the sequenceID field by scanning config.History
for the highest SequenceID (or using len(history)) before returning the Loop,
ensuring appendToHistory and RecordMessage will continue the sequence correctly.
In `@internal/agent/read.go`:
- Around line 56-78: The resolvePath call in readRun currently allows path
traversal; update resolvePath (or its caller) to compute the absolute path
(using filepath.Abs after filepath.Join(ctx.WorkingDir, args.Path) or similar)
and then verify the resolved absolute path is contained within the working
directory (use filepath.Rel between ctx.WorkingDir and the resolved path and
ensure the result does not start with ".." or use safe prefix checking) before
proceeding to validateReadableFile and os.ReadFile; ensure validateReadableFile
receives the resolved absolute path so any stat/open happens only on the
normalized in-tree path.
In `@internal/agent/store.go`:
- Around line 29-36: LLMConfig currently serializes APIKey via `APIKey string
\`json:"apiKey"\``, which risks leaking secrets; change this by removing it from
JSON output (e.g., set `json:"-"` on APIKey) and instead handle the key via a
separate secure mechanism (store encrypted before persistence or provide
explicit SetAPIKey/GetAPIKey methods that encrypt/decrypt and avoid
serialization), and ensure any store/save functions treat LLMConfig.APIKey like
DAG.Env (excluded from JSON) or enforce access controls; update code using
LLMConfig (constructors, marshaling/unmarshaling, and persistence logic) to use
the new secure accessors or encryption helpers.
In `@internal/cmd/status_test.go`:
- Around line 26-40: waitForDAGRunning currently passes a DAG file path
(dagLocation) into DAGRunStore.RecentAttempts which expects a DAG name; resolve
the DAG metadata once and pass the DAG name instead. Update waitForDAGRunning to
call th.DAGStore.GetMetadata (or use dagFile.Name if available) to obtain the
DAG name and then call th.DAGRunStore.RecentAttempts(th.Context, dagName, 1) and
any NewDAGRunRef usages with the DAG name rather than the file path so attempts
are correctly found.
🧹 Nitpick comments (20)
internal/cmd/flags.go (1)
340-356: Consider usingbaseFlagsfor consistency withinitFlags.
initFlagsusesbaseFlags(4 flags), butbindFlagsstarts with onlyconfigFlag. While currently safe (onlyconfigFlaghasbindViper: trueamong base flags), this is a maintenance hazard—ifdaguHomeFlag,quietFlag, orcpuProfileFlaglater gainsbindViper: true, a developer must remember to update this function separately.Since the loop already filters on
bindViper, usingbaseFlagshere would be both consistent and safe.♻️ Proposed fix for consistency
func bindFlags(v *viper.Viper, cmd *cobra.Command, additionalFlags ...commandLineFlag) { - allFlags := append([]commandLineFlag{configFlag}, additionalFlags...) + allFlags := append(baseFlags, additionalFlags...) for _, flag := range allFlags { if !flag.bindViper { continue }internal/output/tree.go (1)
252-270: Consider caching log content to avoid repeated file reads.
hasOutput()reads log files to check for content, and thenrenderOutputs()->renderOutput()reads the same files again. For nodes with both stdout and stderr,ReadLogFileTailmay be called multiple times for the same file paths.Consider restructuring to read the log content once and pass it through the rendering pipeline, or cache the results to avoid repeated I/O operations—especially beneficial when rendering many nodes.
internal/cmd/status_test.go (1)
430-430: Avoid fixed sleep before reading sub‑run metadata.A fixed 500ms sleep can be flaky on slower CI; consider waiting until the sub‑run appears.
♻️ Suggested approach
- time.Sleep(500 * time.Millisecond) + require.Eventually(t, func() bool { + parentRef := exec.NewDAGRunRef(dagFile.Location, parentRunID) + parentAttempt, err := th.DAGRunStore.FindAttempt(th.Context, parentRef) + if err != nil { + return false + } + parentStatus, err := parentAttempt.ReadStatus(th.Context) + if err != nil || len(parentStatus.Nodes) == 0 { + return false + } + return len(parentStatus.Nodes[0].SubRuns) > 0 + }, time.Second*3, time.Millisecond*50)internal/agent/read.go (1)
104-131: Minor: Clarify offset=0 behavior.When
offset=0is provided,max(0, 0-1)evaluates to0, which starts from line 1. This is implicitly treating 0 as "start from beginning," but the description says offset is 1-based. Consider either:
- Documenting that 0 means "start from beginning"
- Treating 0 as invalid input
This is a minor edge case that doesn't break functionality.
internal/agent/subpub_test.go (1)
26-33: Replace fixed sleeps with explicit synchronization to reduce flakiness.Several tests rely on time-based waits; on slow CI these can be brittle. Consider using channels/WaitGroups or polling with timeouts instead of fixed sleeps.
Also applies to: 74-81, 136-139, 162-170, 253-266
internal/agent/schema/output_test.go (1)
53-66: Preferrequirefor fatal assertions in the navigation loop.
This removes manual early returns and aligns the test style with the shared testing guidance.Proposed refactor
import ( "strings" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) @@ t.Run(tt.name, func(t *testing.T) { result, err := DefaultRegistry.Navigate("dag", tt.path) - if (err != nil) != tt.wantErr { - t.Errorf("Navigate() error = %v, wantErr %v", err, tt.wantErr) - return - } - if err != nil { - return - } + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) for _, want := range tt.want { if !strings.Contains(result, want) { t.Errorf("Navigate() result missing %q\nGot:\n%s", want, result) } } }) } }As per coding guidelines: Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks.internal/agent/store_test.go (1)
9-60: Userequireassertions for fail-fast test checks.
These assertions are prerequisites; switching torequireavoids cascading failures and matches the testing guidance.Proposed refactor (apply pattern throughout the file)
import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) @@ cfg := DefaultConfig() - assert.True(t, cfg.Enabled) - assert.Equal(t, DefaultProvider, cfg.LLM.Provider) - assert.Equal(t, DefaultModel, cfg.LLM.Model) + require.True(t, cfg.Enabled) + require.Equal(t, DefaultProvider, cfg.LLM.Provider) + require.Equal(t, DefaultModel, cfg.LLM.Model) @@ - assert.NotNil(t, ErrConversationNotFound) - assert.Contains(t, ErrConversationNotFound.Error(), "not found") + require.NotNil(t, ErrConversationNotFound) + require.Contains(t, ErrConversationNotFound.Error(), "not found")As per coding guidelines: Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks.internal/agent/system_prompt.txt (1)
93-97: Hyphenate “Root-level” for clarity.Proposed edit
- - `path: ""` - Root level fields + - `path: ""` - Root-level fieldsinternal/agent/bash_test.go (1)
20-121: Preferrequirefor core Bash tool expectations.
These assertions gate the rest of each case;requiremakes failures clearer and aligns with test conventions.Proposed refactor (apply pattern throughout the file)
import ( "context" "encoding/json" "strings" "testing" "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) @@ - assert.False(t, result.IsError) - assert.Contains(t, result.Content, "hello") + require.False(t, result.IsError) + require.Contains(t, result.Content, "hello")As per coding guidelines: Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks.internal/agent/think_test.go (1)
10-45: Switch torequirefor the Think tool assertions.
This keeps the tests fail-fast and consistent with the team’s test guidance.Proposed refactor
import ( "encoding/json" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) @@ - assert.False(t, result.IsError) + require.False(t, result.IsError) if tc.wantContains != "" { - assert.Contains(t, result.Content, tc.wantContains) + require.Contains(t, result.Content, tc.wantContains) }As per coding guidelines: Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks.internal/agent/read_test.go (1)
22-24: Consider usingjson.Marshalfor safer JSON construction.The
readInputhelper uses string concatenation to build JSON, which could produce invalid JSON if the path contains special characters like quotes or backslashes.♻️ Suggested safer implementation
-func readInput(path string) json.RawMessage { - return json.RawMessage(`{"path": "` + path + `"}`) +func readInput(path string) json.RawMessage { + data, _ := json.Marshal(map[string]string{"path": path}) + return data }internal/agent/think.go (1)
33-36: Consider validating thethoughtparameter.The
thinkRunfunction ignores the input entirely. While this is intentional for a no-op tool, consider adding minimal validation to ensure thethoughtparameter is provided and non-empty. This would give the LLM better feedback if it misuses the tool.♻️ Optional: Add input validation
+type thinkInput struct { + Thought string `json:"thought"` +} + // thinkRun acknowledges the thought without performing any action. -func thinkRun(_ ToolContext, _ json.RawMessage) ToolOut { +func thinkRun(_ ToolContext, input json.RawMessage) ToolOut { + var in thinkInput + if err := json.Unmarshal(input, &in); err != nil { + return toolError("Failed to parse input: %v", err) + } + if in.Thought == "" { + return toolError("thought is required") + } return ToolOut{Content: "Thought recorded. Continue with your plan."} }internal/agent/api_test.go (2)
419-435: Stream test may be flaky due totime.Sleepsynchronization.The 50ms sleep is used to ensure the SSE handler has time to set headers before cancellation. This timing-based approach can be flaky under CI load or slow environments.
♻️ Consider a more robust synchronization approach
One option is to check for headers in a loop with a timeout instead of a fixed sleep:
done := make(chan struct{}) go func() { defer close(done) setup.router.ServeHTTP(rec, req) }() - time.Sleep(50 * time.Millisecond) + // Wait for SSE headers to be set + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if rec.Header().Get("Content-Type") != "" { + break + } + time.Sleep(5 * time.Millisecond) + } cancel() <-done
488-498: Thetc := tcpattern is now unnecessary in Go 1.22+.Go 1.22 changed loop variable semantics so that each iteration gets its own variable. The
tc := tcshadowing is no longer needed for closure captures in parallel subtests. However, keeping it doesn't cause any issues and maintains backward compatibility.Also applies to: 603-611
internal/agent/patch_test.go (1)
26-32: Consider usingjson.Marshalfor safer JSON construction.Similar to
readInputin read_test.go, this helper uses string formatting which could produce invalid JSON if path or values contain special characters like quotes.♻️ Suggested safer implementation
func patchInput(path, operation string, extra ...string) json.RawMessage { - base := fmt.Sprintf(`{"path": %q, "operation": %q`, path, operation) - for i := 0; i < len(extra)-1; i += 2 { - base += fmt.Sprintf(`, %q: %q`, extra[i], extra[i+1]) - } - return json.RawMessage(base + "}") + m := map[string]string{"path": path, "operation": operation} + for i := 0; i < len(extra)-1; i += 2 { + m[extra[i]] = extra[i+1] + } + data, _ := json.Marshal(m) + return data }internal/agent/loop_test.go (1)
406-413: Hardcoded tool count assertion is brittle.The assertion
assert.Len(t, tools, 8)will fail silently when tools are added or removed. Consider checking for expected tools by name or making this more flexible.♻️ Suggested improvement
func TestLoop_BuildToolDefinitions(t *testing.T) { t.Parallel() t.Run("converts agent tools to LLM tools", func(t *testing.T) { t.Parallel() loop := NewLoop(LoopConfig{ Provider: &mockLLMProvider{}, Tools: CreateTools(""), }) tools := loop.buildToolDefinitions() - assert.Len(t, tools, 8) + assert.NotEmpty(t, tools, "should have at least one tool") for _, tool := range tools { assert.Equal(t, "function", tool.Type) assert.NotEmpty(t, tool.Function.Name) } }) }internal/agent/web_search.go (1)
107-118: Consider refining retry conditions for transient errors only.The retry condition at line 113-114 retries on any error, which includes non-transient errors like DNS resolution failures or invalid URLs. Consider being more selective.
♻️ Suggested refinement
func performSearch(ctx context.Context, query string, maxResults int) ([]SearchResult, error) { client := resty.New(). SetTimeout(defaultWebSearchTimeout). SetRetryCount(maxRetries). SetRetryWaitTime(retryWaitTime). AddRetryCondition(func(r *resty.Response, err error) bool { - if err != nil { - return true - } code := r.StatusCode() - return code == 429 || code >= 500 + // Retry on rate limiting or server errors + return code == 429 || code >= 500 })Note: Removing the
err != nilcheck means only HTTP status codes trigger retries, not connection errors. If retrying connection errors is desired, consider checking for specific error types (e.g.,net.ErrorwithTemporary()orTimeout()).internal/cmn/config/loader_test.go (1)
724-757: Avoid double viper.Reset in loadWithEnv.loadFromYAML already resets viper, so the extra reset is redundant.
♻️ Suggested simplification
func loadWithEnv(t *testing.T, yamlContent string, env map[string]string) *Config { t.Helper() - viper.Reset() - for k, v := range env { t.Setenv(k, v) } return loadFromYAML(t, yamlContent) }internal/agent/mocks_test.go (1)
13-273: Consider moving these mocks to internal/test fixtures for reuse.If these mocks are shared across multiple test files, centralizing them in internal/test keeps fixtures consistent and avoids future duplication.
Based on learnings: Use
stretchr/testify/requireand shared fixtures frominternal/testinstead of duplicating mocks.internal/cmn/config/loader.go (1)
1155-1308: Consider env bindings for new PathsConfig fields.API keys, webhooks, and conversations dirs are configurable via YAML, but lack env bindings—adding them keeps parity for env-based overrides.
➕ Suggested env bindings
// Paths {key: "paths.dagsDir", env: "DAGS", isPath: true}, {key: "paths.dagsDir", env: "DAGS_DIR", isPath: true}, {key: "paths.executable", env: "EXECUTABLE", isPath: true}, {key: "paths.logDir", env: "LOG_DIR", isPath: true}, {key: "paths.dataDir", env: "DATA_DIR", isPath: true}, {key: "paths.suspendFlagsDir", env: "SUSPEND_FLAGS_DIR", isPath: true}, {key: "paths.adminLogsDir", env: "ADMIN_LOG_DIR", isPath: true}, {key: "paths.baseConfig", env: "BASE_CONFIG", isPath: true}, {key: "paths.dagRunsDir", env: "DAG_RUNS_DIR", isPath: true}, {key: "paths.procDir", env: "PROC_DIR", isPath: true}, {key: "paths.queueDir", env: "QUEUE_DIR", isPath: true}, {key: "paths.serviceRegistryDir", env: "SERVICE_REGISTRY_DIR", isPath: true}, {key: "paths.usersDir", env: "USERS_DIR", isPath: true}, + {key: "paths.apiKeysDir", env: "API_KEYS_DIR", isPath: true}, + {key: "paths.webhooksDir", env: "WEBHOOKS_DIR", isPath: true}, + {key: "paths.conversationsDir", env: "CONVERSATIONS_DIR", isPath: true},
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/cmn/config/loader_test.go (1)
603-620:⚠️ Potential issue | 🟡 MinorAvoid
t.Parallel()here; env/viper resets are global.This subtest calls
loadWithEnv(process env +viper.Reset), which can race with other tests running in parallel. Run it serially or fully isolate viper/env usage.✅ Suggested change
t.Run("BuiltinAuthWithBasicAuthWarning", func(t *testing.T) { - t.Parallel() cfg := loadWithEnv(t, ` auth: mode: builtin builtin: admin:
🤖 Fix all issues with AI agents
In `@internal/agent/api.go`:
- Around line 118-126: The middleware dereferences a.configStore in
enabledMiddleware which can panic if API was constructed without a ConfigStore;
fix by validating the dependency in NewAPI: make NewAPI return an error (or
panic early) when the provided ConfigStore is nil so API instances always have a
non-nil configStore, and keep enabledMiddleware calling a.configStore.IsEnabled
safely; alternatively, if you prefer runtime guarding, update enabledMiddleware
to check if a.configStore == nil and respond with an appropriate error via
respondErrorDirect instead of calling IsEnabled.
In `@internal/agent/loop_test.go`:
- Around line 446-454: The helper runLoopForDuration currently creates a context
with a hardcoded 500ms timeout which can cancel before the sleep if the passed
duration is larger; update runLoopForDuration to use the provided duration for
the context timeout (e.g., context.WithTimeout(context.Background(), duration)
or duration plus a small buffer) so the ctx passed to loop.Go(ctx) matches the
time.Sleep(duration) window and avoid premature cancellation of Loop.Go.
In `@internal/agent/loop.go`:
- Around line 264-271: handleToolCalls currently returns processLLMRequest which
can lead to recursive calls between handleToolCalls and processLLMRequest;
replace this recursion with an iterative loop: repeatedly call processLLMRequest
to obtain tool calls, iterate over toolCalls invoking executeTool(ctx, tc) and
recordToolResult(ctx, tc, result), and continue until processLLMRequest returns
no tool calls or the context is done; enforce a configurable maxIterationLimit
(e.g., constant or field on Loop) to abort with an error if exceeded; ensure you
use the existing symbols handleToolCalls, processLLMRequest, executeTool,
recordToolResult and respect ctx cancellation when breaking the loop.
In `@internal/agent/read_schema.go`:
- Around line 22-50: NewReadSchemaTool currently captures
schema.DefaultRegistry.AvailableSchemas() at tool creation, so
dynamically-registered schemas may be omitted; either document that schemas must
be registered before NewReadSchemaTool is called, or refactor: remove the static
"enum" field from the Parameters map in NewReadSchemaTool (and keep a generic
description), and move runtime validation into readSchemaRun where you call
schema.DefaultRegistry.AvailableSchemas() to check the requested "schema" value
and return a clear error listing currently-available schemas if it's missing;
reference NewReadSchemaTool, the "schema" parameter, and
readSchemaRun/schema.DefaultRegistry.AvailableSchemas() when making the change.
In `@internal/agent/read_test.go`:
- Around line 22-24: The test helper readInput currently builds JSON by
concatenating strings which can break on Windows paths or paths containing
quotes; change readInput to construct a struct (e.g., type payload struct { Path
string `json:"path"` }) and use json.Marshal to produce json.RawMessage, and
when creating offset/limit variants reuse the marshaled payload (or marshal
structs with Offset/Limit fields) instead of manual string concatenation so all
inputs are valid JSON.
In `@internal/agent/read.go`:
- Line 66: The resolvePath call must enforce path traversal protection: update
the resolvePath implementation (used when composing path :=
resolvePath(args.Path, ctx.WorkingDir)) to Clean and resolve absolute/canonical
paths (use filepath.Clean and filepath.Abs or filepath.EvalSymlinks) for both
the candidate path and the workingDir, then verify the candidate is inside
workingDir (e.g., via filepath.Rel and ensuring it does not start with ".." or
by checking that the cleaned absolute candidate has workingDir as a prefix) and
return an error when it escapes; also add unit tests for resolvePath that
include attempts like "../../etc/passwd" and symlink cases to assert traversal
is blocked.
In `@internal/agent/schema/registry.go`:
- Around line 225-240: The resolveRef method on navigator can infinite-recurse
on circular $ref chains; modify resolveRef (and any helper it calls) to detect
cycles by tracking visited definition names (e.g., a visited map or set passed
down the recursion) or enforce a recursion depth limit; when encountering a ref
already in visited, return the node (or a safe placeholder) instead of
recursing. Ensure you update calls that pass/expect the new signature (if you
add a visited parameter) and use n.defs and the extracted defName as the unique
key for cycle detection.
In `@internal/agent/subpub.go`:
- Around line 19-41: Both isDone() and send() close s.ch and can double-close
it; add a sync.Once field (e.g., closeOnce) to the subscriber[K] struct and
replace any direct close(s.ch) calls in subscriber.isDone() and
subscriber.send() with s.closeOnce.Do(func(){ close(s.ch) }), leaving s.cancel()
calls as-is in send(); this ensures the channel is closed only once and prevents
panics while retaining the same cancellation behavior.
In `@internal/agent/system_prompt.txt`:
- Around line 94-99: The prompt's list has minor wording/style issues
(hyphenation and repetitive phrasing); edit the block that starts with "Navigate
DAG YAML schema documentation. Call with:" so each bullet is concise and
consistently formatted—e.g., use consistent punctuation, remove redundant words
like "Required" repeated inline (move to a single note if needed), and hyphenate
or not across items uniformly; ensure the entries referencing schema: "dag",
path: "", path: "steps", path: "steps.container", and path: "handlerOn" remain
unchanged functionally but are presented crisply and consistently.
In `@internal/agent/think_test.go`:
- Around line 10-46: In TestThinkTool_Run, replace the critical check using
assert with require: change assert.False(t, result.IsError) to require.False(t,
result.IsError) so the test stops immediately on failure; update the imports to
include "github.com/stretchr/testify/require" (and keep the existing assert
import if you still use assert.Contains) and ensure this change is applied
inside the t.Run subtest where tool.Run(ToolContext{},
json.RawMessage(tc.input)) is called.
In `@internal/cmd/dry.go`:
- Around line 113-125: The current logic uses ctx.Command.ArgsLenAtDash() and
later calls spec.Load(ctx, args[0], ...) which breaks if the argument separator
(“--”) appears before the DAG path (ArgsLenAtDash() == 0). Add an explicit guard
after computing argsLenAtDash: if argsLenAtDash == 0 return a clear error (e.g.,
"invalid command: '--' may not appear before DAG path") so we never treat a
parameter as the DAG file; keep the existing branches that append
spec.WithParams(...) and then call spec.Load as before.
In `@internal/cmn/config/definition.go`:
- Around line 253-259: PostgresPoolDef duplicates PostgresPoolConfig;
consolidate by using a single struct for both unmarshalling and internal config.
Replace one definition with a shared type (e.g., keep PostgresPoolDef or
PostgresPoolConfig) and update all references to the removed symbol
(PostgresPoolDef and PostgresPoolConfig) so code and mapstructure tags remain;
ensure the retained struct keeps the mapstructure tags (MaxOpenConns,
MaxIdleConns, ConnMaxLifetime, ConnMaxIdleTime) and update imports/usages in
code that referenced the other name.
🧹 Nitpick comments (31)
internal/cmd/dry_test.go (1)
36-40: Minor inconsistency: missing closing bracket in expected output.Line 39 expects
[1=p5 2=p6while line 34 expects[1=p3 2=p4]with the closing bracket. While both work for substring matching, the inconsistency reduces readability.Suggested fix
{ Name: "DryRunDAGWithParamsAfterDash", Args: []string{"dry", dagWithParams.Location, "--", "p5", "p6"}, - ExpectedOut: []string{`[1=p5 2=p6`}, + ExpectedOut: []string{`[1=p5 2=p6]`}, },internal/cmn/fileutil/fileutil.go (1)
255-264: Consider fsync for crash-safe durability.The current implementation does not call
fsyncon the temp file before renaming. On some filesystems (especially ext3/ext4 withoutbarrier=1), the data may not be persisted to disk before the rename completes. If the system crashes, the file could be empty or contain partial data.For truly atomic and durable writes, call
Sync()on the file handle before closing/renaming.♻️ Add fsync for durability (if crash safety is required)
func WriteFileAtomic(filePath string, data []byte, perm os.FileMode) error { - tempPath := filePath + ".tmp" - if err := os.WriteFile(tempPath, data, perm); err != nil { - return fmt.Errorf("failed to write temp file %s: %w", tempPath, err) + dir := filepath.Dir(filePath) + f, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := f.Name() + defer func() { _ = os.Remove(tempPath) }() // cleanup on any failure path + + if _, err := f.Write(data); err != nil { + _ = f.Close() + return fmt.Errorf("failed to write temp file %s: %w", tempPath, err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + return fmt.Errorf("failed to sync temp file %s: %w", tempPath, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("failed to close temp file %s: %w", tempPath, err) + } + if err := os.Chmod(tempPath, perm); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", tempPath, err) } if err := os.Rename(tempPath, filePath); err != nil { - _ = os.Remove(tempPath) return fmt.Errorf("failed to rename %s to %s: %w", tempPath, filePath, err) } + // Remove the deferred cleanup since rename succeeded return nil }Note: This is a nice-to-have improvement. If the use case doesn't require crash safety (e.g., configuration files that can be regenerated), the current implementation is acceptable.
internal/cmn/fileutil/atomic_test.go (1)
55-66: Permission test may be flaky on Windows or with umask interference.On Unix systems, the actual file permissions depend on the process umask. On Windows, file permissions work differently. This test may pass locally but fail in certain CI environments.
The codebase already applies this pattern in
internal/runtime/builtin/docker/keepalive_test.go(lines 156–157), where permission assertions are guarded withif runtime.GOOS != "windows". Consider adopting the same approach here to improve test reliability across platforms.♻️ Consider adding Windows skip
t.Run("sets correct permissions", func(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("file permissions work differently on Windows") + } dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") err := WriteFileAtomic(filePath, []byte("data"), 0644) require.NoError(t, err) info, err := os.Stat(filePath) require.NoError(t, err) assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) })Note: You will also need to add
"runtime"to the imports at the top of the file.internal/core/dag.go (1)
398-401: Non-deterministic iteration order when appending env vars.The
for k, v := range varsloop at line 398 iterates over a map, which has non-deterministic order in Go. While this doesn't affect functionality (environment variables are key-value pairs without ordering requirements), it may cause non-deterministicd.Envslice order across runs.This is unlikely to cause issues in practice since env vars are typically accessed by key, not position.
Makefile (1)
119-123: AllowDAGU_DEBUGto be overridden.Hard-coding debug can prevent quiet runs; consider defaulting to
1but allowing callers to override.🔧 Suggested tweak
- `@DAGU_DEBUG`=1 go run ./cmd start-all + `@DAGU_DEBUG`=$${DAGU_DEBUG:-1} go run ./cmd start-allinternal/cmd/flags.go (2)
319-325: Potential slice mutation when appending tobaseFlags.Using
append(baseFlags, additionalFlags...)directly may mutate the backing array ofbaseFlagsif its capacity exceeds length. This could cause subtle bugs ifinitFlagsis called concurrently or multiple times with different flags.♻️ Proposed fix to prevent slice mutation
func initFlags(cmd *cobra.Command, additionalFlags ...commandLineFlag) { - allFlags := append(baseFlags, additionalFlags...) + allFlags := make([]commandLineFlag, 0, len(baseFlags)+len(additionalFlags)) + allFlags = append(allFlags, baseFlags...) + allFlags = append(allFlags, additionalFlags...) for _, flag := range allFlags { registerFlag(cmd, flag) } }
340-355: Same slice append pattern inbindFlags.Similar to
initFlags, this function should also avoid potential mutation of shared slices.♻️ Proposed fix
func bindFlags(v *viper.Viper, cmd *cobra.Command, additionalFlags ...commandLineFlag) { - allFlags := append([]commandLineFlag{configFlag}, additionalFlags...) + allFlags := make([]commandLineFlag, 0, 1+len(additionalFlags)) + allFlags = append(allFlags, configFlag) + allFlags = append(allFlags, additionalFlags...) for _, flag := range allFlags {internal/agent/subpub_test.go (1)
339-356: Unusedcancelparameter incollectMessageshelper.The
cancelparameter is passed but never used within the function. It can be removed to simplify the signature.🔧 Proposed fix
// collectMessages starts a goroutine that collects all messages from the next function // until the context is canceled. Returns a channel that receives the collected slice. -func collectMessages[K any](ctx context.Context, cancel context.CancelFunc, next func() (K, bool)) <-chan []K { +func collectMessages[K any](next func() (K, bool)) <-chan []K { result := make(chan []K, 1) go func() { var msgs []K for { msg, ok := next() if !ok { result <- msgs close(result) return } msgs = append(msgs, msg) } }() return result }Update call sites accordingly (Lines 72, 92).
internal/cmn/config/config.go (1)
417-436: Consider extracting role names as constants.The valid role names ("admin", "manager", "operator", "viewer") are hardcoded in the switch statement. Extracting these as package-level constants would improve maintainability and allow reuse in other validation contexts.
♻️ Proposed refactor
// At package level: const ( RoleAdmin = "admin" RoleManager = "manager" RoleOperator = "operator" RoleViewer = "viewer" ) var ValidRoles = []string{RoleAdmin, RoleManager, RoleOperator, RoleViewer}Then use
slices.Contains(ValidRoles, oidc.RoleMapping.DefaultRole)in validation.internal/agent/bash_test.go (1)
17-122: Preferrequirefor gating assertions in tests.Several checks are prerequisites for later assertions; using
requireavoids follow‑on noise when a precondition fails and aligns with test guidelines.internal/agent/schema/output_test.go (1)
9-68: Considerrequirefor error gating.Using
require.NoError/require.Errorwill align with test guidelines and reduce manual error branching.internal/agent/navigate_test.go (1)
10-80: Preferrequirefor precondition checks.For example,
require.False(t, result.IsError)before content checks keeps failures clean and matches test guidelines.internal/agent/schema/registry_test.go (2)
543-559: Capture loop vars in parallel subtests (Go <1.22).
Line 543 runst.Parallel()inside a range loop; on Go <1.22 this can capture the last value. Same pattern appears in other table-driven tests in this file.♻️ Suggested fix
- for _, tt := range tests { + for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel()
1002-1039: Avoid potential deadlock if many errors are emitted.
Line 1003 sizeserrChtonumGoroutines*numOps, butRegister+Navigatecan emit up to 2× that. If failures happen, goroutines can block.♻️ Suggested fix
- errCh := make(chan error, numGoroutines*numOps) + errCh := make(chan error, numGoroutines*numOps*2)internal/agent/navigate.go (1)
47-65: Consider validating the navigation path.
Line 53 accepts any string while the description enumerates supported routes. A minimal guard (absolute UI path, no scheme) avoids unexpected navigation.♻️ Suggested guard
- if args.Path == "" { + path := strings.TrimSpace(args.Path) + if path == "" { return toolError("Path is required") } + + if !strings.HasPrefix(path, "/") || strings.Contains(path, "://") { + return toolError("Path must be an absolute UI path starting with '/'") + } ... - Path: args.Path, + Path: path, }) } - return ToolOut{Content: fmt.Sprintf("Navigating user to %s", args.Path)} + return ToolOut{Content: fmt.Sprintf("Navigating user to %s", path)}📦 Import update
import ( "encoding/json" "fmt" + "strings" "github.com/dagu-org/dagu/internal/llm" )internal/agent/schema/registry.go (1)
35-53: Consider holding the read lock during navigation.The
Navigatemethod releases the read lock immediately after retrieving the schema from the map. If the schema map is modified concurrently (e.g., another goroutine callsRegisterwith the same name), the navigator could be working with stale or inconsistent data, though the current map semantics make this unlikely to cause corruption.For stronger consistency guarantees, consider holding the lock for the duration of navigation or documenting that schemas should not be modified after initial registration.
internal/agent/loop_test.go (2)
395-413: Avoid hardcoding the expected tool count.The assertion
assert.Len(t, tools, 8)will break whenever tools are added or removed. Consider asserting on minimum expected tools or checking for specific tool names instead.♻️ Suggested fix
func TestLoop_BuildToolDefinitions(t *testing.T) { t.Parallel() t.Run("converts agent tools to LLM tools", func(t *testing.T) { t.Parallel() loop := NewLoop(LoopConfig{ Provider: &mockLLMProvider{}, Tools: CreateTools(""), }) tools := loop.buildToolDefinitions() - assert.Len(t, tools, 8) + assert.NotEmpty(t, tools, "expected at least one tool") for _, tool := range tools { assert.Equal(t, "function", tool.Type) assert.NotEmpty(t, tool.Function.Name) } }) }
41-56: Testing internal state via direct mutex access.Accessing
loop.muandloop.messageQueuedirectly couples tests to internal implementation details. If the internal structure changes, these tests will break even if the public API behavior is correct.Consider exposing a method like
PendingMessageCount()for testing, or accept this as a pragmatic trade-off for test coverage.internal/agent/read.go (2)
104-117: Clarify offset=0 behavior in documentation or validation.The conversion
start := max(0, requestedOffset-1)means bothoffset=0andoffset=1result in reading from line 1. This may confuse users expecting 0-based indexing. The description says "1-based" but doesn't clarify what happens with offset=0.Consider either:
- Treating offset=0 as invalid (since it's documented as 1-based)
- Explicitly documenting that offset ≤ 1 starts from the beginning
📝 Option: Validate that offset is 1-based
func formatFileContent(content string, requestedOffset, requestedLimit int) ToolOut { lines := strings.Split(content, "\n") total := len(lines) + if requestedOffset < 0 { + return toolError("Offset must be positive (1-based line number)") + } + // Convert 1-based offset to 0-based index start := max(0, requestedOffset-1)
72-77: Entire file is read into memory before applying offset/limit.For files approaching
maxReadSize(1MB), the entire content is loaded into memory even if only a small portion is requested via offset/limit. For the current 1MB limit, this is acceptable, but be aware this could become a concern if the limit increases.internal/agent/api_test.go (2)
52-60: Handle potential JSON marshal errors in test helper.
postJSONignores the error fromjson.Marshal. While unlikely to fail with test data, errors would cause confusing test failures.♻️ Suggested fix
// postJSON sends a POST request with JSON body and returns the recorder. -func (s *apiTestSetup) postJSON(path string, body any) *httptest.ResponseRecorder { - data, _ := json.Marshal(body) +func (s *apiTestSetup) postJSON(t *testing.T, path string, body any) *httptest.ResponseRecorder { + t.Helper() + data, err := json.Marshal(body) + require.NoError(t, err, "failed to marshal request body") req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() s.router.ServeHTTP(rec, req) return rec }Note: This would require updating all call sites to pass
t.
413-436: SSE streaming test uses time.Sleep which can be flaky.The test relies on
time.Sleep(50 * time.Millisecond)to allow the SSE handler to set headers before cancellation. This could be flaky under load. Consider using a synchronization mechanism if this becomes unreliable.internal/agent/mocks_test.go (1)
117-122: Private helperrequireConversationassumes caller holds the lock.
requireConversationaccessesm.conversationswithout acquiring the mutex. This is correct because all callers acquire the lock first, but it's fragile. Consider adding a comment documenting this precondition.📝 Suggested documentation
+// requireConversation checks if a conversation exists. +// REQUIRES: m.mu must be held by the caller. func (m *mockConversationStore) requireConversation(id string) error { if _, exists := m.conversations[id]; !exists { return ErrConversationNotFound } return nil }internal/agent/patch.go (1)
108-121:patchCreateoverwrites existing files without warning.The function creates or overwrites files silently. Consider checking if the file exists and either warning the user or requiring explicit confirmation for overwrites, which could prevent accidental data loss.
internal/agent/web_search.go (2)
107-134: Scraping DuckDuckGo HTML is fragile and may break without warning.This implementation relies on DuckDuckGo's HTML structure (class names like
result,result__a,result__snippet). If DuckDuckGo changes their markup, the tool will silently return no results.Consider adding observability (logging when zero results are parsed from non-empty HTML) to detect breakage, or document this fragility for maintainers.
112-118: Retry condition may cause excessive retries on non-transient errors.The retry condition returns
truefor any error, including non-transient errors like DNS failures or invalid URLs. Consider being more selective:♻️ Suggested improvement
AddRetryCondition(func(r *resty.Response, err error) bool { if err != nil { - return true + // Only retry on timeout-related errors + return r == nil // Connection failed, worth retrying } code := r.StatusCode() return code == 429 || code >= 500 })internal/agent/bash.go (1)
71-74: Consider using absolute path for bash executable.Using
"bash"relies on PATH resolution. For consistency and to avoid potential PATH manipulation in edge cases, consider using/bin/bashor/usr/bin/bash.♻️ Suggested change
- cmd := exec.CommandContext(ctx, "bash", "-c", args.Command) + cmd := exec.CommandContext(ctx, "/bin/bash", "-c", args.Command)internal/agent/conversation.go (2)
459-483:createWaitUserResponseFunchas proper cleanup but could leak if context is never cancelled.The defer cleanup ensures the pending prompt is removed, but if the context is never cancelled and no response arrives, the goroutine will block indefinitely. Consider adding a timeout or documenting this behavior.
496-501:SubmitUserResponsesilently fails if channel is full.The non-blocking send with
defaultcase returnsfalseif the channel already has a pending message. Since the channel has buffer size 1, this could happen if a response is submitted twice for the same prompt.This is likely acceptable behavior (idempotent rejection of duplicate responses), but consider logging a warning for debuggability:
♻️ Suggested improvement
select { case ch <- response: return true default: + // Channel already has a response or was closed return false }internal/cmn/config/loader.go (2)
135-146:parseDurationsilently defaults to zero on invalid input.Returning 0 and adding a warning may mask configuration errors. Depending on the field, a zero duration could cause unexpected behavior (e.g., no retry interval). Consider whether some duration fields should fail-fast instead.
1047-1058: Legacy environment mappings for path sections useSectionNone.Lines 1047, 1052, 1057 use
requires: SectionNonewhich means these will be skipped when the section check runs (sinceSectionNone = 0andreq & 0 = 0). This appears intentional to always apply these path mappings, but the logic at lines 1062-1064 checksmapping.requires != SectionNoneto skip therequirescheck entirely forSectionNoneentries.This is correct but subtle - a brief comment might help future maintainers.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1615 +/- ##
==========================================
+ Coverage 67.14% 69.59% +2.45%
==========================================
Files 293 312 +19
Lines 33704 35844 +2140
==========================================
+ Hits 22629 24945 +2316
- Misses 8753 8901 +148
+ Partials 2322 1998 -324
... and 24 files with indirect coverage changes Continue to review full report in Codecov by Sentry.
🚀 New features to boost your workflow:
|
Summary by CodeRabbit
New Features
Chores
✏️ Tip: You can customize this high-level summary in your review settings.