Skip to content

feat: add DAG run artifacts support#1998

Merged
yohamta0 merged 14 commits into
mainfrom
feat/dag-run-artifacts
Apr 12, 2026
Merged

feat: add DAG run artifacts support#1998
yohamta0 merged 14 commits into
mainfrom
feat/dag-run-artifacts

Conversation

@yohamta0

@yohamta0 yohamta0 commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add DAG run artifact support across config, persistence, gRPC transport, and distributed shared-nothing execution
  • add artifact tree, preview, and download support in the frontend API and UI with safe path handling and per-type preview limits
  • harden distributed artifact finalization and retry behavior, including failed-run uploads, empty artifact directories, and stale-attempt rejection with unit and integration coverage

Testing

  • go test ./internal/runtime/agent ./internal/service/worker ./internal/service/coordinator ./internal/runtime/remote ./internal/service/frontend/api/v1 -run 'Artifact|TestTransformArtifactPathsCreatesDirectory|TestArtifactHandlerHandleStream|TestExecuteDAGRun_(FailedExecutionStillUploadsArtifacts|ArtifactUploadFailureMarksRunFailed|FailedExecutionWithArtifactUploadFailurePreservesFailedStatus)' -count=1
  • go test ./internal/intg/distr -run TestExecution_Artifacts -count=1

Summary by CodeRabbit

New Features

  • Artifact Management: Added artifact storage and retrieval for DAG runs with configurable directory paths and per-DAG enablement.
  • Artifact Browsing API: Six new endpoints for listing, previewing, and downloading artifacts from root and sub-DAG runs.
  • UI Artifacts Tab: New artifacts viewer in DAG run details displaying directory trees, file previews (text, Markdown, images), and download capabilities.
  • Configuration Support: New artifact_dir path setting and per-DAG artifact configuration with optional custom directories.

@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 97851b5b-48d7-4cf4-8b5a-06cfb819471a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces end-to-end artifact management capabilities. It adds REST API endpoints and gRPC streaming for uploading, browsing, previewing, and downloading DAG run artifacts, along with configuration support, safe path handling, artifact directory lifecycle management, and UI components for artifact viewing.

Changes

Cohort / File(s) Summary
API Specification
api/v1/api.yaml
Added 6 new artifact management endpoints for both root and sub-DAG runs (list/preview/download). Introduced ArtifactPath, ArtifactRecursive parameters and artifact-related schemas (ArtifactTreeNode, ArtifactTreeResponse, ArtifactPreviewResponse, DAGArtifactsConfig). Extended DAGDetails and DAGRunDetails with artifact fields.
Configuration - Paths
internal/cmn/config/config.go, internal/cmn/config/definition.go, internal/cmn/config/path.go, internal/cmn/config/loader.go, internal/cmn/config/key_hints.go, internal/cmn/schema/config.schema.json
Added ArtifactDir field to path configuration across all layers (struct, definition, loader, schema). Configured defaults to <DataDir>/artifacts with XDG/unified path variants. Added environment variable support and legacy key migration hints.
File Safety Utilities
internal/cmn/fileutil/safe_path.go, internal/cmn/fileutil/safe_path_test.go
Introduced path containment validation via ResolvePathWithinBase and ResolveExistingPathWithinBase with symlink detection (IsSymlinkDirEntry). Prevents directory traversal attacks with ErrPathEscapesBase sentinel error.
Log Path Generation
internal/cmn/logpath/logpath.go
Refactored to support artifact directory generation via new GenerateDir helper. Renamed internal directory method to RunDir() with backward-compatible LogDir() wrapper.
DAG Schema & Spec
internal/core/dag.go, internal/core/spec/dag.go, internal/cmn/schema/dag.schema.json
Added Artifacts *ArtifactsConfig field to DAG with Enabled and Dir properties. Extended DAG spec transformer pipeline. Added JSON schema definitions.
Execution Context
internal/core/exec/context.go, internal/core/exec/env.go, internal/core/exec/runstatus.go
Added EnvKeyDAGRunArtifactsDir environment variable, WithArtifactDir context option, and ArchiveDir field to DAGRunStatus.
DAG Execution - Startup
internal/cmd/context.go, internal/cmd/start.go, internal/cmd/enqueue.go
Added GenArtifactDir helper to compute artifact paths per DAG run. Wired artifact directories into agent options and status building in startup/enqueue flows.
DAG Execution - Advanced Paths
internal/cmd/restart.go, internal/cmd/retry.go, internal/cmd/local_execution.go, internal/cmd/coord.go
Integrated artifact directory generation and injection into agent configuration for restart, retry, and local execution flows. Updated handler configuration for coordinator.
Persistence Layer - Store
internal/persis/filedagrun/store.go, internal/persis/filedagrun/dagrun.go, internal/persis/filedagrun/dataroot.go
Added ArtifactDir configuration, cleanup, and validation. Extended DAG run store with artifact directory awareness for removal/cleanup operations. Implemented safe artifact directory validation before deletion.
Persistence Layer - Tests
internal/persis/filedagrun/setup_test.go, internal/persis/filedagrun/dataroot_test.go, internal/persis/filedagrun/store_test.go
Added artifact directory cleanup tests, removal validation, and fixture setup updates.
Coordinator Service - Artifact Handling
internal/service/coordinator/artifact_handler.go, internal/service/coordinator/artifact_handler_test.go, internal/service/coordinator/handler.go, internal/service/coordinator/handler_test.go, internal/service/coordinator/client.go
Implemented StreamArtifacts gRPC handler with chunk buffering, attempt validation, and safe path resolution. Added coordinator client methods for artifact streaming. Integrated artifact path transformation in status reporting.
Worker - Remote Artifact Upload
internal/runtime/remote/artifact_uploader.go, internal/runtime/remote/artifact_uploader_test.go, internal/service/worker/remote_handler.go, internal/service/worker/remote_handler_test.go
Created artifact uploader with lazy stream initialization, chunk sequencing, and finalization support. Integrated into remote execution flow with attempt ID tracking and error handling.
Agent Runtime
internal/runtime/agent/agent.go, internal/runtime/context.go, internal/runtime/transform/status.go
Added artifact directory creation, finalization with timeout, and delayed terminal status for artifact operations. Integrated WithArchiveDir status option.
Frontend API - Artifact Endpoints
internal/service/frontend/api/v1/dagruns.go, internal/service/frontend/api/v1/dagruns_artifacts_internal_test.go, internal/service/frontend/api/v1/transformer.go
Implemented 6 REST endpoints for artifact browsing/preview/download with safe path resolution, MIME detection, size-aware preview generation, and recursive tree listing.
Scheduler
internal/service/scheduler/enqueue.go, internal/service/scheduler/scheduler.go, internal/service/scheduler/ha_health_test.go
Extended catchup enqueue with artifact directory computation via GenerateDir.
Proto Definitions
proto/coordinator/v1/coordinator.proto
Added StreamArtifacts RPC accepting ArtifactChunk stream (with worker ID, DAG context, sequence, finality markers) returning StreamArtifactsResponse.
Frontend Schema & UI
ui/src/api/v1/schema.ts
Generated TypeScript schema with artifact endpoints and types. Removed agent skills API definitions.
Frontend Components
ui/src/features/dags/components/DAGStatus.tsx, ui/src/features/dags/components/artifacts/ArtifactsTab.tsx, ui/src/features/dags/components/dag-details/DAGDetailsContent.tsx
Added artifacts tab to DAG status view with tree listing, file preview (Markdown/text/image rendering), and download functionality.
Integration & Regression Tests
internal/intg/distr/execution_test.go, internal/intg/distr/fixtures_test.go, internal/intg/queue/fixture_test.go, internal/cmd/artifacts_regression_test.go, internal/core/exec/context_test.go, internal/core/spec/dag_test.go, internal/core/spec/loader_test.go, internal/cmn/config/loader_test.go, internal/cmn/config/path_test.go
Added comprehensive integration tests for artifact persistence, cleanup, upload validation, and configuration loading. Updated fixtures and test helpers with artifact support.
Test Infrastructure
internal/test/helper.go, internal/test/coordinator.go, internal/test/scheduler.go, internal/service/frontend/api/v1/workers_internal_test.go, internal/service/worker/poller_test.go, internal/service/scheduler/enqueue_test.go, internal/cmd/retry_test.go, ui/src/features/dags/components/dag-details/__tests__/*
Extended test helpers and mocks with artifact persistence configuration, stream client mocks, and updated test fixtures.

Sequence Diagram(s)

sequenceDiagram
    participant Worker as Worker/Agent
    participant Coordinator as Coordinator Service
    participant Storage as File System
    
    Worker->>Worker: Generate artifact directory
    Worker->>Worker: Create artifact files during execution
    Worker->>Coordinator: CreateStream (StreamArtifacts RPC)
    activate Coordinator
    
    loop For each artifact file
        Worker->>Worker: Read file chunks
        Worker->>Coordinator: Send ArtifactChunk (data + metadata)
        Coordinator->>Coordinator: Get/create buffered writer
        Coordinator->>Storage: Write chunk bytes
        Storage-->>Coordinator: Ack
    end
    
    Worker->>Coordinator: Send final ArtifactChunk (IsFinal: true)
    Coordinator->>Coordinator: Close writer, validate attempt
    Coordinator->>Storage: Flush/finalize file
    Coordinator-->>Worker: StreamArtifactsResponse (chunks, bytes, error)
    deactivate Coordinator
    Worker->>Worker: Finalize artifacts (error handling)
    Worker->>Worker: Update run status with ArchiveDir
Loading
sequenceDiagram
    participant Frontend as Frontend/Browser
    participant API as REST API Service
    participant SafePath as Path Validator
    participant Storage as File System
    
    Frontend->>API: GET /dag-runs/{id}/artifacts?recursive=true
    activate API
    API->>API: Load DAG run status
    API->>API: Extract ArchiveDir
    API->>API: Walk directory tree recursively
    API->>SafePath: Validate each path
    SafePath->>SafePath: Check containment, resolve symlinks
    SafePath-->>API: Validated paths
    API->>Storage: Read directory metadata
    API-->>Frontend: ArtifactTreeResponse (nodes with size/type)
    deactivate API
    
    Frontend->>API: GET /dag-runs/{id}/artifacts/preview?path=file.md
    activate API
    API->>SafePath: ResolveExistingPathWithinBase
    SafePath->>Storage: EvalSymlinks to canonicalize
    SafePath-->>API: Safe resolved path
    API->>Storage: Read sniff buffer (detect MIME)
    API->>API: Generate preview (Markdown/text/image kind)
    API->>API: Apply size limits, truncate if needed
    API-->>Frontend: ArtifactPreviewResponse (kind, content, flags)
    deactivate API
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~110 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title "feat: add DAG run artifacts support" accurately describes the main objective of the changeset, which is to implement comprehensive DAG run artifact functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dag-run-artifacts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/runtime/agent/agent.go (1)

800-805: ⚠️ Potential issue | 🟠 Major

The 100ms "running" write can still publish a terminal status early.

This goroutine samples a.Status(ctx) after the sleep, and a.finished is not set until much later. For a fast DAG, that means it can write Succeeded/Failed before artifact finalization finishes, bypassing shouldDelayTerminalStatus.

Proposed fix
 	go execWithRecovery(ctx, func() {
 		time.Sleep(waitForRunning)
 		if a.finished.Load() {
 			return
 		}
-		a.writeStatus(ctx, attempt, a.Status(ctx))
+		status := a.Status(ctx)
+		if a.shouldDelayTerminalStatus(status.Status) {
+			return
+		}
+		a.writeStatus(ctx, attempt, status)
 	})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runtime/agent/agent.go` around lines 800 - 805, This goroutine can
write a terminal status too early; change the logic inside execWithRecovery so
after the sleep you 1) obtain status via a.Status(ctx), 2) re-check
a.finished.Load(), and 3) consult a.shouldDelayTerminalStatus(attempt, status)
and skip calling a.writeStatus(ctx, attempt, status) when that returns true
(i.e., only write if not finished and not delayed); use the existing symbols
execWithRecovery, a.finished, a.Status, a.shouldDelayTerminalStatus, and
a.writeStatus to locate and modify the code.
🧹 Nitpick comments (10)
internal/cmd/local_execution.go (1)

109-115: Consider logging errors from path generation in failure recording.

Both GenLogFileName (line 109) and GenArtifactDir (line 110) silently discard errors. While this function is already handling a failure scenario where we want to record the original error, silently ignoring these errors could mask issues with path generation or directory creation.

Per context snippet 1, GenArtifactDir calls logpath.GenerateDir which creates directories via os.MkdirAll. If this fails, the empty artifactDir will still be passed to WithArchiveDir, which is safe but may leave the status without artifact directory info.

The current approach follows the existing pattern for logPath, so this is likely intentional to ensure the failure is always recorded even if path generation fails.

💡 Optional: Log path generation errors
-	logPath, _ := ctx.GenLogFileName(dag, dagRunID)
-	artifactDir, _ := ctx.GenArtifactDir(dag, dagRunID)
+	logPath, logErr := ctx.GenLogFileName(dag, dagRunID)
+	if logErr != nil {
+		logger.Debug(ctx, "Failed to generate log path during failure recording", tag.Error(logErr))
+	}
+	artifactDir, artifactErr := ctx.GenArtifactDir(dag, dagRunID)
+	if artifactErr != nil {
+		logger.Debug(ctx, "Failed to generate artifact dir during failure recording", tag.Error(artifactErr))
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/local_execution.go` around lines 109 - 115, GenLogFileName and
GenArtifactDir are currently called with their errors discarded; capture their
returned errors, log them (including error details and context: dag, dagRunID)
before building the opts slice, and still pass the paths (possibly empty) to
transform.WithLogFilePath and transform.WithArchiveDir so the failure-recording
flow continues; specifically update the call sites around
ctx.GenLogFileName(dag, dagRunID) and ctx.GenArtifactDir(dag, dagRunID) to check
the err, log via the existing logger (or ctx logger) with clear messages, and
then construct opts with transform.WithLogFilePath(logPath) and
transform.WithArchiveDir(artifactDir) as before.
internal/service/scheduler/enqueue_test.go (1)

39-39: Add assertions for persisted artifact/archive metadata in these tests.

Both updated call sites pass ArtifactDir, but neither test currently verifies that this value is persisted into status metadata. Adding that assertion would validate the new argument behavior directly.

Suggested test assertions
@@
 	require.Equal(t, stringutil.FormatTime(scheduleTime), status.ScheduleTime)
 	require.NotEmpty(t, status.Log)
 	assert.Contains(t, status.Log, filepath.Join(th.Config.Paths.LogDir, dag.Name))
+	require.NotEmpty(t, status.ArchiveDir)
+	assert.Contains(t, status.ArchiveDir, filepath.Join(th.Config.Paths.ArtifactDir, dag.Name))
@@
 	attempt, err := th.DAGRunStore.FindAttempt(th.Context, exec.NewDAGRunRef(dag.Name, runID))
 	require.NoError(t, err)
+	status, err := attempt.ReadStatus(th.Context)
+	require.NoError(t, err)
+	require.NotEmpty(t, status.ArchiveDir)
+	assert.Contains(t, status.ArchiveDir, filepath.Join(th.Config.Paths.ArtifactDir, dag.Name))

Also applies to: 103-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/scheduler/enqueue_test.go` at line 39, Two tests call the
function with th.Config.Paths.ArtifactDir but never assert it was persisted;
update the tests that pass th.Config.Paths.ArtifactDir to assert that the
job/archive status metadata contains that artifact path. Locate the assertions
after the enqueue/creation call (look for uses of th.Config.Paths.ArtifactDir in
enqueue_test.go and the variables holding returned status or archive objects,
e.g., status, job, or archive) and add an assertion that status.Metadata (or
archive.Status.Metadata) includes the artifact dir value (compare to
th.Config.Paths.ArtifactDir) and/or the specific metadata key used by the
implementation (e.g., "artifact_dir" or "artifactDir"). Ensure you add the same
assertion in both call sites (the one at the earlier location and the one
referenced as also applies to 103-103).
internal/cmd/context.go (1)

947-950: Don’t silently drop GenArtifactDir errors in early-failure recording.

Line 947 ignores generation errors, which can hide path/config problems and silently omit archiveDir metadata. Keep status write non-blocking, but log the error.

Proposed patch
-	artifactDir, _ := c.GenArtifactDir(dag, dagRunID)
+	artifactDir, artifactErr := c.GenArtifactDir(dag, dagRunID)
+	if artifactErr != nil {
+		logger.Warn(c, "failed to generate artifact directory for early failure status", tag.Error(artifactErr))
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/context.go` around lines 947 - 950, GenArtifactDir is being
called with its error ignored; change the call to capture the error (e.g.,
artifactDir, err := c.GenArtifactDir(dag, dagRunID)), and if err != nil log the
error (using the existing logger in this package) so path/config issues are
visible, then continue to call statusBuilder.Create(dagRunID, core.Failed, 0,
time.Now(), transform.WithLogFilePath(logPath),
transform.WithArchiveDir(artifactDir)) without turning the status write into a
blocking failure; ensure you reference the artifactDir and err variables and use
the same log facility used elsewhere in this file to emit the error.
internal/service/coordinator/handler_test.go (1)

327-352: Add a sanitization-focused test case for DAG names.

This case only verifies a safe name (test-dag). Consider adding a DAG name with separators/traversal tokens and asserting incoming.ArchiveDir remains under baseDir after transformation (matching fileutil.SafeName behavior in internal/service/coordinator/handler.go Line 1493).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/coordinator/handler_test.go` around lines 327 - 352, Add a
new test variant of TestTransformArtifactPathsCreatesDirectory that uses a DAG
name containing separators/traversal tokens (e.g., "../weird/..-dag--name") to
assert transformArtifactPaths sanitizes the DAG name; call
handler.transformArtifactPaths (same as the existing test) and compute the
expected sanitized directory using fileutil.SafeName on the DAG name, then
assert incoming.ArchiveDir is under baseDir and equals filepath.Join(baseDir,
fileutil.SafeName(dag.Name), <archive-suffix-from-incoming>) and that the
directory exists; reuse mockDAGRunAttempt, Handler, and exec.DAGRunStatus from
the current test setup to locate the change near transformArtifactPaths and
fileutil.SafeName usage.
internal/cmn/fileutil/safe_path_test.go (1)

30-60: Skip the symlink cases when the platform can't create symlinks.

Line 39 and Line 54 can fail on Windows or other restricted environments even when the implementation is correct, so these tests should gate on symlink support rather than assuming os.Symlink is always available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmn/fileutil/safe_path_test.go` around lines 30 - 60, Modify the two
tests TestResolveExistingPathWithinBaseRejectsSymlinkEscape and
TestResolveExistingPathWithinBaseAllowsSafeSymlink to skip rather than fail when
the platform cannot create symlinks: after the os.Symlink call (or immediately
before attempting it) check the returned error and call t.Skipf("skipping
symlink test: %v", err) when symlink creation fails (e.g., permission denied or
not supported), so tests only assert behavior when os.Symlink succeeds.
internal/service/scheduler/enqueue.go (1)

73-83: Minor: Redundant nil check on dagCopy.Artifacts.

The check at line 76 (if dagCopy.Artifacts != nil) is redundant because ArtifactsEnabled() already verifies that d.Artifacts != nil before returning true. This block is only entered when ArtifactsEnabled() returns true.

♻️ Suggested simplification
 	artifactDir := ""
 	if dagCopy.ArtifactsEnabled() {
-		dagArtifactDir := ""
-		if dagCopy.Artifacts != nil {
-			dagArtifactDir = dagCopy.Artifacts.Dir
-		}
+		dagArtifactDir := dagCopy.Artifacts.Dir
 		artifactDir, err = logpath.GenerateDir(ctx, baseArtifactDir, dagArtifactDir, dagCopy.Name, runID)
 		if err != nil {
 			return fmt.Errorf("failed to generate catchup artifact directory: %w", err)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/scheduler/enqueue.go` around lines 73 - 83, The nil check on
dagCopy.Artifacts is redundant because dagCopy.ArtifactsEnabled() already
guarantees Artifacts != nil; remove the inner "if dagCopy.Artifacts != nil"
block and directly read dagCopy.Artifacts.Dir into dagArtifactDir before calling
logpath.GenerateDir so artifactDir is computed the same way (preserve
artifactDir, dagArtifactDir variables and the GenerateDir call and error
handling around it).
internal/service/frontend/api/v1/dagruns.go (1)

3284-3289: Add a tiebreaker to keep artifact ordering deterministic.

When two names differ only by case, the comparator returns false both ways, so sort.Slice can reorder them arbitrarily. That makes the tree order unstable.

Proposed fix
 	sort.Slice(nodes, func(i, j int) bool {
 		if nodes[i].Type != nodes[j].Type {
 			return nodes[i].Type == api.ArtifactNodeTypeDirectory
 		}
-		return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name)
+		left := strings.ToLower(nodes[i].Name)
+		right := strings.ToLower(nodes[j].Name)
+		if left != right {
+			return left < right
+		}
+		return nodes[i].Name < nodes[j].Name
 	})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/frontend/api/v1/dagruns.go` around lines 3284 - 3289, The
comparator passed to sort.Slice can return false both ways when names differ
only by case, making ordering nondeterministic; update the anonymous comparator
used with sort.Slice over nodes so that after comparing Type and after comparing
strings.ToLower(nodes[i].Name) vs strings.ToLower(nodes[j].Name) you add a
tiebreaker for case-differing names (e.g., if strings.EqualFold(nodes[i].Name,
nodes[j].Name) or if lowercased names are equal, return nodes[i].Name <
nodes[j].Name) so the comparison becomes deterministic; reference the existing
symbols sort.Slice, nodes, nodes[i].Name, nodes[j].Name, and
api.ArtifactNodeTypeDirectory.
api/v1/api.yaml (1)

6751-6758: Validate ArtifactPath as a safe relative path in the spec.

The description says this must be relative, but the schema still accepts absolute paths and .. segments. The backend's safe-path resolution can reject those later, but encoding the same constraint here keeps generated clients/docs honest and fails bad requests earlier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v1/api.yaml` around lines 6751 - 6758, The ArtifactPath query parameter
currently allows absolute paths and `..` segments; update the ArtifactPath
schema to enforce a safe relative-path constraint by adding a regex pattern that
disallows a leading slash and any `..` path segments (for example a negative
lookahead that forbids leading '/' and any '/..' or '..' segments), keeping
type: string and minLength: 1; reference ArtifactPath in the spec and ensure the
description stays, so generated clients/docs and validation will reject absolute
paths and parent-directory traversals early.
internal/service/coordinator/artifact_handler.go (1)

99-125: Move store/filesystem work out of writersMu.

writersMu currently covers archiveDir() lookups, MkdirAll, and OpenOrCreateFile. That serializes unrelated artifact streams on DB and disk latency, which will noticeably throttle concurrent uploads. A double-checked lock here would keep the critical section small without changing behavior.

Refactor sketch
 func (h *artifactHandler) getOrCreateWriter(ctx context.Context, chunk *coordinatorv1.ArtifactChunk) (*logWriter, error) {
 	key := h.streamKey(chunk)

 	h.writersMu.Lock()
-	defer h.writersMu.Unlock()
-
 	if w, ok := h.writers[key]; ok {
+		h.writersMu.Unlock()
 		return w, nil
 	}
+	h.writersMu.Unlock()

 	filePath, err := h.artifactFilePath(ctx, chunk)
 	if err != nil {
 		return nil, err
 	}
 	if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil {
 		return nil, fmt.Errorf("failed to create artifact directory: %w", err)
 	}
 	file, err := fileutil.OpenOrCreateFile(filePath)
 	if err != nil {
 		return nil, fmt.Errorf("failed to open artifact file: %w", err)
 	}

 	w := &logWriter{
 		file:   file,
 		writer: bufio.NewWriterSize(file, 64*1024),
 		path:   filePath,
 	}
+
+	h.writersMu.Lock()
+	defer h.writersMu.Unlock()
+	if existing, ok := h.writers[key]; ok {
+		_ = file.Close()
+		return existing, nil
+	}
 	h.writers[key] = w
 	return w, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/coordinator/artifact_handler.go` around lines 99 - 125, The
current writersMu lock around artifactFilePath, os.MkdirAll and
fileutil.OpenOrCreateFile serializes unrelated disk/DB work; change to a
double-checked locking pattern: under h.writersMu check h.writers[key] and
return if present, otherwise release the lock and perform artifactFilePath,
MkdirAll and OpenOrCreateFile, then re-acquire h.writersMu, re-check
h.writers[key] (in case another goroutine created it), and only then set
h.writers[key] = w and return; reference functions/vars: writersMu, h.writers,
artifactFilePath, fileutil.OpenOrCreateFile, logWriter to locate and adjust the
code.
ui/src/api/v1/schema.ts (1)

4812-4813: Tighten the ArtifactPath contract to match the safe-path behavior.

ArtifactPath is typed as any non-empty string, so the generated docs and client types still advertise absolute/traversing values even though these endpoints are supposed to accept only safe relative paths. Please encode the no-leading-slash / no-.. constraint in the OpenAPI parameter schema and regenerate this file so preview/download callers see the real contract.

Suggested upstream OpenAPI change
 ArtifactPath:
       name: path
       in: query
-      description: "Relative artifact file path within the DAG-run artifact directory"
+      description: "Relative artifact file path within the DAG-run artifact directory. Must not start with '/' or contain '..'."
       required: true
       schema:
         type: string
         minLength: 1
+        pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
As per coding guidelines, "Frontend API types must be generated from OpenAPI spec via `pnpm gen:api` to maintain type safety with the backend".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/api/v1/schema.ts` around lines 4812 - 4813, The ArtifactPath property
is currently typed too loosely; update the OpenAPI parameter/schema that defines
ArtifactPath to enforce a safe relative-path constraint (e.g., minLength: 1 and
a pattern that disallows a leading slash and any '..' segments such as using a
negative-lookahead regex like one that forbids '^/' and forbids '..'), then
regenerate frontend types with pnpm gen:api so the generated ArtifactPath in
ui/src/api/v1/schema.ts reflects the no-leading-slash/no-`..` rule; locate the
OpenAPI parameter/schema that maps to ArtifactPath (the same schema/parameter
name used to produce the ArtifactPath property) and modify it accordingly before
running the generator.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/v1/api.yaml`:
- Around line 8213-8215: Remove the archiveDir field from the public API schema
(the archiveDir property in api.yaml) so the coordinator/worker filesystem path
is not exposed to clients; instead add a server-side capability flag (e.g.,
artifactsAvailable or artifactsEnabled) to the same DAG-run response object to
indicate whether artifacts exist or are supported, and ensure any code that
serializes/deserializes archiveDir (responses, examples, mocks) is updated to
stop emitting it while server storage and the run-scoped artifact endpoints
remain unchanged.
- Around line 2249-2259: The current GET /dag-runs/{name}/{dagRunId}/artifacts
endpoint forces full recursive expansion of directory children causing expensive
full-tree walks; add an optional query parameter (e.g., ?recursive=true or
?depth=N or ?shallow=true) to api.yaml for operationId getDAGRunArtifacts (and
the other affected artifacts-list operation) and update the handler in
internal/service/frontend/api/v1/dagruns.go (the listing logic around the
current children expansion in the function handling getDAGRunArtifacts and the
similar block at the other range) to honor the new parameter: perform a shallow
single-directory listing by default (no recursive children expansion), support
depth-limited or explicit recursive=true behavior only when requested, and
ensure response schema/documentation reflects the new param and behavior to
avoid large payloads and unnecessary filesystem walks.

In `@internal/cmd/enqueue.go`:
- Around line 100-103: Move the ctx.GenArtifactDir(dag, dagRunID) call so it
runs after the duplicate-run validation block that checks whether the dagRunID
already exists; currently artifactDir is created before that validation and can
create/touch paths for runs that will be rejected. Specifically, remove the
GenArtifactDir invocation from before the duplicate-run check and reinsert it
immediately after the validation succeeds (keeping the same error handling: wrap
and return fmt.Errorf("failed to generate artifact directory: %w", err) on
failure), referencing ctx.GenArtifactDir, dag and dagRunID to locate the call to
move.

In `@internal/cmn/fileutil/safe_path.go`:
- Around line 77-79: The pathWithinBase function fails for root bases because
base+string(filepath.Separator) becomes "//"; to fix, normalize inputs with
filepath.Clean and handle the root case explicitly: in pathWithinBase(path,
base) call filepath.Clean on both path and base, then return true if cleanedPath
== cleanedBase or (cleanedBase == string(filepath.Separator) &&
strings.HasPrefix(cleanedPath, cleanedBase)) or strings.HasPrefix(cleanedPath,
cleanedBase+string(filepath.Separator)); update only the pathWithinBase function
accordingly.

In `@internal/persis/filedagrun/dagrun.go`:
- Around line 308-317: The cleanup loop currently calls os.RemoveAll on entries
from uniqueArtifactDirs without validating the paths; restrict deletions to a
trusted artifact root by resolving and cleaning each dir (use filepath.Abs +
filepath.Clean), compute rel := filepath.Rel(trustedRoot, dir) and only call
os.RemoveAll if rel does not start with ".." (i.e., dir is inside trustedRoot);
log and skip otherwise; update the same validation when processing persisted
ArchiveDir entries (the block handling ArchiveDir around lines 409-440) and
ensure parentDirs population only happens for validated, accepted dirs.

In `@internal/runtime/agent/agent.go`:
- Around line 851-867: The artifact finalization is using the cancelable run
context (ctx) which allows aborted runs to skip uploads; change the Finalize
call to use a detached context (like context.WithoutCancel(ctx) or a
background/detached context) so artifactFinalizer.Finalize(ctx,
finishedStatus.AttemptID, a.artifactDir) becomes
artifactFinalizer.Finalize(detachedCtx, finishedStatus.AttemptID,
a.artifactDir); update the call site in the block that references
a.artifactFinalizer, a.artifactDir, finishedStatus and lastErr and ensure the
detached context variable is created just before the Finalize call.

In `@internal/service/coordinator/artifact_handler.go`:
- Around line 38-85: When the streaming loop in handleStream exits early (on
io.EOF or any stream.Recv error), ensure any open buffered writers in h.writers
are closed to avoid stale state: before returning on io.EOF or on a non-nil recv
error, iterate over the entries in the h.writers map and call h.closeWriter(ctx,
<corresponding chunk/identifier>) or add and call a helper like
closeAllWriters(ctx) that closes each writer; update handleStream to invoke this
cleanup path prior to returning so getOrCreateWriter and future writes won't
reuse orphaned writers.
- Around line 96-109: The cached writer returned by getOrCreateWriter can be
stale because the code returns an existing writer before revalidating the
current attempt; ensure you revalidate the attempt before reusing a cached
writer by calling the same check used for new writers (e.g., call
validateCurrentAttempt which calls archiveDir(ctx, chunk) or directly call
h.archiveDir(ctx, chunk)) immediately after acquiring the writer from h.writers
and before returning it; if the validation fails, treat it as a miss and proceed
with artifactFilePath and writer creation as originally implemented.

In `@internal/service/coordinator/handler.go`:
- Around line 1477-1485: The code expands the artifact base directory into
baseDir (from h.artifactDir or dag.Artifacts.Dir) using eval.String but does not
reject an empty result; after env expansion an empty baseDir will make
filepath.Join produce a relative path and MkdirAll write to the process CWD.
After the eval.String call that sets baseDir (and checks err), add a validation
that baseDir is not empty (and optionally trim whitespace) and return a
descriptive error (e.g., "artifact directory is empty after expansion") if it
is; reference the variables and calls baseDir, dag.Artifacts.Dir, h.artifactDir,
and the eval.String expansion so the check occurs before any use in
filepath.Join/MkdirAll.
- Around line 1459-1464: The current early return checks "h.artifactDir == \"\"
|| incoming == nil || incoming.ArchiveDir == \"\"" which prevents reusing
latestStatus.ArchiveDir and allows a later empty incoming.ArchiveDir to clear
the persisted path; change the guard to only return when h.artifactDir == "" or
incoming == nil, then if latestStatus != nil && latestStatus.ArchiveDir != ""
copy latestStatus.ArchiveDir into incoming.ArchiveDir before any write/clear
logic so that missing ArchiveDir in subsequent updates preserves the previously
resolved ArchiveDir (refer to symbols h.artifactDir, incoming, latestStatus,
ArchiveDir).

In `@internal/service/frontend/api/v1/dagruns.go`:
- Around line 811-818: The code incorrectly maps every error from
getDAGRunArtifactStatus()/getSubDAGRunArtifactStatus() to a 404; change the
error handling in GetDAGRunArtifacts (and the similar blocks at the other
ranges) to distinguish "not found" from other failures: detect the actual
not-found sentinel (e.g. errors.Is(err, os.ErrNotExist) or the package-specific
sentinel returned by those helpers) and only return the 404/NotFound response
for that case, otherwise return an internal/server-error response (appropriate
api.*500JSONResponse or api.ErrorCodeInternal) including the original error
message for debugging; update the branches in GetDAGRunArtifacts, the
preview/download handlers, and other occurrences you noted to use this pattern
with getDAGRunArtifactStatus and getSubDAGRunArtifactStatus.

In `@internal/service/worker/remote_handler_test.go`:
- Around line 1883-1892: The test uses an unprotected reported
[]exec.DAGRunStatus which is appended to inside the mock client's
ReportStatusFunc and can race with executeDAGRun; fix by adding a sync.Mutex (or
sync.RWMutex) near the reported declaration and lock it around all
accesses/modifications to reported inside ReportStatusFunc and when later
reading the slice for assertions (referencing reported, ReportStatusFunc,
newMockRemoteCoordinatorClient, convert.ProtoToDAGRunStatus, require.NoError),
and apply the same mutex-guarding change to the other occurrence mentioned (the
block around lines 1942-1951).

In `@ui/src/api/v1/schema.ts`:
- Around line 3322-3328: The public schema currently exposes node-local
filesystem paths (DAGArtifactsConfig.dir and archiveDir) which leak host layout
and are useless to browsers; remove these fields from the browser-visible types
and replace them with a capability flag (e.g., hasArtifacts or
artifactsAvailable) on DAGArtifactsConfig and any related run artifact types,
keep the actual path values strictly server-side and only return them via
protected list/preview/download endpoints, and update any serializers/DTOs and
clients that used dir or archiveDir to instead call the server endpoints for
artifact operations.

In `@ui/src/features/dags/components/artifacts/ArtifactsTab.tsx`:
- Around line 1-20: This file (ArtifactsTab.tsx) is missing the repository's GPL
v3 license header; add the standard GPL v3 banner to the top of the file (above
all imports) matching the project's header template and license text, then run
the repository tool to ensure consistency (e.g., run make addlicense) so the
header is applied exactly like other source files.
- Around line 255-278: The fetchTree async flow only handles a resolved response
shape and can leave treeLoading true if requestArtifactTree() throws; wrap the
await requestArtifactTree() call and its subsequent handling in a try/catch
inside fetchTree, and in the catch ensure you call setTreeLoading(false),
setTree([]), and setTreeError(error.message || 'Failed to load artifacts');
apply the same pattern to the preview-fetching logic (the previewLoading state
handling around the request used in the preview path around lines 311-325) so
any thrown rejections clear loading state and set an error.
- Around line 280-292: When refreshing the tree we currently preserve
selectedPath if it still exists, but that prevents the preview effect from
re-running and leaves stale content; update the selection logic around
findFirstFile/ setSelectedPath so that if you return the existing current path
(i.e., keep the selection because nextNodes.some(...) is true) you also clear
the preview (call setPreview(null)) to force the preview effect to reload,
otherwise set the selection to firstFile.path as before; reference
setSelectedPath, setPreview, findFirstFile, nextNodes and the
selectedPath/current callback when making the change.

---

Outside diff comments:
In `@internal/runtime/agent/agent.go`:
- Around line 800-805: This goroutine can write a terminal status too early;
change the logic inside execWithRecovery so after the sleep you 1) obtain status
via a.Status(ctx), 2) re-check a.finished.Load(), and 3) consult
a.shouldDelayTerminalStatus(attempt, status) and skip calling a.writeStatus(ctx,
attempt, status) when that returns true (i.e., only write if not finished and
not delayed); use the existing symbols execWithRecovery, a.finished, a.Status,
a.shouldDelayTerminalStatus, and a.writeStatus to locate and modify the code.

---

Nitpick comments:
In `@api/v1/api.yaml`:
- Around line 6751-6758: The ArtifactPath query parameter currently allows
absolute paths and `..` segments; update the ArtifactPath schema to enforce a
safe relative-path constraint by adding a regex pattern that disallows a leading
slash and any `..` path segments (for example a negative lookahead that forbids
leading '/' and any '/..' or '..' segments), keeping type: string and minLength:
1; reference ArtifactPath in the spec and ensure the description stays, so
generated clients/docs and validation will reject absolute paths and
parent-directory traversals early.

In `@internal/cmd/context.go`:
- Around line 947-950: GenArtifactDir is being called with its error ignored;
change the call to capture the error (e.g., artifactDir, err :=
c.GenArtifactDir(dag, dagRunID)), and if err != nil log the error (using the
existing logger in this package) so path/config issues are visible, then
continue to call statusBuilder.Create(dagRunID, core.Failed, 0, time.Now(),
transform.WithLogFilePath(logPath), transform.WithArchiveDir(artifactDir))
without turning the status write into a blocking failure; ensure you reference
the artifactDir and err variables and use the same log facility used elsewhere
in this file to emit the error.

In `@internal/cmd/local_execution.go`:
- Around line 109-115: GenLogFileName and GenArtifactDir are currently called
with their errors discarded; capture their returned errors, log them (including
error details and context: dag, dagRunID) before building the opts slice, and
still pass the paths (possibly empty) to transform.WithLogFilePath and
transform.WithArchiveDir so the failure-recording flow continues; specifically
update the call sites around ctx.GenLogFileName(dag, dagRunID) and
ctx.GenArtifactDir(dag, dagRunID) to check the err, log via the existing logger
(or ctx logger) with clear messages, and then construct opts with
transform.WithLogFilePath(logPath) and transform.WithArchiveDir(artifactDir) as
before.

In `@internal/cmn/fileutil/safe_path_test.go`:
- Around line 30-60: Modify the two tests
TestResolveExistingPathWithinBaseRejectsSymlinkEscape and
TestResolveExistingPathWithinBaseAllowsSafeSymlink to skip rather than fail when
the platform cannot create symlinks: after the os.Symlink call (or immediately
before attempting it) check the returned error and call t.Skipf("skipping
symlink test: %v", err) when symlink creation fails (e.g., permission denied or
not supported), so tests only assert behavior when os.Symlink succeeds.

In `@internal/service/coordinator/artifact_handler.go`:
- Around line 99-125: The current writersMu lock around artifactFilePath,
os.MkdirAll and fileutil.OpenOrCreateFile serializes unrelated disk/DB work;
change to a double-checked locking pattern: under h.writersMu check
h.writers[key] and return if present, otherwise release the lock and perform
artifactFilePath, MkdirAll and OpenOrCreateFile, then re-acquire h.writersMu,
re-check h.writers[key] (in case another goroutine created it), and only then
set h.writers[key] = w and return; reference functions/vars: writersMu,
h.writers, artifactFilePath, fileutil.OpenOrCreateFile, logWriter to locate and
adjust the code.

In `@internal/service/coordinator/handler_test.go`:
- Around line 327-352: Add a new test variant of
TestTransformArtifactPathsCreatesDirectory that uses a DAG name containing
separators/traversal tokens (e.g., "../weird/..-dag--name") to assert
transformArtifactPaths sanitizes the DAG name; call
handler.transformArtifactPaths (same as the existing test) and compute the
expected sanitized directory using fileutil.SafeName on the DAG name, then
assert incoming.ArchiveDir is under baseDir and equals filepath.Join(baseDir,
fileutil.SafeName(dag.Name), <archive-suffix-from-incoming>) and that the
directory exists; reuse mockDAGRunAttempt, Handler, and exec.DAGRunStatus from
the current test setup to locate the change near transformArtifactPaths and
fileutil.SafeName usage.

In `@internal/service/frontend/api/v1/dagruns.go`:
- Around line 3284-3289: The comparator passed to sort.Slice can return false
both ways when names differ only by case, making ordering nondeterministic;
update the anonymous comparator used with sort.Slice over nodes so that after
comparing Type and after comparing strings.ToLower(nodes[i].Name) vs
strings.ToLower(nodes[j].Name) you add a tiebreaker for case-differing names
(e.g., if strings.EqualFold(nodes[i].Name, nodes[j].Name) or if lowercased names
are equal, return nodes[i].Name < nodes[j].Name) so the comparison becomes
deterministic; reference the existing symbols sort.Slice, nodes, nodes[i].Name,
nodes[j].Name, and api.ArtifactNodeTypeDirectory.

In `@internal/service/scheduler/enqueue_test.go`:
- Line 39: Two tests call the function with th.Config.Paths.ArtifactDir but
never assert it was persisted; update the tests that pass
th.Config.Paths.ArtifactDir to assert that the job/archive status metadata
contains that artifact path. Locate the assertions after the enqueue/creation
call (look for uses of th.Config.Paths.ArtifactDir in enqueue_test.go and the
variables holding returned status or archive objects, e.g., status, job, or
archive) and add an assertion that status.Metadata (or archive.Status.Metadata)
includes the artifact dir value (compare to th.Config.Paths.ArtifactDir) and/or
the specific metadata key used by the implementation (e.g., "artifact_dir" or
"artifactDir"). Ensure you add the same assertion in both call sites (the one at
the earlier location and the one referenced as also applies to 103-103).

In `@internal/service/scheduler/enqueue.go`:
- Around line 73-83: The nil check on dagCopy.Artifacts is redundant because
dagCopy.ArtifactsEnabled() already guarantees Artifacts != nil; remove the inner
"if dagCopy.Artifacts != nil" block and directly read dagCopy.Artifacts.Dir into
dagArtifactDir before calling logpath.GenerateDir so artifactDir is computed the
same way (preserve artifactDir, dagArtifactDir variables and the GenerateDir
call and error handling around it).

In `@ui/src/api/v1/schema.ts`:
- Around line 4812-4813: The ArtifactPath property is currently typed too
loosely; update the OpenAPI parameter/schema that defines ArtifactPath to
enforce a safe relative-path constraint (e.g., minLength: 1 and a pattern that
disallows a leading slash and any '..' segments such as using a
negative-lookahead regex like one that forbids '^/' and forbids '..'), then
regenerate frontend types with pnpm gen:api so the generated ArtifactPath in
ui/src/api/v1/schema.ts reflects the no-leading-slash/no-`..` rule; locate the
OpenAPI parameter/schema that maps to ArtifactPath (the same schema/parameter
name used to produce the ArtifactPath property) and modify it accordingly before
running the generator.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 27278248-d1e4-48df-863a-0d4c19cf39f6

📥 Commits

Reviewing files that changed from the base of the PR and between 591a1d2 and b185694.

⛔ Files ignored due to path filters (3)
  • proto/coordinator/v1/coordinator.pb.go is excluded by !**/*.pb.go
  • proto/coordinator/v1/coordinator_grpc.pb.go is excluded by !**/*.pb.go
  • proto/coordinator/v1/coordinator_protoopaque.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (61)
  • api/v1/api.gen.go
  • api/v1/api.yaml
  • internal/cmd/context.go
  • internal/cmd/coord.go
  • internal/cmd/dry.go
  • internal/cmd/enqueue.go
  • internal/cmd/local_execution.go
  • internal/cmd/restart.go
  • internal/cmd/retry.go
  • internal/cmd/retry_test.go
  • internal/cmd/start.go
  • internal/cmn/config/config.go
  • internal/cmn/config/definition.go
  • internal/cmn/config/key_hints.go
  • internal/cmn/config/loader.go
  • internal/cmn/config/path.go
  • internal/cmn/fileutil/safe_path.go
  • internal/cmn/fileutil/safe_path_test.go
  • internal/cmn/logpath/logpath.go
  • internal/cmn/schema/config.schema.json
  • internal/cmn/schema/dag.schema.json
  • internal/core/dag.go
  • internal/core/exec/context.go
  • internal/core/exec/env.go
  • internal/core/exec/runstatus.go
  • internal/core/spec/dag.go
  • internal/core/spec/key_hints.go
  • internal/intg/distr/execution_test.go
  • internal/intg/distr/fixtures_test.go
  • internal/intg/queue/fixture_test.go
  • internal/persis/filedagrun/dagrun.go
  • internal/persis/filedagrun/dataroot_test.go
  • internal/persis/filedagrun/store_test.go
  • internal/runtime/agent/agent.go
  • internal/runtime/context.go
  • internal/runtime/remote/artifact_uploader.go
  • internal/runtime/remote/artifact_uploader_test.go
  • internal/runtime/remote/status_pusher_test.go
  • internal/runtime/transform/status.go
  • internal/service/coordinator/artifact_handler.go
  • internal/service/coordinator/artifact_handler_test.go
  • internal/service/coordinator/client.go
  • internal/service/coordinator/handler.go
  • internal/service/coordinator/handler_test.go
  • internal/service/frontend/api/v1/dagruns.go
  • internal/service/frontend/api/v1/dagruns_artifacts_internal_test.go
  • internal/service/frontend/api/v1/transformer.go
  • internal/service/frontend/api/v1/workers_internal_test.go
  • internal/service/scheduler/enqueue.go
  • internal/service/scheduler/enqueue_test.go
  • internal/service/scheduler/scheduler.go
  • internal/service/worker/poller_test.go
  • internal/service/worker/remote_handler.go
  • internal/service/worker/remote_handler_test.go
  • internal/test/coordinator.go
  • internal/test/helper.go
  • proto/coordinator/v1/coordinator.proto
  • ui/src/api/v1/schema.ts
  • ui/src/features/dags/components/DAGStatus.tsx
  • ui/src/features/dags/components/artifacts/ArtifactsTab.tsx
  • ui/src/features/dags/components/dag-details/DAGDetailsContent.tsx

Comment thread api/v1/api.yaml
Comment thread api/v1/api.yaml Outdated
Comment thread internal/cmd/enqueue.go Outdated
Comment thread internal/cmn/fileutil/safe_path.go
Comment thread internal/persis/filedagrun/dagrun.go
Comment thread internal/service/worker/remote_handler_test.go
Comment thread ui/src/api/v1/schema.ts
Comment on lines +3322 to +3328
/** @description Configuration for DAG run artifact storage */
DAGArtifactsConfig: {
/** @description Whether artifact storage is enabled for this DAG */
enabled: boolean;
/** @description Optional DAG-specific artifact storage directory */
dir?: string;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid exposing node-local artifact paths in public responses.

Lines 3326-3327 and Line 3379 add raw filesystem locations (dir, archiveDir) to browser-visible types. In distributed runs those paths are unusable client-side, and they also leak host layout unnecessarily. Prefer a capability flag such as hasArtifacts or keep the storage path entirely server-side behind the list/preview/download endpoints.

Also applies to: 3379-3380

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/api/v1/schema.ts` around lines 3322 - 3328, The public schema
currently exposes node-local filesystem paths (DAGArtifactsConfig.dir and
archiveDir) which leak host layout and are useless to browsers; remove these
fields from the browser-visible types and replace them with a capability flag
(e.g., hasArtifacts or artifactsAvailable) on DAGArtifactsConfig and any related
run artifact types, keep the actual path values strictly server-side and only
return them via protected list/preview/download endpoints, and update any
serializers/DTOs and clients that used dir or archiveDir to instead call the
server endpoints for artifact operations.

Comment thread ui/src/features/dags/components/artifacts/ArtifactsTab.tsx
Comment thread ui/src/features/dags/components/artifacts/ArtifactsTab.tsx Outdated
Comment thread ui/src/features/dags/components/artifacts/ArtifactsTab.tsx Outdated
- wire the configured artifact dir into dag run stores used by command and scheduler paths
- keep dry runs free of artifact directory side effects
- remove the stray recursive query param from getDAGRunSpec and regenerate API types
@yohamta0

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/v1/api.yaml (1)

2249-3011: 🛠️ Refactor suggestion | 🟠 Major

Please regenerate API server code from this updated spec.

This OpenAPI update should be followed by make api so generated server types/handlers stay in sync with new endpoints and schemas.

As per coding guidelines, api/v1/api.yaml: Generate REST API server code from OpenAPI spec at api/v1/api.yaml using oapi-codegen via make api.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v1/api.yaml` around lines 2249 - 3011, The OpenAPI spec added several new
endpoints/operations (e.g., operationId getDAGRunArtifacts,
getDAGRunArtifactPreview, downloadDAGRunArtifact, getDAGRunOutputs, retryDAGRun,
terminateDAGRun, getDAGRunStepLog, downloadDAGRunStepLog, getDAGRunStepMessages,
updateDAGRunStepStatus, approveDAGRunStep, rejectDAGRunStep, pushBackDAGRunStep,
getSubDAGRunDetails, getSubDAGRunSpec, getSubDAGRunLog, downloadSubDAGRunLog,
getSubDAGRunArtifacts, getSubDAGRunArtifactPreview, downloadSubDAGRunArtifact
and schemas like
ArtifactTreeResponse/ArtifactPreviewResponse/DAGRunOutputs/ApproveStepRequest
etc.), but the generated server code wasn’t updated; regenerate the REST server
types and handlers from the OpenAPI spec using the project’s oapi-codegen flow
(run the provided make/api generation step), then commit the updated generated
types/handlers so the codebase reflects the new operationIds and response
schemas.
♻️ Duplicate comments (1)
ui/src/api/v1/schema.ts (1)

3322-3328: ⚠️ Potential issue | 🟠 Major

Remove the artifact storage directory from the public schema.

Line 3327 still exposes a node-local filesystem path to the browser. That leaks host layout and is not usable in remote-node deployments; the public DTO should keep only capability metadata like enabled/artifactsAvailable and leave storage paths server-side behind the artifact endpoints. Please fix the OpenAPI/backend source and regenerate this file rather than patching the generated output directly. As per coding guidelines, "Frontend API types must be generated from OpenAPI spec via pnpm gen:api to maintain type safety with the backend".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/api/v1/schema.ts` around lines 3322 - 3328, The public DTO exposes a
node-local path via DAGArtifactsConfig.dir; remove the dir field from the
OpenAPI model (the source that generates DAGArtifactsConfig) so the generated
ui/src/api/v1/schema.ts no longer includes DAGArtifactsConfig.dir, update the
backend/OpenAPI spec (where DAGArtifactsConfig is defined) to only include
capability flags (e.g., enabled/artifactsAvailable), then regenerate the
frontend types with pnpm gen:api rather than editing the generated file
directly; ensure references to DAGArtifactsConfig.dir are removed or handled
server-side behind artifact endpoints.
🧹 Nitpick comments (3)
internal/core/spec/dag_test.go (1)

2164-2202: Add an explicit enabled: false artifact test case.

The table currently skips the explicit-disable path, which is the key override case when a base config enables artifacts.

Suggested test case
 	{
 		name: "EnabledAndDir",
 		yaml: `
 artifacts:
   enabled: true
   dir: /var/lib/dagu/artifacts
 `,
 		expected: &core.ArtifactsConfig{
 			Enabled: true,
 			Dir:     "/var/lib/dagu/artifacts",
 		},
 	},
+	{
+		name: "ExplicitDisable",
+		yaml: `
+artifacts:
+  enabled: false
+`,
+		expected: &core.ArtifactsConfig{
+			Enabled: false,
+		},
+	},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/spec/dag_test.go` around lines 2164 - 2202, Add a new table
test case to cover the explicit-disable path: insert a test entry in the tests
slice (same style as existing entries) named e.g. "DisabledExplicit" with yaml
set to:
artifacts:
  enabled: false
and expected set to &core.ArtifactsConfig{Enabled: false}; place it alongside
the other cases so the test verifies explicit disabling is parsed into
core.ArtifactsConfig.Enabled == false (refer to the tests variable and
core.ArtifactsConfig type in dag_test.go).
internal/service/coordinator/client.go (1)

676-716: Consider extracting shared stream-failover logic into one helper.

StreamLogs and StreamArtifacts now duplicate coordinator selection, health check, and retry selection flow. A small internal helper would reduce drift risk.

Also applies to: 734-772

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/coordinator/client.go` around lines 676 - 716, The
StreamLogs/StreamArtifacts methods duplicate the coordinator selection + health
check + failover loop; extract that loop into a single helper on clientImpl
(e.g., call it withStreamFailover or tryCoordinatorStream) that takes context
and a callback which, given a coordinator client, attempts to open the specific
stream (so it can invoke client.client.StreamLogs or
client.client.StreamArtifacts). Move logic that calls getCoordinatorMembers,
rand.Shuffle, getOrCreateClient, isHealthy, recordFailure, recordSuccess, and
returns the opened stream into that helper, and have StreamLogs and
StreamArtifacts call the helper with the appropriate stream-opening callback so
both reuse the same failover/retry flow.
internal/cmn/config/loader_test.go (1)

687-692: Strengthen this expectation by avoiding a self-referential value.

Using cfg.Paths.ArtifactDir in expected can hide regressions in derived-path logic. Prefer asserting the explicit derived value from data_dir.

♻️ Suggested test tightening
 		Paths: PathsConfig{
 			DAGsDir:            "/var/dagu/dags",
 			DocsDir:            "/var/dagu/dags/docs",
 			LogDir:             "/var/dagu/logs",
 			DataDir:            "/var/dagu/data",
-			ArtifactDir:        cfg.Paths.ArtifactDir,
+			ArtifactDir:        "/var/dagu/data/artifacts",
 			SuspendFlagsDir:    "/var/dagu/suspend",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmn/config/loader_test.go` around lines 687 - 692, The test
currently uses the self-referential value cfg.Paths.ArtifactDir in the expected
struct which can hide regressions; change the expected ArtifactDir to the
explicit derived value from the DataDir instead of reusing
cfg.Paths.ArtifactDir—e.g., compute it with filepath.Join(cfg.Paths.DataDir,
"artifacts") or assert the literal "/var/dagu/data/artifacts" (matching DataDir
"/var/dagu/data") when building the expected value so the test verifies the
derived-path logic (update the expected variable where ArtifactDir is set and
reference cfg.Paths.DataDir, not cfg.Paths.ArtifactDir).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/v1/api.yaml`:
- Around line 6753-6765: The ArtifactPath query parameter schema currently
forbids Unix-style traversal but allows Windows-style inputs; update the
ArtifactPath schema (the component named ArtifactPath) to also reject
backslash-based traversal and Windows absolute/drive paths by adding additional
validation rules: disallow any backslash characters, disallow leading
backslashes, disallow backslash-based parent segments like "..\" and disallow
drive-letter prefixes like "C:\" (e.g., add complementary not: pattern checks
for backslashes and for ^[A-Za-z]:\\ or ^\\). Ensure these new patterns are
combined with the existing patterns so both Unix and Windows traversal/absolute
paths are rejected.

In `@internal/cmd/artifacts_regression_test.go`:
- Around line 103-107: The YAML writer builds the test config using fmt.Sprintf
into the variable content with unquoted path interpolations (dagRunsDir and
artifactDir), which can produce invalid YAML for Windows-style paths; change the
interpolation to quote those values (use quoted formatting for dagRunsDir and
artifactDir in the fmt.Sprintf call that assigns content) so the generated YAML
contains proper string literals for the paths.

In `@internal/runtime/remote/artifact_uploader.go`:
- Around line 57-67: The upload routine reads u.attemptID for every chunk which
allows a concurrent SetAttemptID to change the value mid-stream; modify
UploadDir (or its call sites like Finalize) to capture the attempt ID once into
a local immutable variable (e.g., localAttemptID := u.attemptID with u.mu held)
at the start of the upload and then use that localAttemptID for all
chunks/stream messages instead of reading u.attemptID repeatedly; ensure
SetAttemptID still acquires u.mu and updates u.attemptID, but UploadDir uses the
snapshot to prevent mixed AttemptId values across RPC chunks (apply same change
where chunk streaming occurs, e.g., the code paths noted around lines 70-74 and
137-163).
- Around line 117-121: The current UploadDir callback defers file.Close(), which
only runs when UploadDir returns and leaks FDs for each file; change the
per-file handling so the file is closed immediately after its upload work
completes (e.g., move the open/upload logic into a short helper or closure
inside UploadDir or call file.Close() explicitly right after the
upload/processing and before returning from the callback). Update the code
around the os.Open(filepath.Clean(path)) usage in UploadDir to remove the
deferred close and ensure file.Close() is invoked in all success and error paths
for that specific file (including after any uploadToRemote or io.Copy calls).

In `@internal/service/coordinator/artifact_handler.go`:
- Around line 97-103: The streamKey function builds a cache key from chunk
fields but uses chunk.RelativePath raw; canonicalize/normalize RelativePath
before using it so semantically equivalent paths (e.g., "foo/./bar.txt" vs
"foo/bar.txt" or leading/trailing slashes) map to the same writer. In
artifactHandler.streamKey, call the path normalization (e.g., path.Clean and
trim any leading "./" or leading "/" as your project expects) on
chunk.RelativePath and use that cleaned value in the fmt.Sprintf to produce the
key.
- Around line 69-75: The handler currently skips writing when chunk.IsFinal is
true, dropping trailing bytes; modify the branch handling chunk.IsFinal so it
still obtains the writer via h.getOrCreateWriter(ctx, chunk), writes the chunk's
payload to that writer (checking and returning any write error), then closes the
writer via h.closeWriterByKey(ctx, key) and deletes activeKeys[key]; ensure you
reference chunk.IsFinal, h.getOrCreateWriter, h.closeWriterByKey, key and handle
errors from the write before cleanup.

In `@internal/service/coordinator/handler.go`:
- Around line 1460-1492: The early return currently checks h.artifactDir == ""
|| incoming == nil which prevents using a DAG-level artifacts dir when
Handler.artifactDir is empty; remove the h.artifactDir check so the guard only
returns when incoming == nil, then keep the existing logic in resolve (call
attempt.ReadDAG, use dag.Artifacts.Dir if set, otherwise fall back to
h.artifactDir, run eval.String with eval.WithOSExpansion, trim and validate
baseDir) so DAG-level artifact directories are honored even when
Handler.artifactDir is empty (refer to h.artifactDir, incoming.ArchiveDir,
latestStatus, attempt.ReadDAG, dag.Artifacts.Dir, baseDir and eval.String).

In `@internal/service/frontend/api/v1/dagruns.go`:
- Around line 3386-3400: The current checks only reject directories using
info.IsDir(), but they should also reject non-regular files (FIFOs, sockets,
devices) to avoid hanging or exposing special files; update the logic around
os.Stat(absPath) so that after getting info you return an error (e.g.,
os.ErrNotExist) when !info.Mode().IsRegular() instead of only when info.IsDir(),
and keep the rest of the flow (opening filepath.Clean(absPath) and deferring
Close) unchanged; apply the same change to the other occurrence referenced (the
block around the later open at lines 3453-3461) so both preview and download
paths consistently reject non-regular files.

In `@internal/service/scheduler/ha_health_test.go`:
- Around line 99-102: The test constructs dagRunStore via filedagrun.New and
wires WithArtifactDir(cfg.Paths.ArtifactDir) but never sets
cfg.Paths.ArtifactDir, so the fixture passes an empty path; set
cfg.Paths.ArtifactDir to a valid non-empty path (for example a t.TempDir() or
the same base used for cfg.Paths.DAGRunsDir) before calling filedagrun.New so
WithArtifactDir receives a real artifact directory and the test exercises
artifact-dir behavior correctly.

In `@ui/src/features/dags/components/artifacts/ArtifactsTab.tsx`:
- Around line 236-257: The buildArtifactDownloadUrl/fetchArtifactDownload flow
uses a direct fetch and duplicates auth/base-URL handling; replace this with the
generated shared API client by calling client.GET (or client.POST if required)
for the artifacts/download endpoint and pass remoteNode and path as query
params, ensuring you use the client instance that already handles base URL and
auth tokens; update fetchArtifactDownload to return the client response (and
propagate AbortSignal if client supports it) and remove
buildArtifactDownloadUrl, and make the same change for the other similar
handlers referenced (lines 404-422).
- Around line 259-312: fetchTree can write state after async awaits and
overwrite newer runs; add a per-call request identity (e.g., a serial requestId
or AbortController) at the top of fetchTree and capture it in a local variable
before any await, then check that identity (or !aborted) before every state
mutation (setTree, setOpenDirs, setSelectedPath, setPreview, setTreeError,
setTreeLoading, setPreviewVersion) in the try, catch and finally blocks; also
wire that identity/abort to the useEffect so each invocation creates a fresh
id/controller and cancels or ignores prior requests when
dagRun.artifactsAvailable, dagRun.dagRunId, dagRun.rootDAGRunId or remoteNode
change, and ensure requestArtifactTree is cancellable or its result is ignored
when the id/controller no longer matches.

---

Outside diff comments:
In `@api/v1/api.yaml`:
- Around line 2249-3011: The OpenAPI spec added several new endpoints/operations
(e.g., operationId getDAGRunArtifacts, getDAGRunArtifactPreview,
downloadDAGRunArtifact, getDAGRunOutputs, retryDAGRun, terminateDAGRun,
getDAGRunStepLog, downloadDAGRunStepLog, getDAGRunStepMessages,
updateDAGRunStepStatus, approveDAGRunStep, rejectDAGRunStep, pushBackDAGRunStep,
getSubDAGRunDetails, getSubDAGRunSpec, getSubDAGRunLog, downloadSubDAGRunLog,
getSubDAGRunArtifacts, getSubDAGRunArtifactPreview, downloadSubDAGRunArtifact
and schemas like
ArtifactTreeResponse/ArtifactPreviewResponse/DAGRunOutputs/ApproveStepRequest
etc.), but the generated server code wasn’t updated; regenerate the REST server
types and handlers from the OpenAPI spec using the project’s oapi-codegen flow
(run the provided make/api generation step), then commit the updated generated
types/handlers so the codebase reflects the new operationIds and response
schemas.

---

Duplicate comments:
In `@ui/src/api/v1/schema.ts`:
- Around line 3322-3328: The public DTO exposes a node-local path via
DAGArtifactsConfig.dir; remove the dir field from the OpenAPI model (the source
that generates DAGArtifactsConfig) so the generated ui/src/api/v1/schema.ts no
longer includes DAGArtifactsConfig.dir, update the backend/OpenAPI spec (where
DAGArtifactsConfig is defined) to only include capability flags (e.g.,
enabled/artifactsAvailable), then regenerate the frontend types with pnpm
gen:api rather than editing the generated file directly; ensure references to
DAGArtifactsConfig.dir are removed or handled server-side behind artifact
endpoints.

---

Nitpick comments:
In `@internal/cmn/config/loader_test.go`:
- Around line 687-692: The test currently uses the self-referential value
cfg.Paths.ArtifactDir in the expected struct which can hide regressions; change
the expected ArtifactDir to the explicit derived value from the DataDir instead
of reusing cfg.Paths.ArtifactDir—e.g., compute it with
filepath.Join(cfg.Paths.DataDir, "artifacts") or assert the literal
"/var/dagu/data/artifacts" (matching DataDir "/var/dagu/data") when building the
expected value so the test verifies the derived-path logic (update the expected
variable where ArtifactDir is set and reference cfg.Paths.DataDir, not
cfg.Paths.ArtifactDir).

In `@internal/core/spec/dag_test.go`:
- Around line 2164-2202: Add a new table test case to cover the explicit-disable
path: insert a test entry in the tests slice (same style as existing entries)
named e.g. "DisabledExplicit" with yaml set to:
artifacts:
  enabled: false
and expected set to &core.ArtifactsConfig{Enabled: false}; place it alongside
the other cases so the test verifies explicit disabling is parsed into
core.ArtifactsConfig.Enabled == false (refer to the tests variable and
core.ArtifactsConfig type in dag_test.go).

In `@internal/service/coordinator/client.go`:
- Around line 676-716: The StreamLogs/StreamArtifacts methods duplicate the
coordinator selection + health check + failover loop; extract that loop into a
single helper on clientImpl (e.g., call it withStreamFailover or
tryCoordinatorStream) that takes context and a callback which, given a
coordinator client, attempts to open the specific stream (so it can invoke
client.client.StreamLogs or client.client.StreamArtifacts). Move logic that
calls getCoordinatorMembers, rand.Shuffle, getOrCreateClient, isHealthy,
recordFailure, recordSuccess, and returns the opened stream into that helper,
and have StreamLogs and StreamArtifacts call the helper with the appropriate
stream-opening callback so both reuse the same failover/retry flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3ac59ed-e3a7-4cea-bcd2-64603eac9140

📥 Commits

Reviewing files that changed from the base of the PR and between 591a1d2 and ea910f2.

⛔ Files ignored due to path filters (3)
  • proto/coordinator/v1/coordinator.pb.go is excluded by !**/*.pb.go
  • proto/coordinator/v1/coordinator_grpc.pb.go is excluded by !**/*.pb.go
  • proto/coordinator/v1/coordinator_protoopaque.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (73)
  • api/v1/api.gen.go
  • api/v1/api.yaml
  • internal/cmd/artifacts_regression_test.go
  • internal/cmd/context.go
  • internal/cmd/coord.go
  • internal/cmd/enqueue.go
  • internal/cmd/local_execution.go
  • internal/cmd/restart.go
  • internal/cmd/retry.go
  • internal/cmd/retry_test.go
  • internal/cmd/start.go
  • internal/cmn/config/config.go
  • internal/cmn/config/definition.go
  • internal/cmn/config/key_hints.go
  • internal/cmn/config/loader.go
  • internal/cmn/config/loader_test.go
  • internal/cmn/config/path.go
  • internal/cmn/config/path_test.go
  • internal/cmn/fileutil/safe_path.go
  • internal/cmn/fileutil/safe_path_test.go
  • internal/cmn/logpath/logpath.go
  • internal/cmn/schema/config.schema.json
  • internal/cmn/schema/dag.schema.json
  • internal/core/dag.go
  • internal/core/exec/context.go
  • internal/core/exec/context_test.go
  • internal/core/exec/env.go
  • internal/core/exec/runstatus.go
  • internal/core/spec/dag.go
  • internal/core/spec/dag_test.go
  • internal/core/spec/key_hints.go
  • internal/core/spec/loader_test.go
  • internal/intg/distr/execution_test.go
  • internal/intg/distr/fixtures_test.go
  • internal/intg/queue/fixture_test.go
  • internal/persis/filedagrun/dagrun.go
  • internal/persis/filedagrun/dataroot.go
  • internal/persis/filedagrun/dataroot_test.go
  • internal/persis/filedagrun/setup_test.go
  • internal/persis/filedagrun/store.go
  • internal/persis/filedagrun/store_test.go
  • internal/runtime/agent/agent.go
  • internal/runtime/context.go
  • internal/runtime/remote/artifact_uploader.go
  • internal/runtime/remote/artifact_uploader_test.go
  • internal/runtime/remote/status_pusher_test.go
  • internal/runtime/transform/status.go
  • internal/service/coordinator/artifact_handler.go
  • internal/service/coordinator/artifact_handler_test.go
  • internal/service/coordinator/client.go
  • internal/service/coordinator/handler.go
  • internal/service/coordinator/handler_test.go
  • internal/service/frontend/api/v1/dagruns.go
  • internal/service/frontend/api/v1/dagruns_artifacts_internal_test.go
  • internal/service/frontend/api/v1/transformer.go
  • internal/service/frontend/api/v1/workers_internal_test.go
  • internal/service/scheduler/enqueue.go
  • internal/service/scheduler/enqueue_test.go
  • internal/service/scheduler/ha_health_test.go
  • internal/service/scheduler/scheduler.go
  • internal/service/worker/poller_test.go
  • internal/service/worker/remote_handler.go
  • internal/service/worker/remote_handler_test.go
  • internal/test/coordinator.go
  • internal/test/helper.go
  • internal/test/scheduler.go
  • proto/coordinator/v1/coordinator.proto
  • ui/src/api/v1/schema.ts
  • ui/src/features/dags/components/DAGStatus.tsx
  • ui/src/features/dags/components/artifacts/ArtifactsTab.tsx
  • ui/src/features/dags/components/dag-details/DAGDetailsContent.tsx
  • ui/src/features/dags/components/dag-details/__tests__/DAGStatusOverview.test.tsx
  • ui/src/features/dags/components/dag-editor/__tests__/improveDagDefinitionPrompt.test.ts

Comment thread api/v1/api.yaml
Comment on lines +6753 to +6765
ArtifactPath:
name: path
in: query
description: "Relative artifact file path within the DAG-run artifact directory. Must not start with '/' or contain '..'."
required: true
schema:
allOf:
- type: string
minLength: 1
pattern: "^[^/].*$"
- not:
pattern: "(^|/)\\.\\.(/|$)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden ArtifactPath against Windows-style traversal inputs.

Current validation blocks ../ but still permits ..\ and drive-prefixed paths (e.g., C:\...), which weakens traversal protection on Windows deployments.

🔧 Suggested spec hardening
     ArtifactPath:
       name: path
       in: query
       description: "Relative artifact file path within the DAG-run artifact directory. Must not start with '/' or contain '..'."
       required: true
       schema:
         allOf:
           - type: string
             minLength: 1
-            pattern: "^[^/].*$"
+            pattern: "^[^/\\\\].*$"
           - not:
-              pattern: "(^|/)\\.\\.(/|$)"
+              pattern: "(^|[\\\\/])\\.\\.([\\\\/]|$)"
+          - not:
+              pattern: "^[A-Za-z]:"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v1/api.yaml` around lines 6753 - 6765, The ArtifactPath query parameter
schema currently forbids Unix-style traversal but allows Windows-style inputs;
update the ArtifactPath schema (the component named ArtifactPath) to also reject
backslash-based traversal and Windows absolute/drive paths by adding additional
validation rules: disallow any backslash characters, disallow leading
backslashes, disallow backslash-based parent segments like "..\" and disallow
drive-letter prefixes like "C:\" (e.g., add complementary not: pattern checks
for backslashes and for ^[A-Za-z]:\\ or ^\\). Ensure these new patterns are
combined with the existing patterns so both Unix and Windows traversal/absolute
paths are rejected.

