RFC-028: Scheduled Task Detail Page
RFC-028: Scheduled Task Detail Page
Document Information
- RFC ID: RFC-028
- Related PRD: FEAT-028 (Scheduled Task Detail)
- Extends: RFC-019 (Scheduled Tasks)
- Depends On: RFC-019 (Scheduled Tasks), RFC-001 (Chat Interaction)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Draft
- Author: TBD
Overview
Background
RFC-019 implemented the scheduled task system with two screens: a list screen and an edit screen. The list screen shows minimal information (name, schedule, toggle, last status), and tapping a task navigates directly to the edit form. There is no way to view detailed task information, execution history, or quickly access past execution sessions without entering edit mode.
Additionally, the current data model only stores the last execution result on the ScheduledTask entity itself (lastExecutionAt, lastExecutionStatus, lastExecutionSessionId). There is no persistent record of execution history.
Goals
- Add a read-only detail page that displays full task configuration, runtime status, and execution history
- Introduce a
task_execution_recordstable to persist execution history - Change the list screen navigation so tapping a task opens the detail page instead of the edit screen
- Add a “Run Now” action to manually trigger task execution from the detail page
Non-Goals
- Modifying the edit screen (it remains unchanged)
- Adding execution statistics, charts, or analytics
- Supporting execution retry from history entries
- Modifying the
ScheduledTaskdomain model’s existing fields
Technical Design
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Navigation Layer │
│ │
│ Routes.kt │
│ └── ScheduleDetail(taskId) (new route) │
│ │
│ NavGraph.kt │
│ └── List -> tap item -> Detail (not Edit) │
│ └── Detail -> Edit button -> Edit │
├─────────────────────────────────────────────────────────────┤
│ UI Layer │
│ │
│ ScheduledTaskDetailScreen.kt (new) │
│ ScheduledTaskDetailViewModel.kt (new) │
│ ScheduledTaskDetailUiState (new) │
│ │
├─────────────────────────────────────────────────────────────┤
│ Use Case Layer │
│ │
│ RunScheduledTaskNowUseCase.kt (new) │
│ CleanupExecutionHistoryUseCase.kt (new) │
│ │
├─────────────────────────────────────────────────────────────┤
│ Data Layer │
│ │
│ TaskExecutionRecord (new domain model) │
│ TaskExecutionRecordEntity (new Room entity) │
│ TaskExecutionRecordDao (new DAO) │
│ TaskExecutionRecordMapper (new mapper) │
│ TaskExecutionRecordRepository (new repository) │
│ │
│ ScheduledTaskWorker.kt (modified: save history) │
│ │
├─────────────────────────────────────────────────────────────┤
│ Database │
│ │
│ Migration v(N) -> v(N+1): CREATE TABLE │
│ task_execution_records │
└─────────────────────────────────────────────────────────────┘
Data Model
TaskExecutionRecord (Domain Model)
data class TaskExecutionRecord(
val id: String, // UUID
val taskId: String, // FK to scheduled_tasks.id
val status: ExecutionStatus,
val sessionId: String?, // chat session created for this execution
val startedAt: Long, // epoch millis
val completedAt: Long?, // epoch millis, null if still running
val errorMessage: String? // error description if FAILED
)
Room Entity
@Entity(
tableName = "task_execution_records",
foreignKeys = [
ForeignKey(
entity = ScheduledTaskEntity::class,
parentColumns = ["id"],
childColumns = ["task_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["task_id"]),
Index(value = ["started_at"])
]
)
data class TaskExecutionRecordEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "task_id") val taskId: String,
val status: String, // "RUNNING", "SUCCESS", "FAILED"
@ColumnInfo(name = "session_id") val sessionId: String?,
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "completed_at") val completedAt: Long?,
@ColumnInfo(name = "error_message") val errorMessage: String?
)
Key decisions:
CASCADEdelete: when a scheduled task is deleted, all its execution records are automatically removed- Index on
task_idfor efficient per-task queries - Index on
started_atfor ordering and cleanup queries errorMessagestores failure details, null on success
Database Migration
CREATE TABLE IF NOT EXISTS task_execution_records (
id TEXT NOT NULL PRIMARY KEY,
task_id TEXT NOT NULL,
status TEXT NOT NULL,
session_id TEXT,
started_at INTEGER NOT NULL,
completed_at INTEGER,
error_message TEXT,
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS index_task_execution_records_task_id ON task_execution_records(task_id);
CREATE INDEX IF NOT EXISTS index_task_execution_records_started_at ON task_execution_records(started_at);
DAO
@Dao
interface TaskExecutionRecordDao {
@Query("""
SELECT * FROM task_execution_records
WHERE task_id = :taskId
ORDER BY started_at DESC
LIMIT :limit
""")
fun getRecordsByTaskId(taskId: String, limit: Int = 50): Flow<List<TaskExecutionRecordEntity>>
@Insert
suspend fun insert(record: TaskExecutionRecordEntity)
@Query("""
UPDATE task_execution_records
SET status = :status, completed_at = :completedAt,
session_id = :sessionId, error_message = :errorMessage
WHERE id = :id
""")
suspend fun updateResult(
id: String,
status: String,
completedAt: Long,
sessionId: String?,
errorMessage: String?
)
@Query("DELETE FROM task_execution_records WHERE task_id = :taskId")
suspend fun deleteByTaskId(taskId: String)
@Query("DELETE FROM task_execution_records WHERE started_at < :cutoffMillis")
suspend fun deleteOlderThan(cutoffMillis: Long)
@Query("SELECT COUNT(*) FROM task_execution_records WHERE task_id = :taskId")
suspend fun countByTaskId(taskId: String): Int
}
Repository
interface TaskExecutionRecordRepository {
fun getRecordsByTaskId(taskId: String, limit: Int = 50): Flow<List<TaskExecutionRecord>>
suspend fun createRecord(record: TaskExecutionRecord)
suspend fun updateResult(
id: String,
status: ExecutionStatus,
completedAt: Long,
sessionId: String?,
errorMessage: String?
)
suspend fun deleteByTaskId(taskId: String)
suspend fun cleanupOlderThan(days: Int)
}
Components
ScheduledTaskDetailScreen
Read-only Composable screen with the following structure:
┌─────────────────────────────────┐
│ TopAppBar │
│ ← [Task Name] [Edit] │
├─────────────────────────────────┤
│ │
│ ┌─ Configuration Card ────────┐ │
│ │ Agent: General Assistant │ │
│ │ Schedule: Daily at 07:00 │ │
│ │ Enabled: [====ON====] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─ Prompt Card ───────────────┐ │
│ │ Give me a morning briefing │ │
│ │ including today's weather │ │
│ │ and top news headlines. │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─ Status Card ───────────────┐ │
│ │ Next trigger: Mar 2 07:00 │ │
│ │ Last run: Mar 1 07:00 │ │
│ │ Last status: ● Success │ │
│ │ Created: Feb 28 10:30 │ │
│ └─────────────────────────────┘ │
│ │
│ [Run Now] [View Last Session]│
│ │
│ ── Execution History ────────── │
│ │
│ ┌─────────────────────────────┐ │
│ │ ● Mar 1 07:00 SUCCESS →│ │
│ │ ● Feb 28 07:01 FAILED →│ │
│ │ ● Feb 27 07:00 SUCCESS →│ │
│ └─────────────────────────────┘ │
│ │
│ [Delete Task] │
│ │
└─────────────────────────────────┘
Parameters:
@Composable
fun ScheduledTaskDetailScreen(
onNavigateBack: () -> Unit,
onEditTask: (String) -> Unit,
onNavigateToSession: (String) -> Unit,
viewModel: ScheduledTaskDetailViewModel = koinViewModel()
)
Key UI details:
- TopAppBar: task name as title, back button, edit icon button
- Configuration card: agent name (resolved via
AgentRepository), schedule description (reusesformatScheduleDescription), enabled toggle switch - Prompt card: full prompt text displayed in a card, scrollable if very long
- Status card: next trigger time, last execution time, last status with colored indicator, created/updated timestamps
- Action buttons row: “Run Now” button (shows progress indicator while running), “View Last Session” button (disabled if no session exists)
- Execution history:
LazyColumnsection listingTaskExecutionRecordentries. Each entry shows timestamp, status indicator (colored dot), and a chevron to navigate to the session. Tapping an entry navigates to its session. - Delete button at the bottom with confirmation dialog
- Empty state for execution history: “No executions yet”
ScheduledTaskDetailViewModel
class ScheduledTaskDetailViewModel(
savedStateHandle: SavedStateHandle,
private val scheduledTaskRepository: ScheduledTaskRepository,
private val executionRecordRepository: TaskExecutionRecordRepository,
private val agentRepository: AgentRepository,
private val toggleUseCase: ToggleScheduledTaskUseCase,
private val deleteUseCase: DeleteScheduledTaskUseCase,
private val runNowUseCase: RunScheduledTaskNowUseCase
) : ViewModel() {
private val taskId: String = savedStateHandle["taskId"] ?: ""
private val _uiState = MutableStateFlow(ScheduledTaskDetailUiState())
val uiState: StateFlow<ScheduledTaskDetailUiState> = _uiState.asStateFlow()
init {
loadTask()
loadExecutionHistory()
}
fun toggleEnabled(enabled: Boolean) { /* ... */ }
fun deleteTask() { /* ... */ }
fun runNow() { /* ... */ }
}
ScheduledTaskDetailUiState
data class ScheduledTaskDetailUiState(
val task: ScheduledTask? = null,
val agentName: String = "",
val executionHistory: List<TaskExecutionRecord> = emptyList(),
val isLoading: Boolean = true,
val isRunningNow: Boolean = false,
val isDeleted: Boolean = false,
val errorMessage: String? = null
)
RunScheduledTaskNowUseCase
Manually triggers an immediate execution of a scheduled task:
class RunScheduledTaskNowUseCase(
private val scheduledTaskRepository: ScheduledTaskRepository,
private val executionRecordRepository: TaskExecutionRecordRepository,
private val createSessionUseCase: CreateSessionUseCase,
private val sendMessageUseCase: SendMessageUseCase,
private val notificationHelper: NotificationHelper
) {
suspend operator fun invoke(taskId: String): AppResult<String> {
// 1. Read task from DB
val task = scheduledTaskRepository.getTaskById(taskId)
?: return AppResult.Error(ErrorCode.NOT_FOUND, "Task not found")
// 2. Create execution record (status = RUNNING)
val recordId = UUID.randomUUID().toString()
val startedAt = System.currentTimeMillis()
executionRecordRepository.createRecord(
TaskExecutionRecord(
id = recordId,
taskId = taskId,
status = ExecutionStatus.RUNNING,
sessionId = null,
startedAt = startedAt,
completedAt = null,
errorMessage = null
)
)
// 3. Create session and run agent loop
var sessionId: String? = null
var responseText = ""
var isSuccess = false
try {
val session = createSessionUseCase(agentId = task.agentId)
sessionId = session.id
sendMessageUseCase.execute(
sessionId = session.id,
userText = task.prompt,
agentId = task.agentId
).collect { event ->
when (event) {
is ChatEvent.StreamingText -> responseText += event.text
is ChatEvent.ResponseComplete -> isSuccess = true
is ChatEvent.Error -> {
responseText = event.message
isSuccess = false
}
else -> {}
}
}
} catch (e: Exception) {
responseText = e.message ?: "Unknown error"
isSuccess = false
}
// 4. Update execution record
val completedAt = System.currentTimeMillis()
executionRecordRepository.updateResult(
id = recordId,
status = if (isSuccess) ExecutionStatus.SUCCESS else ExecutionStatus.FAILED,
completedAt = completedAt,
sessionId = sessionId,
errorMessage = if (!isSuccess) responseText else null
)
// 5. Update task's last execution fields
scheduledTaskRepository.updateExecutionResult(
id = taskId,
status = if (isSuccess) ExecutionStatus.SUCCESS else ExecutionStatus.FAILED,
sessionId = sessionId,
nextTriggerAt = task.nextTriggerAt, // unchanged
isEnabled = task.isEnabled // unchanged
)
// 6. Send notification
if (isSuccess) {
notificationHelper.sendScheduledTaskCompletedNotification(
taskName = task.name,
sessionId = sessionId,
responsePreview = responseText
)
} else {
notificationHelper.sendScheduledTaskFailedNotification(
taskName = task.name,
sessionId = sessionId,
errorMessage = responseText
)
}
return if (isSuccess) {
AppResult.Success(sessionId ?: recordId)
} else {
AppResult.Error(ErrorCode.EXECUTION_FAILED, responseText)
}
}
}
Key difference from ScheduledTaskWorker: “Run Now” does NOT reschedule the alarm or disable one-time tasks. It is an ad-hoc execution that does not affect the task’s scheduling lifecycle.
CleanupExecutionHistoryUseCase
Removes execution records older than the configured retention period:
class CleanupExecutionHistoryUseCase(
private val executionRecordRepository: TaskExecutionRecordRepository
) {
suspend operator fun invoke(retentionDays: Int = 90) {
val cutoffMillis = System.currentTimeMillis() - (retentionDays * 24 * 60 * 60 * 1000L)
executionRecordRepository.cleanupOlderThan(retentionDays)
}
}
Called from OneclawApplication.onCreate() on app startup.
Modifications to Existing Components
ScheduledTaskWorker (modified)
The worker is modified to create a TaskExecutionRecord at the start and update it upon completion, in addition to the existing updateExecutionResult call on ScheduledTask:
// In doWork(), after marking as RUNNING:
val recordId = UUID.randomUUID().toString()
executionRecordRepository.createRecord(
TaskExecutionRecord(
id = recordId,
taskId = taskId,
status = ExecutionStatus.RUNNING,
sessionId = null,
startedAt = System.currentTimeMillis(),
completedAt = null,
errorMessage = null
)
)
// ... existing execution logic ...
// After execution completes (alongside existing updateExecutionResult):
executionRecordRepository.updateResult(
id = recordId,
status = if (isSuccess) ExecutionStatus.SUCCESS else ExecutionStatus.FAILED,
completedAt = System.currentTimeMillis(),
sessionId = sessionId,
errorMessage = if (!isSuccess) responseText else null
)
ScheduledTaskListScreen (modified)
Change onEditTask callback to onTaskClick to navigate to the detail page instead of the edit page:
// Before:
onEditTask = { onEditTask(task.id) }
// After:
onClick = { onTaskClick(task.id) }
The ScheduledTaskItem composable is updated to make the entire row clickable (instead of relying on the edit behavior).
NavGraph (modified)
Add the detail route and update list screen navigation:
// New route
composable(Route.ScheduleDetail.PATH) { backStackEntry ->
val taskId = backStackEntry.arguments?.getString("taskId") ?: return@composable
ScheduledTaskDetailScreen(
onNavigateBack = { navController.safePopBackStack() },
onEditTask = { id -> navController.safeNavigate(Route.ScheduleEdit.create(id)) },
onNavigateToSession = { sessionId ->
navController.safeNavigate(Route.ChatSession.create(sessionId))
}
)
}
// Update list screen: onEditTask -> onTaskClick -> navigate to detail
composable(Route.ScheduleList.path) {
ScheduledTaskListScreen(
onNavigateBack = { navController.safePopBackStack() },
onCreateTask = { navController.safeNavigate(Route.ScheduleCreate.path) },
onTaskClick = { taskId ->
navController.safeNavigate(Route.ScheduleDetail.create(taskId))
}
)
}
Navigation
New route added to Routes.kt:
data class ScheduleDetail(val taskId: String) : Route("schedules/{taskId}/detail") {
companion object {
const val PATH = "schedules/{taskId}/detail"
fun create(taskId: String) = "schedules/$taskId/detail"
}
}
The existing ScheduleEdit route path schedules/{taskId} remains unchanged. The detail route uses schedules/{taskId}/detail to avoid path conflicts.
DI Registration
DatabaseModule:
// RFC-028: Execution record DAO
single { get<AppDatabase>().taskExecutionRecordDao() }
RepositoryModule:
// RFC-028: Execution record repository
single<TaskExecutionRecordRepository> { TaskExecutionRecordRepositoryImpl(get()) }
FeatureModule:
// RFC-028: Detail page
single { RunScheduledTaskNowUseCase(get(), get(), get(), get(), get()) }
single { CleanupExecutionHistoryUseCase(get()) }
viewModel { ScheduledTaskDetailViewModel(get(), get(), get(), get(), get(), get(), get()) }
Implementation Steps
Phase 1: Data Layer (execution history persistence)
- Create
TaskExecutionRecorddomain model incore/model/ - Create
TaskExecutionRecordEntityRoom entity indata/local/entity/ - Create
TaskExecutionRecordDaoindata/local/dao/ - Create
TaskExecutionRecordMapperindata/local/mapper/ - Create
TaskExecutionRecordRepositoryinterface incore/repository/ - Create
TaskExecutionRecordRepositoryImplindata/repository/ - Add
taskExecutionRecordDao()toAppDatabase - Create database migration for
task_execution_recordstable - Register DAO and repository in DI modules
Phase 2: Worker modification (record executions)
- Inject
TaskExecutionRecordRepositoryintoScheduledTaskWorker - Create execution record at the start of
doWork() - Update execution record upon completion
- Add unit tests for worker execution recording
Phase 3: Detail page UI
- Create
ScheduledTaskDetailUiStatedata class - Create
ScheduledTaskDetailViewModelwith task loading, execution history, and actions - Create
ScheduledTaskDetailScreencomposable with all sections - Add
ScheduleDetailroute toRoutes.kt - Register detail page in
NavGraph.kt - Update
ScheduledTaskListScreento navigate to detail page instead of edit page - Register ViewModel and use cases in DI modules
Phase 4: Run Now feature
- Create
RunScheduledTaskNowUseCase - Wire “Run Now” button in the detail screen to the use case
- Add loading state and error handling
- Add unit tests for
RunScheduledTaskNowUseCase
Phase 5: History cleanup
- Create
CleanupExecutionHistoryUseCase - Call cleanup on app startup in
OneclawApplication.onCreate() - Add unit test for cleanup logic
Testing Strategy
Unit Tests
TaskExecutionRecordRepositoryImplTest:
- CRUD operations with mocked DAO
- Verify
getRecordsByTaskIdreturns records in descending order bystartedAt - Verify
deleteByTaskIdremoves all records for a task - Verify
cleanupOlderThanremoves only records older than the cutoff
ScheduledTaskDetailViewModelTest:
- Verify task and agent name are loaded on init
- Verify execution history is loaded and exposed in UI state
- Verify
toggleEnabledcallsToggleScheduledTaskUseCase - Verify
deleteTaskcallsDeleteScheduledTaskUseCaseand setsisDeleted = true - Verify
runNowsetsisRunningNow = trueduring execution andfalseafter
RunScheduledTaskNowUseCaseTest:
- Verify execution record is created with
RUNNINGstatus - Verify session is created and agent loop is executed
- Verify record is updated with
SUCCESSon success - Verify record is updated with
FAILEDon failure - Verify task alarm schedule is NOT modified
- Verify notification is sent
CleanupExecutionHistoryUseCaseTest:
- Verify records older than retention period are deleted
- Verify recent records are preserved
Integration Tests
- Verify
ScheduledTaskWorkercreates and updates execution records - Verify cascade delete removes execution records when task is deleted
- Verify detail page loads with task and execution history data
Manual Tests
- Navigate to detail page from list, verify all sections display correctly
- Run Now: tap button, verify progress indicator, verify execution history updates
- Toggle enable/disable from detail page, verify alarm state
- Delete from detail page, verify navigation back to list
- Tap execution history entry, verify navigation to session
- View detail page for a task with no executions, verify empty state
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 0.1 | Initial version | - |