• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / cronn / 21509077922

30 Jan 2026 08:14AM UTC coverage: 75.286% (+0.4%) from 74.846%
21509077922

push

github

web-flow
Add JSON API endpoints for job history and execution logs (#59)

* docs: add plan for JSON API history and logs endpoints

Add implementation plan for new JSON API endpoints:
- GET /api/v1/jobs/{id}/history
- GET /api/v1/jobs/{id}/executions/{exec_id}/logs

Also includes refactoring to separate JSON API code into api.go.

* feat(web): add JSON API endpoints for job history and execution logs

Create new api.go with:
- APIExecution, APIHistoryResponse, APILogsResponse types
- handleAPIJobHistory for GET /api/v1/jobs/{id}/history
- handleAPIExecutionLogs for GET /api/v1/jobs/{id}/executions/{exec_id}/logs
- Helper functions toAPIExecution and toAPIJob for type conversion
- writeJSON and writeJSONError utilities for JSON responses

Moves existing API code from handlers.go:
- APIStatusResponse, APIJob, APIStats types
- handleAPIStatus handler

* test(web): add tests for JSON API endpoints

Add comprehensive test coverage for handleAPIStatus, handleAPIJobHistory,
and handleAPIExecutionLogs handlers in new api_test.go file.

* chore: complete JSON API plan with final validation

- Remove unused json import from handlers_test.go
- Move completed plan to docs/plans/completed/

* docs: add documentation for JSON API history and logs endpoints

document the new API endpoints for job execution history
and execution logs in the README JSON API section

* fix: address codex review findings

- add ErrNotFound sentinel error in persistence package
- GetExecutionByID returns ErrNotFound for missing executions
- API handler distinguishes 404 (not found) from 500 (internal error)
- job ID mismatch returns 404 to prevent execution enumeration
- update tests to verify new error handling behavior
- use context in SQLite initialization and migration methods

150 of 163 new or added lines in 3 files covered. (92.02%)

3 existing lines in 2 files now uncovered.

2766 of 3674 relevant lines covered (75.29%)

36.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.52
/app/web/persistence/sqlite.go
1
package persistence
2

3
import (
4
        "context"
5
        "database/sql"
6
        "errors"
7
        "fmt"
8
        "sync"
9
        "time"
10

11
        "github.com/jmoiron/sqlx"
12
        _ "modernc.org/sqlite" // sqlite driver
13

14
        "github.com/umputun/cronn/app/service/request"
15
        "github.com/umputun/cronn/app/web/enums"
16
)
17

18
// ErrNotFound is returned when a requested resource does not exist
19
var ErrNotFound = errors.New("not found")
20

21
// JobInfo represents a cron job with its execution state
22
type JobInfo struct {
23
        ID         string          `db:"id"`
24
        Command    string          `db:"command"`
25
        Schedule   string          `db:"schedule"`
26
        NextRun    time.Time       `db:"next_run"`
27
        LastRun    time.Time       `db:"last_run"`
28
        LastStatus enums.JobStatus `db:"last_status"`
29
        IsRunning  bool            `db:"-"` // not stored in DB
30
        Enabled    bool            `db:"enabled"`
31
        CreatedAt  time.Time       `db:"created_at"`
32
        UpdatedAt  time.Time       `db:"updated_at"`
33
        SortIndex  int             `db:"sort_index"`
34
}
35

36
// ExecutionInfo represents a single job execution record
37
type ExecutionInfo struct {
38
        ID              int             `db:"id"`
39
        JobID           string          `db:"job_id"`
40
        StartedAt       time.Time       `db:"started_at"`
41
        FinishedAt      time.Time       `db:"finished_at"`
42
        Status          enums.JobStatus `db:"status"`
43
        ExitCode        int             `db:"exit_code"`
44
        ExecutedCommand string          `db:"executed_command"`
45
        Output          string          `db:"output"`
46
}
47

48
// SQLiteStore implements persistence using SQLite
49
type SQLiteStore struct {
50
        db *sqlx.DB
51
        mu sync.RWMutex // protects concurrent database access
52
}
53

54
// NewSQLiteStore creates a new SQLite store and initializes the database
55
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
23✔
56
        db, err := sqlx.Open("sqlite", dbPath)
23✔
57
        if err != nil {
23✔
58
                return nil, fmt.Errorf("failed to open database: %w", err)
×
59
        }
×
60

61
        ctx := context.Background()
23✔
62

23✔
63
        // helper to execute pragma with proper error handling
23✔
64
        execPragma := func(pragma, errMsgPrefix string) error {
68✔
65
                if _, err := db.ExecContext(ctx, pragma); err != nil {
46✔
66
                        if closeErr := db.Close(); closeErr != nil {
1✔
67
                                return fmt.Errorf("%s: %w (also failed to close db: %v)", errMsgPrefix, err, closeErr)
×
68
                        }
×
69
                        return fmt.Errorf("%s: %w", errMsgPrefix, err)
1✔
70
                }
71
                return nil
44✔
72
        }
73

74
        // enable WAL mode for better concurrency
75
        if err := execPragma("PRAGMA journal_mode=WAL", "failed to set WAL mode"); err != nil {
24✔
76
                return nil, err
1✔
77
        }
1✔
78

79
        // set busy timeout to wait when database is locked
80
        if err := execPragma("PRAGMA busy_timeout=5000", "failed to set busy timeout"); err != nil {
22✔
81
                return nil, err
×
82
        }
×
83

84
        store := &SQLiteStore{db: db}
22✔
85

22✔
86
        // initialize database tables
22✔
87
        if err := store.initialize(ctx); err != nil {
22✔
88
                _ = db.Close()
×
89
                return nil, fmt.Errorf("failed to initialize database: %w", err)
×
90
        }
×
91

92
        return store, nil
22✔
93
}
94

95
// initialize creates the database schema
96
func (s *SQLiteStore) initialize(ctx context.Context) error {
22✔
97
        queries := []string{
22✔
98
                `CREATE TABLE IF NOT EXISTS jobs (
22✔
99
                        id TEXT PRIMARY KEY,
22✔
100
                        command TEXT NOT NULL,
22✔
101
                        schedule TEXT NOT NULL,
22✔
102
                        next_run DATETIME,
22✔
103
                        last_run DATETIME,
22✔
104
                        last_status TEXT,
22✔
105
                        enabled BOOLEAN DEFAULT 1,
22✔
106
                        created_at DATETIME,
22✔
107
                        updated_at DATETIME,
22✔
108
                        sort_index INTEGER DEFAULT 0
22✔
109
                )`,
22✔
110
                `CREATE TABLE IF NOT EXISTS executions (
22✔
111
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
22✔
112
                        job_id TEXT,
22✔
113
                        started_at DATETIME,
22✔
114
                        finished_at DATETIME,
22✔
115
                        status TEXT,
22✔
116
                        exit_code INTEGER,
22✔
117
                        executed_command TEXT,
22✔
118
                        output TEXT,
22✔
119
                        FOREIGN KEY (job_id) REFERENCES jobs(id)
22✔
120
                )`,
22✔
121
                `CREATE INDEX IF NOT EXISTS idx_executions_job_started ON executions(job_id, started_at DESC)`,
22✔
122
        }
22✔
123

22✔
124
        for _, query := range queries {
88✔
125
                if _, err := s.db.ExecContext(ctx, query); err != nil {
66✔
126
                        return fmt.Errorf("failed to execute query: %w", err)
×
127
                }
×
128
        }
129

130
        // run schema migrations
131
        if err := s.migrate(ctx); err != nil {
22✔
132
                return fmt.Errorf("failed to run migrations: %w", err)
×
133
        }
×
134

135
        return nil
22✔
136
}
137

138
// migrate performs schema migrations for existing databases
139
func (s *SQLiteStore) migrate(ctx context.Context) error {
22✔
140
        // check if executed_command column exists
22✔
141
        var executedCommandExists bool
22✔
142
        err := s.db.QueryRowContext(ctx, `
22✔
143
                SELECT COUNT(*) > 0
22✔
144
                FROM pragma_table_info('executions')
22✔
145
                WHERE name = 'executed_command'
22✔
146
        `).Scan(&executedCommandExists)
22✔
147
        if err != nil {
22✔
148
                return fmt.Errorf("failed to check for executed_command column: %w", err)
×
149
        }
×
150

151
        // add executed_command column if it doesn't exist
152
        if !executedCommandExists {
23✔
153
                if _, execErr := s.db.ExecContext(ctx, "ALTER TABLE executions ADD COLUMN executed_command TEXT DEFAULT ''"); execErr != nil {
1✔
154
                        return fmt.Errorf("failed to add executed_command column: %w", execErr)
×
155
                }
×
156
        }
157

158
        // check if output column exists
159
        var outputExists bool
22✔
160
        err = s.db.QueryRowContext(ctx, `
22✔
161
                SELECT COUNT(*) > 0
22✔
162
                FROM pragma_table_info('executions')
22✔
163
                WHERE name = 'output'
22✔
164
        `).Scan(&outputExists)
22✔
165
        if err != nil {
22✔
166
                return fmt.Errorf("failed to check for output column: %w", err)
×
167
        }
×
168

169
        // add output column if it doesn't exist
170
        if !outputExists {
24✔
171
                if _, outErr := s.db.ExecContext(ctx, "ALTER TABLE executions ADD COLUMN output TEXT DEFAULT ''"); outErr != nil {
2✔
172
                        return fmt.Errorf("failed to add output column: %w", outErr)
×
173
                }
×
174
        }
175

176
        return nil
22✔
177
}
178

179
// LoadJobs retrieves all jobs from the database
180
func (s *SQLiteStore) LoadJobs() ([]JobInfo, error) {
4✔
181
        s.mu.RLock()
4✔
182
        defer s.mu.RUnlock()
4✔
183

4✔
184
        var jobs []JobInfo
4✔
185
        err := s.db.Select(&jobs, `
4✔
186
                SELECT id, command, schedule, next_run, last_run, last_status, enabled, 
4✔
187
                       created_at, updated_at, sort_index
4✔
188
                FROM jobs
4✔
189
                ORDER BY sort_index`)
4✔
190
        if err != nil {
5✔
191
                return nil, fmt.Errorf("failed to query jobs: %w", err)
1✔
192
        }
1✔
193

194
        // ensure we return empty slice, not nil
195
        if jobs == nil {
4✔
196
                jobs = []JobInfo{}
1✔
197
        }
1✔
198

199
        return jobs, nil
3✔
200
}
201

202
// SaveJobs persists multiple jobs in a transaction
203
func (s *SQLiteStore) SaveJobs(jobs []JobInfo) error {
4✔
204
        s.mu.Lock()
4✔
205
        defer s.mu.Unlock()
4✔
206

4✔
207
        tx, err := s.db.Beginx()
4✔
208
        if err != nil {
4✔
209
                return fmt.Errorf("failed to begin transaction: %w", err)
×
210
        }
×
211
        defer tx.Rollback()
4✔
212

4✔
213
        for idx, job := range jobs {
9✔
214
                // set sort_index based on position
5✔
215
                job.SortIndex = idx
5✔
216

5✔
217
                _, err := tx.NamedExec(`
5✔
218
                        INSERT OR REPLACE INTO jobs 
5✔
219
                        (id, command, schedule, next_run, last_run, last_status, enabled, created_at, updated_at, sort_index)
5✔
220
                        VALUES (:id, :command, :schedule, :next_run, :last_run, :last_status, :enabled, :created_at, :updated_at, :sort_index)`,
5✔
221
                        job)
5✔
222
                if err != nil {
6✔
223
                        return fmt.Errorf("failed to save job %s: %w", job.ID, err)
1✔
224
                }
1✔
225
        }
226

227
        if err := tx.Commit(); err != nil {
3✔
228
                return fmt.Errorf("failed to commit transaction: %w", err)
×
229
        }
×
230

231
        return nil
3✔
232
}
233