Comment on lines +103 to +107
content := fmt.Sprintf(`
paths:
dag_runs_dir: %s
artifact_dir: %s
`, dagRunsDir, artifactDir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Quote YAML path values in the test config writer.

The current interpolation can produce invalid YAML for certain filesystem paths (notably Windows-style paths with :/\).

🐛 Proposed fix
 	content := fmt.Sprintf(`
 paths:
-  dag_runs_dir: %s
-  artifact_dir: %s
+  dag_runs_dir: %q
+  artifact_dir: %q
 `, dagRunsDir, artifactDir)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
content := fmt.Sprintf(`
paths:
dag_runs_dir: %s
artifact_dir: %s
`, dagRunsDir, artifactDir)
content := fmt.Sprintf(`
paths:
dag_runs_dir: %q
artifact_dir: %q
`, dagRunsDir, artifactDir)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/artifacts_regression_test.go` around lines 103 - 107, The YAML
writer builds the test config using fmt.Sprintf into the variable content with
unquoted path interpolations (dagRunsDir and artifactDir), which can produce
invalid YAML for Windows-style paths; change the interpolation to quote those
values (use quoted formatting for dagRunsDir and artifactDir in the fmt.Sprintf
call that assigns content) so the generated YAML contains proper string literals
for the paths.

Comment on lines +57 to +67
// SetAttemptID updates the attempt ID after the agent creates the attempt.
func (u *ArtifactUploader) SetAttemptID(attemptID string) {
u.mu.Lock()
defer u.mu.Unlock()
u.attemptID = attemptID
}

// Finalize uploads artifacts for the finalized attempt before the terminal status is written.
func (u *ArtifactUploader) Finalize(ctx context.Context, attemptID, dir string) error {
u.SetAttemptID(attemptID)
return u.UploadDir(ctx, dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Snapshot the attempt ID once per upload.

This stream reads u.attemptID for every chunk. If a retry calls SetAttemptID while a previous upload is still in flight, one RPC can emit mixed AttemptId values and defeat the stale-attempt protection you added on the coordinator side. Capture the attempt ID once at the start of UploadDir (or pass it in as an argument) and reuse that immutable value for all chunks in the stream.

♻️ Proposed fix
 func (u *ArtifactUploader) UploadDir(ctx context.Context, dir string) error {
 	if dir == "" {
 		return nil
 	}
 
+	attemptID := u.getAttemptID()
 	seq := uint64(0)
 	var stream coordinatorv1.CoordinatorService_StreamArtifactsClient
@@
 				chunk := &coordinatorv1.ArtifactChunk{
 					WorkerId:           u.workerID,
 					DagRunId:           u.dagRunID,
 					DagName:            u.dagName,
 					RelativePath:       relPath,
 					Data:               append([]byte(nil), buf[:n]...),
 					Sequence:           seq,
 					RootDagRunName:     u.rootRef.Name,
 					RootDagRunId:       u.rootRef.ID,
-					AttemptId:          u.getAttemptID(),
+					AttemptId:          attemptID,
 					OwnerCoordinatorId: u.owner.ID,
 				}
@@
 		if err := sendChunk(&coordinatorv1.ArtifactChunk{
 			WorkerId:           u.workerID,
 			DagRunId:           u.dagRunID,
 			DagName:            u.dagName,
 			RelativePath:       relPath,
 			IsFinal:            true,
 			Sequence:           seq,
 			RootDagRunName:     u.rootRef.Name,
 			RootDagRunId:       u.rootRef.ID,
-			AttemptId:          u.getAttemptID(),
+			AttemptId:          attemptID,
 			OwnerCoordinatorId: u.owner.ID,
 		}); err != nil {
 			return fmt.Errorf("send artifact final marker: %w", err)
 		}

Also applies to: 70-74, 137-163

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runtime/remote/artifact_uploader.go` around lines 57 - 67, The
upload routine reads u.attemptID for every chunk which allows a concurrent
SetAttemptID to change the value mid-stream; modify UploadDir (or its call sites
like Finalize) to capture the attempt ID once into a local immutable variable
(e.g., localAttemptID := u.attemptID with u.mu held) at the start of the upload
and then use that localAttemptID for all chunks/stream messages instead of
reading u.attemptID repeatedly; ensure SetAttemptID still acquires u.mu and
updates u.attemptID, but UploadDir uses the snapshot to prevent mixed AttemptId
values across RPC chunks (apply same change where chunk streaming occurs, e.g.,
the code paths noted around lines 70-74 and 137-163).

Comment on lines +117 to +121
file, err := os.Open(filepath.Clean(path))
if err != nil {
return fmt.Errorf("open artifact %s: %w", path, err)
}
defer func() { _ = file.Close() }()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Close each artifact file before walking the next one.

defer file.Close() here runs only when UploadDir returns, not when the current file finishes. On large artifact trees this keeps one FD open per file and can fail mid-upload with too many open files. Wrap the per-file work in a helper/closure or close explicitly before returning from the callback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runtime/remote/artifact_uploader.go` around lines 117 - 121, The
current UploadDir callback defers file.Close(), which only runs when UploadDir
returns and leaks FDs for each file; change the per-file handling so the file is
closed immediately after its upload work completes (e.g., move the open/upload
logic into a short helper or closure inside UploadDir or call file.Close()
explicitly right after the upload/processing and before returning from the
callback). Update the code around the os.Open(filepath.Clean(path)) usage in
UploadDir to remove the deferred close and ensure file.Close() is invoked in all
success and error paths for that specific file (including after any
uploadToRemote or io.Copy calls).

Comment on lines +69 to +75
if chunk.IsFinal {
if _, err := h.getOrCreateWriter(ctx, chunk); err != nil {
return fmt.Errorf("failed to create artifact writer: %w", err)
}
h.closeWriterByKey(ctx, key)
delete(activeKeys, key)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't drop data on the final chunk.

IsFinal short-circuits before any write, so a sender that marks the last payload-bearing chunk as final loses its trailing bytes.

🧩 Suggested fix
-		if chunk.IsFinal {
-			if _, err := h.getOrCreateWriter(ctx, chunk); err != nil {
-				return fmt.Errorf("failed to create artifact writer: %w", err)
-			}
-			h.closeWriterByKey(ctx, key)
-			delete(activeKeys, key)
-			continue
-		}
-		if len(chunk.Data) == 0 {
+		if len(chunk.Data) == 0 && !chunk.IsFinal {
 			continue
 		}
 
-		writer, err := h.getOrCreateWriter(ctx, chunk)
-		if err != nil {
-			return fmt.Errorf("failed to create artifact writer: %w", err)
-		}
-		activeKeys[key] = struct{}{}
-
-		n, err := writer.write(chunk.Data)
-		if err != nil {
-			return fmt.Errorf("failed to write artifact data: %w", err)
+		if len(chunk.Data) > 0 {
+			writer, err := h.getOrCreateWriter(ctx, chunk)
+			if err != nil {
+				return fmt.Errorf("failed to create artifact writer: %w", err)
+			}
+			activeKeys[key] = struct{}{}
+
+			n, err := writer.write(chunk.Data)
+			if err != nil {
+				return fmt.Errorf("failed to write artifact data: %w", err)
+			}
+			if n > 0 {
+				bytesWritten += uint64(n) // `#nosec` G115 -- n is non-negative from successful Write
+			}
 		}
-		if n > 0 {
-			bytesWritten += uint64(n) // `#nosec` G115 -- n is non-negative from successful Write
+
+		if chunk.IsFinal {
+			if _, err := h.getOrCreateWriter(ctx, chunk); err != nil {
+				return fmt.Errorf("failed to create artifact writer: %w", err)
+			}
+			h.closeWriterByKey(ctx, key)
+			delete(activeKeys, key)
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/coordinator/artifact_handler.go` around lines 69 - 75, The
handler currently skips writing when chunk.IsFinal is true, dropping trailing
bytes; modify the branch handling chunk.IsFinal so it still obtains the writer
via h.getOrCreateWriter(ctx, chunk), writes the chunk's payload to that writer
(checking and returning any write error), then closes the writer via
h.closeWriterByKey(ctx, key) and deletes activeKeys[key]; ensure you reference
chunk.IsFinal, h.getOrCreateWriter, h.closeWriterByKey, key and handle errors
from the write before cleanup.

Comment thread internal/service/coordinator/handler.go Outdated
Comment on lines +1460 to +1492
if h.artifactDir == "" || incoming == nil {
return nil
}
if latestStatus != nil && latestStatus.ArchiveDir != "" {
incoming.ArchiveDir = latestStatus.ArchiveDir
} else {
if incoming.ArchiveDir == "" {
return nil
}
if attempt == nil {
return fmt.Errorf("dag run attempt is required to resolve artifact path")
}

dag, err := attempt.ReadDAG(ctx)
if err != nil {
return fmt.Errorf("read DAG for artifact path: %w", err)
}
if dag == nil {
return fmt.Errorf("read DAG for artifact path: DAG is nil")
}

baseDir := h.artifactDir
if dag.Artifacts != nil && dag.Artifacts.Dir != "" {
baseDir = dag.Artifacts.Dir
}
baseDir, err = eval.String(ctx, baseDir, eval.WithOSExpansion())
if err != nil {
return fmt.Errorf("expand artifact directory: %w", err)
}
baseDir = strings.TrimSpace(baseDir)
if baseDir == "" {
return fmt.Errorf("artifact directory is empty after expansion")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't short-circuit DAG-level artifact directories here.

This guard makes dag.Artifacts.Dir unreachable unless Handler.artifactDir is also set. In that configuration, the coordinator keeps the worker-local ArchiveDir, and the later artifact upload path is effectively dead even though the DAG itself has a valid artifact directory configured.

Suggested fix
 func (h *Handler) transformArtifactPaths(
 	ctx context.Context,
 	attempt exec.DAGRunAttempt,
 	latestStatus *exec.DAGRunStatus,
 	incoming *exec.DAGRunStatus,
 ) error {
-	if h.artifactDir == "" || incoming == nil {
+	if incoming == nil {
 		return nil
 	}
 	if latestStatus != nil && latestStatus.ArchiveDir != "" {
 		incoming.ArchiveDir = latestStatus.ArchiveDir
 	} else {
@@
-		baseDir := h.artifactDir
+		baseDir := h.artifactDir
 		if dag.Artifacts != nil && dag.Artifacts.Dir != "" {
 			baseDir = dag.Artifacts.Dir
 		}
+		baseDir = strings.TrimSpace(baseDir)
+		if baseDir == "" {
+			return fmt.Errorf("artifact directory is not configured")
+		}
 		baseDir, err = eval.String(ctx, baseDir, eval.WithOSExpansion())
 		if err != nil {
 			return fmt.Errorf("expand artifact directory: %w", err)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/coordinator/handler.go` around lines 1460 - 1492, The early
return currently checks h.artifactDir == "" || incoming == nil which prevents
using a DAG-level artifacts dir when Handler.artifactDir is empty; remove the
h.artifactDir check so the guard only returns when incoming == nil, then keep
the existing logic in resolve (call attempt.ReadDAG, use dag.Artifacts.Dir if
set, otherwise fall back to h.artifactDir, run eval.String with
eval.WithOSExpansion, trim and validate baseDir) so DAG-level artifact
directories are honored even when Handler.artifactDir is empty (refer to
h.artifactDir, incoming.ArchiveDir, latestStatus, attempt.ReadDAG,
dag.Artifacts.Dir, baseDir and eval.String).

Comment on lines +3386 to +3400
info, err := os.Stat(absPath)
if err != nil {
return api.ArtifactPreviewResponse{}, err
}
if info.IsDir() {
return api.ArtifactPreviewResponse{}, os.ErrNotExist
}

file, err := os.Open(filepath.Clean(absPath))
if err != nil {
return api.ArtifactPreviewResponse{}, err
}
defer func() {
_ = file.Close()
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject non-regular artifact paths before preview/download.

These helpers only filter out directories. FIFOs, sockets, and device files under ArchiveDir will still pass through; preview can hang on a pipe, and download can expose special files instead of a stored artifact.

🔒 Suggested fix
 func buildArtifactPreview(archiveDir, relPath string) (api.ArtifactPreviewResponse, error) {
 	absPath, err := resolveArtifactPath(archiveDir, relPath)
 	if err != nil {
 		return api.ArtifactPreviewResponse{}, err
 	}

 	info, err := os.Stat(absPath)
 	if err != nil {
 		return api.ArtifactPreviewResponse{}, err
 	}
-	if info.IsDir() {
+	if info.IsDir() || !info.Mode().IsRegular() {
 		return api.ArtifactPreviewResponse{}, os.ErrNotExist
 	}
 	info, err := file.Stat()
 	if err != nil {
 		_ = file.Close()
 		return nil, nil, err
 	}
-	if info.IsDir() {
+	if info.IsDir() || !info.Mode().IsRegular() {
 		_ = file.Close()
 		return nil, nil, os.ErrNotExist
 	}
 	return file, info, nil
 }

Also applies to: 3453-3461

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/frontend/api/v1/dagruns.go` around lines 3386 - 3400, The
current checks only reject directories using info.IsDir(), but they should also
reject non-regular files (FIFOs, sockets, devices) to avoid hanging or exposing
special files; update the logic around os.Stat(absPath) so that after getting
info you return an error (e.g., os.ErrNotExist) when !info.Mode().IsRegular()
instead of only when info.IsDir(), and keep the rest of the flow (opening
filepath.Clean(absPath) and deferring Close) unchanged; apply the same change to
the other occurrence referenced (the block around the later open at lines
3453-3461) so both preview and download paths consistently reject non-regular
files.

Comment on lines +99 to +102
dagRunStore := filedagrun.New(
cfg.Paths.DAGRunsDir,
filedagrun.WithArtifactDir(cfg.Paths.ArtifactDir),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Initialize ArtifactDir before wiring WithArtifactDir.

cfg.Paths.ArtifactDir is never set in this fixture, so Line 101 currently passes an empty path. That can bypass real artifact-dir behavior in this test setup.

Suggested fix
 		Paths: config.PathsConfig{
 			DataDir:            filepath.Join(tmpDir, "data"),
 			DAGsDir:            filepath.Join(tmpDir, "dags"),
 			DAGRunsDir:         filepath.Join(tmpDir, "data", "dag-runs"),
+			ArtifactDir:        filepath.Join(tmpDir, "data", "artifacts"),
 			QueueDir:           filepath.Join(tmpDir, "data", "queue"),
 			ProcDir:            filepath.Join(tmpDir, "data", "proc"),
 			ServiceRegistryDir: filepath.Join(tmpDir, "data", "service-registry"),
 			LogDir:             filepath.Join(tmpDir, "logs"),
 		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/scheduler/ha_health_test.go` around lines 99 - 102, The test
constructs dagRunStore via filedagrun.New and wires
WithArtifactDir(cfg.Paths.ArtifactDir) but never sets cfg.Paths.ArtifactDir, so
the fixture passes an empty path; set cfg.Paths.ArtifactDir to a valid non-empty
path (for example a t.TempDir() or the same base used for cfg.Paths.DAGRunsDir)
before calling filedagrun.New so WithArtifactDir receives a real artifact
directory and the test exercises artifact-dir behavior correctly.

Comment on lines +236 to +257
const buildArtifactDownloadUrl = (path: string) => {
const endpoint = isSubDAGRun
? `${config.apiURL}/dag-runs/${encodeURIComponent(dagRun.rootDAGRunName!)}/${encodeURIComponent(dagRun.rootDAGRunId!)}/sub-dag-runs/${encodeURIComponent(dagRun.dagRunId)}/artifacts/download`
: `${config.apiURL}/dag-runs/${encodeURIComponent(dagRun.name)}/${encodeURIComponent(dagRun.dagRunId)}/artifacts/download`;

const url = new URL(endpoint, window.location.origin);
url.searchParams.set('remoteNode', remoteNode);
url.searchParams.set('path', path);
return url;
};

const fetchArtifactDownload = async (path: string, signal?: AbortSignal) => {
const token = getAuthToken();
const response = await fetch(buildArtifactDownloadUrl(path).toString(), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal,
});
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`);
}
return response;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Keep artifact downloads on the shared API client path.

This download flow bypasses the generated client and duplicates auth/base-URL handling in a second code path.

As per coding guidelines, "Use client.GET and client.POST calls with remoteNode in query parameters for API interactions".

Also applies to: 404-422

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/features/dags/components/artifacts/ArtifactsTab.tsx` around lines 236
- 257, The buildArtifactDownloadUrl/fetchArtifactDownload flow uses a direct
fetch and duplicates auth/base-URL handling; replace this with the generated
shared API client by calling client.GET (or client.POST if required) for the
artifacts/download endpoint and pass remoteNode and path as query params,
ensuring you use the client instance that already handles base URL and auth
tokens; update fetchArtifactDownload to return the client response (and
propagate AbortSignal if client supports it) and remove
buildArtifactDownloadUrl, and make the same change for the other similar
handlers referenced (lines 404-422).

Comment on lines +259 to +312
const fetchTree = async () => {
if (!dagRun.artifactsAvailable) {
setTree([]);
setTreeError(null);
setPreview(null);
return;
}

setTreeLoading(true);
setTreeError(null);
try {
const request = await requestArtifactTree();

if (request.error) {
setTree([]);
setSelectedPath(null);
setPreview(null);
setTreeError(request.error.message || 'Failed to load artifacts');
return;
}

const items = request.data?.items ?? [];
const nextNodes = flattenNodes(items);
setTree(items);
setOpenDirs(new Set(collectDirectoryPaths(items)));

const firstFile = findFirstFile(items);
if (!firstFile) {
setSelectedPath(null);
setPreview(null);
return;
}

if (selectedPath && nextNodes.some((node) => node.path === selectedPath)) {
setPreview(null);
setPreviewVersion((current) => current + 1);
return;
}

setSelectedPath(firstFile.path);
} catch (error: unknown) {
setTree([]);
setSelectedPath(null);
setPreview(null);
setTreeError(error instanceof Error ? error.message : 'Failed to load artifacts');
} finally {
setTreeLoading(false);
}
};

useEffect(() => {
void fetchTree();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dagRun.artifactsAvailable, dagRun.dagRunId, dagRun.rootDAGRunId, remoteNode]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tie tree loading to the current DAG/run request.

fetchTree() mutates state after await with no stale-request guard, and this effect only keys off run IDs. If the user switches DAGs/nodes quickly—or lands on another DAG that reuses the same run ID—an older response can overwrite the current tree and selection.

Suggested fix
-  useEffect(() => {
-    void fetchTree();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [dagRun.artifactsAvailable, dagRun.dagRunId, dagRun.rootDAGRunId, remoteNode]);
+  useEffect(() => {
+    let cancelled = false;
+
+    const loadTree = async () => {
+      await fetchTree();
+      if (cancelled) {
+        return;
+      }
+    };
+
+    void loadTree();
+    return () => {
+      cancelled = true;
+    };
+  }, [
+    dagRun.artifactsAvailable,
+    dagRun.dagRunId,
+    dagRun.name,
+    dagRun.rootDAGRunId,
+    dagRun.rootDAGRunName,
+    isSubDAGRun,
+    remoteNode,
+  ]);

You’ll also want the fetchTree state writes guarded by the same cancellation/request-identity check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/features/dags/components/artifacts/ArtifactsTab.tsx` around lines 259
- 312, fetchTree can write state after async awaits and overwrite newer runs;
add a per-call request identity (e.g., a serial requestId or AbortController) at
the top of fetchTree and capture it in a local variable before any await, then
check that identity (or !aborted) before every state mutation (setTree,
setOpenDirs, setSelectedPath, setPreview, setTreeError, setTreeLoading,
setPreviewVersion) in the try, catch and finally blocks; also wire that
identity/abort to the useEffect so each invocation creates a fresh id/controller
and cancels or ignores prior requests when dagRun.artifactsAvailable,
dagRun.dagRunId, dagRun.rootDAGRunId or remoteNode change, and ensure
requestArtifactTree is cancellable or its result is ignored when the
id/controller no longer matches.

@yohamta0 yohamta0 merged commit 84f7492 into main Apr 12, 2026
4 checks passed
@yohamta0 yohamta0 deleted the feat/dag-run-artifacts branch April 12, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant