Files
kandev/apps/backend/internal/agentctl/server/process/workspace_tracker.go
T
João Salavisa 2e108b040f refactor: session resumption cleanup and AgentInstance → AgentExecution rename (#23)
* feat: session resumption with ACP session ID support

- Add useSessionResumption hook for automatic session recovery on page reload
- Fix ACPSessionID not being set on AgentInstance in Launch() and createInstance()
- Add task.session.status and task.session.resume WebSocket actions
- Fix shell terminal retry logic for resumed sessions
- Fix taskSessionState reading from Zustand store instead of local state
- Remove dead code: unused stream handling functions in lifecycle/manager.go
- Fix test mock interfaces (CleanupStaleInstanceByTaskID, NewManager signature)
- Add enhanced logging for session resumption debugging
- Simplify page-client.tsx state merging logic

* refactor: remove unused AgentController (dead code)

- Delete agent_controller.go - entirely unused duplicate of Manager methods
- Remove agentController field from Manager struct
- Remove AgentController initialization in NewManager

The AgentController duplicated StartAgentProcess, initializeACPSession, and
PromptAgent from Manager but was never called. All agent control operations
go through Manager's own methods.

* refactor: remove unused MemoryRepository (dead code)

- Delete memory.go - entire MemoryRepository implementation unused
  (replaced by SQLite repository)
- Delete memory_test.go - tests for removed MemoryRepository

Found via 'deadcode' tool analysis.

* refactor: consolidate agentctl packages into server/client structure

Reorganize the confusing dual agentctl packages:
- internal/agentctl/* → internal/agentctl/server/
- internal/agent/agentctl/* → internal/agentctl/client/

Structure is now:
  internal/agentctl/
  ├── client/      - HTTP client for talking to agentctl servers
  │   ├── client.go
  │   ├── standalone.go
  │   ├── acp.go
  │   └── launcher/
  ├── server/      - agentctl server implementation (runs in containers)
  │   ├── api/
  │   ├── adapter/
  │   ├── acp/
  │   ├── config/
  │   ├── instance/
  │   ├── process/
  │   └── shell/
  └── types/       - Shared types used by both client and server

Also consolidated shared types (GitStatusUpdate, FileInfo, etc.) from
process/types.go into the shared types/ package.

* refactor: rename AgentInstance to AgentExecution for clarity

Rename AgentInstance -> AgentExecution across the entire codebase to better
reflect what it represents: an agent actively working on a task, not just
a generic 'instance'.

Changes:
- AgentInstance struct -> AgentExecution
- InstanceStore -> ExecutionStore (new file: execution_store.go)
- RecoveredInstance -> RecoveredExecution
- All method names: GetInstance* -> GetExecution*, ListInstances -> ListExecutions, etc.
- All parameters: instanceID -> executionID
- All local variables: instance -> execution
- DTOs and JSON tags: agent_instance_id -> agent_execution_id
- Database columns: agent_instance_id -> agent_execution_id

Also consolidates duplicate prompt logic:
- Merge sendInitialPrompt and SendPrompt into single SendPrompt method
- Add validateStatus parameter to differentiate initial vs follow-up prompts

Also extracts ACP session management to session.go:
- Move initializeACPSession logic to SessionManager.InitializeAndPrompt
- Move PromptAgent logic to SessionManager.SendPrompt
- Manager now delegates to SessionManager for all prompt operations

Also removes duplicate event publishing:
- Remove 9 duplicate m.publish*() methods from manager.go
- Use existing EventPublisher methods via m.eventPublisher.Publish*()

Also extracts types to dedicated file:
- Move AgentExecution, LaunchRequest, PromptResult, etc. to types.go

Net reduction: ~600 lines removed from manager.go through deduplication
and better separation of concerns.

* fix(backend): Docker runtime now creates instance via ControlServer

The Docker runtime was incorrectly creating an agentctl.Client pointing
directly to port 9999 (the ControlServer port), then trying to call
instance-specific APIs like /api/v1/agent/configure which only exist
on per-instance servers.

Fixed by making Docker runtime follow the same flow as standalone mode:
- LaunchContainer now uses StandaloneCtl to create an instance via the
  control API, then creates Client pointing to the instance port
- RecoverInstances queries the ControlServer to get the instance port
- Added waitForHealth helper to wait for agentctl to be ready

Also improved HTTP error handling in agentctl client:
- All client methods now check HTTP status codes before JSON decoding
- Better error messages include status code and response body

* fix: restore session resumption imports and variables

* fix: add sessionId prop back to TaskLayout
2026-01-16 22:14:19 +00:00

773 lines
20 KiB
Go

package process
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// WorkspaceTracker monitors workspace changes and provides real-time updates
type WorkspaceTracker struct {
workDir string
logger *logger.Logger
// Current state
currentStatus types.GitStatusUpdate
currentFiles types.FileListUpdate
mu sync.RWMutex
// Subscribers
gitStatusSubscribers map[types.GitStatusSubscriber]struct{}
filesSubscribers map[types.FilesSubscriber]struct{}
fileChangeSubscribers map[types.FileChangeSubscriber]struct{}
subMu sync.RWMutex
// Filesystem watcher
watcher *fsnotify.Watcher
// Debounce channel for filesystem change events
fsChangeTrigger chan struct{}
// Control
stopCh chan struct{}
wg sync.WaitGroup
}
// NewWorkspaceTracker creates a new workspace tracker
func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Error("failed to create filesystem watcher", zap.Error(err))
watcher = nil
}
return &WorkspaceTracker{
workDir: workDir,
logger: log.WithFields(zap.String("component", "workspace-tracker")),
gitStatusSubscribers: make(map[types.GitStatusSubscriber]struct{}),
filesSubscribers: make(map[types.FilesSubscriber]struct{}),
fileChangeSubscribers: make(map[types.FileChangeSubscriber]struct{}),
watcher: watcher,
fsChangeTrigger: make(chan struct{}, 1), // Buffered to avoid blocking
stopCh: make(chan struct{}),
}
}
// Start begins monitoring the workspace
func (wt *WorkspaceTracker) Start(ctx context.Context) {
wt.wg.Add(1)
go wt.monitorLoop(ctx)
// Start filesystem watcher if available
if wt.watcher != nil {
wt.wg.Add(1)
go wt.watchFilesystem(ctx)
// Add workspace root to watcher
if err := wt.addDirectoryRecursive(wt.workDir); err != nil {
wt.logger.Error("failed to watch workspace directory", zap.Error(err))
}
}
}
// Stop stops the workspace tracker
func (wt *WorkspaceTracker) Stop() {
close(wt.stopCh)
if wt.watcher != nil {
if err := wt.watcher.Close(); err != nil {
wt.logger.Debug("failed to close watcher", zap.Error(err))
}
}
wt.wg.Wait()
wt.logger.Info("workspace tracker stopped")
}
// monitorLoop handles debounced filesystem change events
// When files change, we wait for activity to settle, then update everything at once
func (wt *WorkspaceTracker) monitorLoop(ctx context.Context) {
defer wt.wg.Done()
// Debounce duration - wait this long after last file change before updating
const debounceDuration = 300 * time.Millisecond
var debounceTimer *time.Timer
var pendingUpdate bool
// Initial update
wt.updateGitStatus(ctx)
wt.updateFiles(ctx)
for {
select {
case <-ctx.Done():
if debounceTimer != nil {
debounceTimer.Stop()
}
return
case <-wt.stopCh:
if debounceTimer != nil {
debounceTimer.Stop()
}
return
case <-wt.fsChangeTrigger:
// File change detected - start or reset debounce timer
if debounceTimer == nil {
debounceTimer = time.NewTimer(debounceDuration)
} else {
// Reset the timer if already running
if !debounceTimer.Stop() {
select {
case <-debounceTimer.C:
default:
}
}
debounceTimer.Reset(debounceDuration)
}
pendingUpdate = true
case <-func() <-chan time.Time {
if debounceTimer != nil {
return debounceTimer.C
}
return nil
}():
// Debounce timer fired - update everything
if pendingUpdate {
wt.updateGitStatus(ctx)
wt.updateFiles(ctx)
// Notify file change subscribers that workspace changed
wt.notifyFileChangeSubscribers(types.FileChangeNotification{
Timestamp: time.Now(),
Path: "",
Operation: "refresh",
})
pendingUpdate = false
}
debounceTimer = nil
}
}
}
// updateGitStatus updates the git status
func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) {
status, err := wt.getGitStatus(ctx)
if err != nil {
return
}
wt.mu.Lock()
wt.currentStatus = status
wt.mu.Unlock()
// Notify subscribers
wt.notifyGitStatusSubscribers(status)
}
// getGitStatus retrieves the current git status
func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) {
update := types.GitStatusUpdate{
Timestamp: time.Now(),
Modified: []string{},
Added: []string{},
Deleted: []string{},
Untracked: []string{},
Renamed: []string{},
Files: make(map[string]types.FileInfo),
}
// Get current branch
branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
branchCmd.Dir = wt.workDir
branchOut, err := branchCmd.Output()
if err != nil {
return update, err
}
update.Branch = strings.TrimSpace(string(branchOut))
// Get remote branch
remoteCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "@{upstream}")
remoteCmd.Dir = wt.workDir
if remoteOut, err := remoteCmd.Output(); err == nil {
update.RemoteBranch = strings.TrimSpace(string(remoteOut))
}
// Get ahead/behind counts
if update.RemoteBranch != "" {
countCmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", update.Branch+"..."+update.RemoteBranch)
countCmd.Dir = wt.workDir
if countOut, err := countCmd.Output(); err == nil {
parts := strings.Fields(string(countOut))
if len(parts) == 2 {
update.Ahead, _ = strconv.Atoi(parts[0])
update.Behind, _ = strconv.Atoi(parts[1])
}
}
}
// Get file status using git status --porcelain
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
statusCmd.Dir = wt.workDir
statusOut, err := statusCmd.Output()
if err != nil {
return update, err
}
// Parse porcelain output
lines := strings.Split(string(statusOut), "\n")
for _, line := range lines {
if len(line) < 4 {
continue
}
statusCode := line[:2]
filePath := strings.TrimSpace(line[3:])
fileInfo := types.FileInfo{
Path: filePath,
}
// Parse status code
switch {
case strings.HasPrefix(statusCode, "M ") || strings.HasPrefix(statusCode, " M"):
fileInfo.Status = "modified"
update.Modified = append(update.Modified, filePath)
case strings.HasPrefix(statusCode, "A "):
fileInfo.Status = "added"
update.Added = append(update.Added, filePath)
case strings.HasPrefix(statusCode, "D ") || strings.HasPrefix(statusCode, " D"):
fileInfo.Status = "deleted"
update.Deleted = append(update.Deleted, filePath)
case strings.HasPrefix(statusCode, "??"):
fileInfo.Status = "untracked"
update.Untracked = append(update.Untracked, filePath)
case strings.HasPrefix(statusCode, "R "):
fileInfo.Status = "renamed"
// Renamed files have format "old -> new"
if idx := strings.Index(filePath, " -> "); idx != -1 {
fileInfo.OldPath = filePath[:idx]
fileInfo.Path = filePath[idx+4:]
}
update.Renamed = append(update.Renamed, filePath)
}
update.Files[filePath] = fileInfo
}
// Enrich file info with diff data (additions, deletions, and actual diff content)
wt.enrichWithDiffData(ctx, &update)
return update, nil
}
// enrichWithDiffData adds diff information (additions, deletions, diff content) to file info
func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) {
// Determine the base ref to compare against
// Use remote branch if available, otherwise use HEAD
baseRef := "HEAD"
if update.RemoteBranch != "" {
baseRef = update.RemoteBranch
}
// Get numstat for additions/deletions count (compare against base branch)
numstatCmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef)
numstatCmd.Dir = wt.workDir
numstatOut, err := numstatCmd.Output()
if err != nil {
wt.logger.Debug("failed to get numstat", zap.Error(err))
return
}
// Parse numstat output and update file info
lines := strings.Split(string(numstatOut), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 3 {
continue
}
additions, _ := strconv.Atoi(parts[0])
deletions, _ := strconv.Atoi(parts[1])
filePath := parts[2]
// Update existing file info if it exists
if fileInfo, exists := update.Files[filePath]; exists {
fileInfo.Additions = additions
fileInfo.Deletions = deletions
// Get the actual diff content for this file (compare against base branch)
diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath)
diffCmd.Dir = wt.workDir
if diffOut, err := diffCmd.Output(); err == nil {
fileInfo.Diff = string(diffOut)
}
update.Files[filePath] = fileInfo
} else {
// File might be committed but not in status (because it's committed)
// Add it to the Files map
fileInfo := types.FileInfo{
Path: filePath,
Status: "modified", // Assume modified if it has a diff
Additions: additions,
Deletions: deletions,
}
// Get the actual diff content for this file
diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath)
diffCmd.Dir = wt.workDir
if diffOut, err := diffCmd.Output(); err == nil {
fileInfo.Diff = string(diffOut)
}
update.Files[filePath] = fileInfo
update.Modified = append(update.Modified, filePath)
}
}
// Also check for staged changes
stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--numstat")
stagedCmd.Dir = wt.workDir
stagedOut, err := stagedCmd.Output()
if err != nil {
return
}
lines = strings.Split(string(stagedOut), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 3 {
continue
}
additions, _ := strconv.Atoi(parts[0])
deletions, _ := strconv.Atoi(parts[1])
filePath := parts[2]
// Update existing file info if it exists
if fileInfo, exists := update.Files[filePath]; exists {
// Add staged changes to the counts
fileInfo.Additions += additions
fileInfo.Deletions += deletions
// Get the staged diff content
diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--", filePath)
diffCmd.Dir = wt.workDir
if diffOut, err := diffCmd.Output(); err == nil {
// Append staged diff to existing diff
if fileInfo.Diff != "" {
fileInfo.Diff += "\n\n--- Staged changes ---\n" + string(diffOut)
} else {
fileInfo.Diff = string(diffOut)
}
}
update.Files[filePath] = fileInfo
}
}
// For untracked files, show the entire file content as "added"
for filePath, fileInfo := range update.Files {
if fileInfo.Status == "untracked" {
// Show file content as diff (all additions)
catCmd := exec.CommandContext(ctx, "cat", filePath)
catCmd.Dir = wt.workDir
if catOut, err := catCmd.Output(); err == nil {
content := string(catOut)
lines := strings.Split(content, "\n")
fileInfo.Additions = len(lines)
fileInfo.Deletions = 0
// Format as a diff
var diffBuilder strings.Builder
diffBuilder.WriteString("--- /dev/null\n")
diffBuilder.WriteString("+++ b/" + filePath + "\n")
diffBuilder.WriteString("@@ -0,0 +1," + strconv.Itoa(len(lines)) + " @@\n")
for _, line := range lines {
diffBuilder.WriteString("+" + line + "\n")
}
fileInfo.Diff = diffBuilder.String()
update.Files[filePath] = fileInfo
}
}
}
}
// updateFiles updates the file listing
func (wt *WorkspaceTracker) updateFiles(ctx context.Context) {
files, err := wt.getFileList(ctx)
if err != nil {
wt.logger.Debug("failed to get file list", zap.Error(err))
return
}
wt.mu.Lock()
wt.currentFiles = files
wt.mu.Unlock()
// Notify subscribers
wt.notifyFilesSubscribers(files)
}
// getFileList retrieves the list of files in the workspace
func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpdate, error) {
update := types.FileListUpdate{
Timestamp: time.Now(),
Files: []types.FileEntry{},
}
// Use git ls-files to get tracked files
cmd := exec.CommandContext(ctx, "git", "ls-files")
cmd.Dir = wt.workDir
out, err := cmd.Output()
if err != nil {
return update, err
}
lines := strings.Split(string(out), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
update.Files = append(update.Files, types.FileEntry{
Path: line,
IsDir: false,
})
}
return update, nil
}
// Subscriber management methods
// SubscribeGitStatus creates a new git status subscriber
func (wt *WorkspaceTracker) SubscribeGitStatus() types.GitStatusSubscriber {
sub := make(types.GitStatusSubscriber, 10)
wt.subMu.Lock()
wt.gitStatusSubscribers[sub] = struct{}{}
wt.subMu.Unlock()
// Send current status immediately
wt.mu.RLock()
current := wt.currentStatus
wt.mu.RUnlock()
if current.Timestamp.IsZero() {
current.Timestamp = time.Now()
}
select {
case sub <- current:
default:
}
return sub
}
// UnsubscribeGitStatus removes a git status subscriber
func (wt *WorkspaceTracker) UnsubscribeGitStatus(sub types.GitStatusSubscriber) {
wt.subMu.Lock()
delete(wt.gitStatusSubscribers, sub)
wt.subMu.Unlock()
close(sub)
}
// SubscribeFiles creates a new files subscriber
func (wt *WorkspaceTracker) SubscribeFiles() types.FilesSubscriber {
sub := make(types.FilesSubscriber, 10)
wt.subMu.Lock()
wt.filesSubscribers[sub] = struct{}{}
wt.subMu.Unlock()
// Send current files immediately
wt.mu.RLock()
current := wt.currentFiles
wt.mu.RUnlock()
if current.Timestamp.IsZero() {
current.Timestamp = time.Now()
}
select {
case sub <- current:
default:
}
return sub
}
// UnsubscribeFiles removes a files subscriber
func (wt *WorkspaceTracker) UnsubscribeFiles(sub types.FilesSubscriber) {
wt.subMu.Lock()
delete(wt.filesSubscribers, sub)
wt.subMu.Unlock()
close(sub)
}
// Notification methods
// notifyGitStatusSubscribers notifies all git status subscribers
func (wt *WorkspaceTracker) notifyGitStatusSubscribers(update types.GitStatusUpdate) {
wt.subMu.RLock()
defer wt.subMu.RUnlock()
for sub := range wt.gitStatusSubscribers {
select {
case sub <- update:
default:
// Subscriber is slow, skip
}
}
}
// notifyFilesSubscribers notifies all files subscribers
func (wt *WorkspaceTracker) notifyFilesSubscribers(update types.FileListUpdate) {
wt.subMu.RLock()
defer wt.subMu.RUnlock()
for sub := range wt.filesSubscribers {
select {
case sub <- update:
default:
// Subscriber is slow, skip
}
}
}
// SubscribeFileChanges creates a new file change subscriber
func (wt *WorkspaceTracker) SubscribeFileChanges() types.FileChangeSubscriber {
sub := make(types.FileChangeSubscriber, 100)
wt.subMu.Lock()
wt.fileChangeSubscribers[sub] = struct{}{}
wt.subMu.Unlock()
return sub
}
// UnsubscribeFileChanges removes a file change subscriber
func (wt *WorkspaceTracker) UnsubscribeFileChanges(sub types.FileChangeSubscriber) {
wt.subMu.Lock()
delete(wt.fileChangeSubscribers, sub)
wt.subMu.Unlock()
close(sub)
}
// notifyFileChangeSubscribers notifies all file change subscribers
func (wt *WorkspaceTracker) notifyFileChangeSubscribers(notification types.FileChangeNotification) {
wt.subMu.RLock()
defer wt.subMu.RUnlock()
for sub := range wt.fileChangeSubscribers {
select {
case sub <- notification:
default:
// Subscriber is slow, skip
}
}
}
// watchFilesystem watches for filesystem changes and triggers debounced updates
func (wt *WorkspaceTracker) watchFilesystem(ctx context.Context) {
defer wt.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-wt.stopCh:
return
case event, ok := <-wt.watcher.Events:
if !ok {
return
}
// If a directory was created, watch it
if event.Op&fsnotify.Create == fsnotify.Create {
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
if err := wt.addDirectoryRecursive(event.Name); err != nil {
wt.logger.Debug("failed to watch new directory", zap.Error(err))
}
}
}
// Trigger debounced update (non-blocking)
select {
case wt.fsChangeTrigger <- struct{}{}:
default:
// Channel full, update already pending
}
case err, ok := <-wt.watcher.Errors:
if !ok {
return
}
wt.logger.Debug("filesystem watcher error", zap.Error(err))
}
}
}
// addDirectoryRecursive adds a directory and all its subdirectories to the watcher
func (wt *WorkspaceTracker) addDirectoryRecursive(dir string) error {
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip hidden directories and common ignore patterns
if d.IsDir() {
name := d.Name()
if name == ".git" || name == "node_modules" || name == ".next" || name == "dist" || name == "build" {
return filepath.SkipDir
}
// Add directory to watcher
if err := wt.watcher.Add(path); err != nil {
wt.logger.Debug("failed to watch directory", zap.String("path", path), zap.Error(err))
}
}
return nil
})
}
// GetFileTree returns the file tree for a given path and depth
func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileTreeNode, error) {
// Resolve the full path
fullPath := filepath.Join(wt.workDir, reqPath)
// Check if path exists
info, err := os.Stat(fullPath)
if err != nil {
return nil, fmt.Errorf("path not found: %w", err)
}
// Build the tree
node, err := wt.buildFileTreeNode(fullPath, reqPath, info, depth, 0)
if err != nil {
return nil, err
}
return node, nil
}
// buildFileTreeNode recursively builds a file tree node
func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*types.FileTreeNode, error) {
node := &types.FileTreeNode{
Name: info.Name(),
Path: relPath,
IsDir: info.IsDir(),
Size: info.Size(),
}
// If it's a file or we've reached max depth, return
if !info.IsDir() || (maxDepth > 0 && currentDepth >= maxDepth) {
return node, nil
}
// Read directory contents
entries, err := os.ReadDir(fullPath)
if err != nil {
return node, nil // Return node without children on error
}
// Build children
node.Children = make([]*types.FileTreeNode, 0, len(entries))
for _, entry := range entries {
// Skip hidden files and common ignore patterns
name := entry.Name()
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
continue
}
if name == "node_modules" || name == ".next" || name == "dist" || name == "build" {
continue
}
childFullPath := filepath.Join(fullPath, name)
childRelPath := filepath.Join(relPath, name)
childInfo, err := entry.Info()
if err != nil {
continue
}
childNode, err := wt.buildFileTreeNode(childFullPath, childRelPath, childInfo, maxDepth, currentDepth+1)
if err != nil {
continue
}
node.Children = append(node.Children, childNode)
}
return node, nil
}
// GetFileContent returns the content of a file
func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error) {
// Resolve the full path
fullPath := filepath.Join(wt.workDir, reqPath)
// Check if file exists and is a regular file
info, err := os.Stat(fullPath)
if err != nil {
return "", 0, fmt.Errorf("file not found: %w", err)
}
if info.IsDir() {
return "", 0, fmt.Errorf("path is a directory, not a file")
}
// Check file size (limit to 10MB)
const maxFileSize = 10 * 1024 * 1024
if info.Size() > maxFileSize {
return "", info.Size(), fmt.Errorf("file too large (max 10MB)")
}
// Read file content
file, err := os.Open(fullPath)
if err != nil {
return "", 0, fmt.Errorf("failed to open file: %w", err)
}
defer func() {
_ = file.Close()
}()
content, err := io.ReadAll(file)
if err != nil {
return "", 0, fmt.Errorf("failed to read file: %w", err)
}
return string(content), info.Size(), nil
}