234
// RecordExecution logs a job execution event
235
func (s *SQLiteStore) RecordExecution(req request.RecordExecution) error {
54✔
236
        s.mu.Lock()
54✔
237
        defer s.mu.Unlock()
54✔
238

54✔
239
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
54✔
240
        defer cancel()
54✔
241

54✔
242
        _, err := s.db.ExecContext(ctx, `
54✔
243
                INSERT INTO executions (job_id, started_at, finished_at, status, exit_code, executed_command, output)
54✔
244
                VALUES (?, ?, ?, ?, ?, ?, ?)`,
54✔
245
                req.JobID, req.StartedAt, req.FinishedAt, req.Status.String(), req.ExitCode, req.ExecutedCommand, req.Output)
54✔
246

54✔
247
        if err != nil {
54✔
248
                return fmt.Errorf("failed to record execution: %w", err)
×
249
        }
×
250

251
        return nil
54✔
252
}
253

254
// GetExecutions retrieves execution history for a job, limited to the most recent executions
255
func (s *SQLiteStore) GetExecutions(jobID string, limit int) ([]ExecutionInfo, error) {
14✔
256
        s.mu.RLock()
14✔
257
        defer s.mu.RUnlock()
14✔
258

14✔
259
        var executions []ExecutionInfo
14✔
260
        err := s.db.Select(&executions, `
14✔
261
                SELECT id, job_id, started_at, finished_at, status, exit_code, executed_command, output
14✔
262
                FROM executions
14✔
263
                WHERE job_id = ?
14✔
264
                ORDER BY started_at DESC
14✔
265
                LIMIT ?`,
14✔
266
                jobID, limit)
14✔
267

14✔
268
        if err != nil {
15✔
269
                return nil, fmt.Errorf("failed to query executions: %w", err)
1✔
270
        }
1✔
271

272
        // ensure we return empty slice, not nil
273
        if executions == nil {
14✔
274
                executions = []ExecutionInfo{}
1✔
275
        }
1✔
276

277
        return executions, nil
13✔
278
}
279

280
// GetExecutionByID retrieves a specific execution by its ID.
281
// Returns ErrNotFound if the execution does not exist.
282
func (s *SQLiteStore) GetExecutionByID(execID int) (ExecutionInfo, error) {
2✔
283
        s.mu.RLock()
2✔
284
        defer s.mu.RUnlock()
2✔
285

2✔
286
        var execution ExecutionInfo
2✔
287
        err := s.db.Get(&execution, `
2✔
288
                SELECT id, job_id, started_at, finished_at, status, exit_code, executed_command, output
2✔
289
                FROM executions
2✔
290
                WHERE id = ?`,
2✔
291
                execID)
2✔
292

2✔
293
        if err != nil {
3✔
294
                if errors.Is(err, sql.ErrNoRows) {
2✔
295
                        return ExecutionInfo{}, ErrNotFound
1✔
296
                }
1✔
UNCOV
297
                return ExecutionInfo{}, fmt.Errorf("failed to query execution: %w", err)
×
298
        }
299

300
        return execution, nil
1✔
301
}
302

303
// CleanupOldExecutions removes old executions beyond the limit for a job
304
func (s *SQLiteStore) CleanupOldExecutions(jobID string, limit int) error {
3✔
305
        s.mu.Lock()
3✔
306
        defer s.mu.Unlock()
3✔
307

3✔
308
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
3✔
309
        defer cancel()
3✔
310

3✔
311
        // delete executions beyond the limit, keeping the most recent ones
3✔
312
        _, err := s.db.ExecContext(ctx, `
3✔
313
                DELETE FROM executions
3✔
314
                WHERE job_id = ?
3✔
315
                AND id NOT IN (
3✔
316
                        SELECT id FROM executions
3✔
317
                        WHERE job_id = ?
3✔
318
                        ORDER BY started_at DESC
3✔
319
                        LIMIT ?
3✔
320
                )`,
3✔
321
                jobID, jobID, limit)
3✔
322

3✔
323
        if err != nil {
3✔
324
                return fmt.Errorf("failed to cleanup old executions: %w", err)
×
325
        }
×
326

327
        return nil
3✔
328
}
329

330
// Close closes the database connection
331
func (s *SQLiteStore) Close() error {
22✔
332
        s.mu.Lock()
22✔
333
        defer s.mu.Unlock()
22✔
334

22✔
335
        if err := s.db.Close(); err != nil {
22✔
336
                return fmt.Errorf("failed to close database: %w", err)
×
337
        }
×
338
        return nil
22✔
339
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc