diff --git a/.prettierignore b/.prettierignore index 53473f67..f5ec4927 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,9 @@ docs/ **/README.md pnpm-lock.yaml packages/contracts/generated/ +# Published schema bytes are compatibility-versioned and must not be rewritten +# by a formatting-only change. +packages/contracts/schemas/v1/recipe-assignment.schema.json packages/design-tokens/brand/derivatives.json services/engine/test/fixtures/fake-uv-prefix infrastructure/local/.env.example diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt index 14eb3eac..f7767e66 100644 --- a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt @@ -36,4 +36,19 @@ class MainActivityTest { composeRule.onNodeWithTag("capture-screen").assertIsDisplayed() composeRule.onNodeWithText(savedText).assertIsDisplayed() } + + @Test + fun folder_autopilot_keeps_actions_content_free_and_reversible() { + composeRule.onNodeWithTag("autopilot-button").performClick() + composeRule.onNodeWithTag("autopilot-screen").assertIsDisplayed() + + composeRule.onNodeWithTag("autopilot-pause-button").performClick() + composeRule.onNodeWithTag("autopilot-assignment-state").assertIsDisplayed() + + composeRule.onNodeWithTag("autopilot-approve-button").performClick() + composeRule.onNodeWithTag("autopilot-approval-state").assertIsDisplayed() + + composeRule.onNodeWithTag("autopilot-undo-button").performClick() + composeRule.onNodeWithTag("autopilot-undo-state").assertIsDisplayed() + } } diff --git a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt index d3ba91d8..27fe9542 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt @@ -16,8 +16,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -30,11 +32,24 @@ import com.databreeze.android.storage.InMemoryLocalStore import com.databreeze.android.storage.LocalStorePort import com.databreeze.android.storage.SyncQueueEntity import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.folderautopilot.FolderAutopilotApprovalDecision +import com.databreeze.android.folderautopilot.FolderAutopilotAssignmentState +import com.databreeze.android.folderautopilot.FolderAutopilotAssignmentSummary +import com.databreeze.android.folderautopilot.FolderAutopilotExceptionSummary +import com.databreeze.android.folderautopilot.FolderAutopilotMobileState +import com.databreeze.android.folderautopilot.FolderAutopilotOfflineActionQueue +import com.databreeze.android.folderautopilot.FolderAutopilotOutcome +import com.databreeze.android.folderautopilot.FolderAutopilotOutcomeSummary +import com.databreeze.android.folderautopilot.FolderAutopilotApprovalSummary +import com.databreeze.android.folderautopilot.FolderAutopilotUndoState +import com.databreeze.android.folderautopilot.FolderAutopilotWatcherState +import com.databreeze.android.folderautopilot.FolderAutopilotScreen import kotlinx.coroutines.launch private object AppRoutes { const val HOME = "home" const val CAPTURE = "capture" + const val AUTOPILOT = "autopilot" } private val localScope = AccountWorkspaceScope("local-account", "local-workspace") @@ -63,6 +78,11 @@ fun DataBreezeApp( syncScheduler: SyncScheduler? = null, ) { val navController = rememberNavController() + var autopilotState by remember { mutableStateOf(sampleFolderAutopilotState()) } + val autopilotActions = remember(localStore, scope, syncScheduler) { + FolderAutopilotOfflineActionQueue(localStore, scope, syncScheduler) + } + val autopilotActionScope = rememberCoroutineScope() DataBreezeTheme { Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) }, @@ -73,7 +93,10 @@ fun DataBreezeApp( modifier = Modifier.padding(padding), ) { composable(AppRoutes.HOME) { - HomeScreen(onCapture = { navController.navigate(AppRoutes.CAPTURE) }) + HomeScreen( + onCapture = { navController.navigate(AppRoutes.CAPTURE) }, + onAutopilot = { navController.navigate(AppRoutes.AUTOPILOT) }, + ) } composable(AppRoutes.CAPTURE) { CaptureScreen( @@ -83,13 +106,66 @@ fun DataBreezeApp( onBack = { navController.popBackStack() }, ) } + composable(AppRoutes.AUTOPILOT) { + FolderAutopilotScreen( + state = autopilotState, + onPause = { + autopilotActionScope.launch { + val current = autopilotState + autopilotActions.enqueuePause(current.assignment) + autopilotState = current.pauseAssignment() + } + }, + onApprove = { + autopilotActionScope.launch { + val current = autopilotState + val nowEpochMs = System.currentTimeMillis() + autopilotActions.enqueueApproval( + current.approval, + FolderAutopilotApprovalDecision.APPROVED, + current.approval.planHash, + nowEpochMs, + ) + autopilotState = current.decideApproval( + FolderAutopilotApprovalDecision.APPROVED, + current.approval.planHash, + nowEpochMs, + ) + } + }, + onReject = { + autopilotActionScope.launch { + val current = autopilotState + val nowEpochMs = System.currentTimeMillis() + autopilotActions.enqueueApproval( + current.approval, + FolderAutopilotApprovalDecision.REJECTED, + current.approval.planHash, + nowEpochMs, + ) + autopilotState = current.decideApproval( + FolderAutopilotApprovalDecision.REJECTED, + current.approval.planHash, + nowEpochMs, + ) + } + }, + onUndo = { + autopilotActionScope.launch { + val current = autopilotState + autopilotActions.enqueueUndo(current.recentOutcome) + autopilotState = current.requestUndo() + } + }, + ) + } } } } } @Composable -private fun HomeScreen(onCapture: () -> Unit) { +private fun HomeScreen(onCapture: () -> Unit, onAutopilot: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -102,9 +178,44 @@ private fun HomeScreen(onCapture: () -> Unit) { Button(onClick = onCapture, modifier = Modifier.testTag("capture-button")) { Text(stringResource(R.string.capture_action)) } + Button(onClick = onAutopilot, modifier = Modifier.testTag("autopilot-button")) { + Text(stringResource(R.string.autopilot_title)) + } } } +private fun sampleFolderAutopilotState() = FolderAutopilotMobileState( + assignment = FolderAutopilotAssignmentSummary( + assignmentId = "assignment-1", + displayName = "Invoice intake", + state = FolderAutopilotAssignmentState.ACTIVE, + revision = 3, + watcherState = FolderAutopilotWatcherState.HEALTHY, + ), + approval = FolderAutopilotApprovalSummary( + approvalId = "approval-1", + previewId = "preview-1", + planHash = "a".repeat(64), + affectedCount = 2, + blockedCount = 1, + decision = FolderAutopilotApprovalDecision.PENDING, + expiresAt = "2026-08-05T00:00:00Z", + ), + recentOutcome = FolderAutopilotOutcomeSummary( + executionId = "execution-1", + outcome = FolderAutopilotOutcome.UNDO_AVAILABLE, + affectedCount = 2, + undoState = FolderAutopilotUndoState.AVAILABLE, + ), + exceptions = listOf( + FolderAutopilotExceptionSummary( + exceptionId = "exception-1", + severity = "WARNING", + reasonCode = "DESTINATION_COLLISION", + ), + ), +) + @Composable private fun CaptureScreen( localStore: LocalStorePort, diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt new file mode 100644 index 00000000..a97f91f2 --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt @@ -0,0 +1,158 @@ +package com.databreeze.android.folderautopilot + +import java.time.Instant + +private val OPAQUE_IDENTIFIER = Regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +private val SAFE_TEXT = Regex("^[^\\u0000-\\u001f\\u007f]{1,128}$") +private val PLAN_HASH = Regex("^[0-9a-f]{64}$") +private val REASON_CODE = Regex("^[A-Z][A-Z0-9_.-]{1,63}$") + +enum class FolderAutopilotAssignmentState { ACTIVE, PAUSED, RETIRED, INVALID } + +enum class FolderAutopilotWatcherState { HEALTHY, PAUSED, OVERFLOWED, OFFLINE } + +enum class FolderAutopilotApprovalDecision { PENDING, APPROVED, REJECTED, EXPIRED } + +enum class FolderAutopilotOutcome { + QUEUED, + WAITING_FOR_APPROVAL, + RUNNING, + HANDLED, + EXCEPTION, + UNDO_AVAILABLE, + UNDO_EXPIRED, +} + +enum class FolderAutopilotUndoState { AVAILABLE, REQUESTED, COMPLETED, CONFLICT, EXPIRED, NOT_ELIGIBLE } + +data class FolderAutopilotAssignmentSummary( + val assignmentId: String, + val displayName: String, + val state: FolderAutopilotAssignmentState, + val revision: Long, + val watcherState: FolderAutopilotWatcherState, +) { + init { + requireOpaqueIdentifier(assignmentId) + requireSafeText(displayName) + require(revision > 0) { "revision must be positive" } + } + + fun pause(): FolderAutopilotAssignmentSummary { + check(state == FolderAutopilotAssignmentState.ACTIVE) { "assignment is not active" } + return copy(state = FolderAutopilotAssignmentState.PAUSED, revision = revision + 1) + } +} + +data class FolderAutopilotApprovalSummary( + val approvalId: String, + val previewId: String, + val planHash: String, + val affectedCount: Int, + val blockedCount: Int, + val decision: FolderAutopilotApprovalDecision, + val expiresAt: String, +) { + init { + requireOpaqueIdentifier(approvalId) + requireOpaqueIdentifier(previewId) + requirePlanHash(planHash) + require(affectedCount >= 0) { "affectedCount must not be negative" } + require(blockedCount >= 0) { "blockedCount must not be negative" } + require(expiresAt.isNotBlank()) { "expiresAt must be present" } + requireNotNull(parseExpiryEpochMs(expiresAt)) { "expiresAt must be an ISO-8601 timestamp" } + } + + fun isExpired(nowEpochMs: Long = System.currentTimeMillis()): Boolean = + nowEpochMs >= requireNotNull(parseExpiryEpochMs(expiresAt)) + + fun decide( + next: FolderAutopilotApprovalDecision, + expectedPlanHash: String, + nowEpochMs: Long = System.currentTimeMillis(), + ): FolderAutopilotApprovalSummary { + require(next == FolderAutopilotApprovalDecision.APPROVED || next == FolderAutopilotApprovalDecision.REJECTED) { + "only an approval or rejection can be submitted" + } + requirePlanHash(expectedPlanHash) + check(decision == FolderAutopilotApprovalDecision.PENDING) { "approval is no longer pending" } + check(!isExpired(nowEpochMs)) { "approval has expired" } + require(planHash == expectedPlanHash) { "approval plan hash changed" } + return copy(decision = next) + } +} + +data class FolderAutopilotOutcomeSummary( + val executionId: String, + val outcome: FolderAutopilotOutcome, + val affectedCount: Int, + val undoState: FolderAutopilotUndoState, +) { + init { + requireOpaqueIdentifier(executionId) + require(affectedCount >= 0) { "affectedCount must not be negative" } + } + + fun requestUndo(): FolderAutopilotOutcomeSummary { + check(undoState == FolderAutopilotUndoState.AVAILABLE) { "undo is not available" } + return copy(undoState = FolderAutopilotUndoState.REQUESTED) + } +} + +data class FolderAutopilotExceptionSummary( + val exceptionId: String, + val severity: String, + val reasonCode: String, +) { + init { + requireOpaqueIdentifier(exceptionId) + require(severity in setOf("INFO", "WARNING", "ERROR")) { "unsupported severity" } + requireReasonCode(reasonCode) + } +} + +data class FolderAutopilotMobileState( + val assignment: FolderAutopilotAssignmentSummary, + val approval: FolderAutopilotApprovalSummary, + val recentOutcome: FolderAutopilotOutcomeSummary, + val exceptions: List, +) { + init { + require(exceptions.size <= 50) { "too many exception summaries" } + require(exceptions.none { it.reasonCode.contains("PATH", ignoreCase = true) }) { + "path-bearing exception details are not allowed" + } + } + + fun pauseAssignment(): FolderAutopilotMobileState = copy(assignment = assignment.pause()) + + fun decideApproval( + decision: FolderAutopilotApprovalDecision, + expectedPlanHash: String, + nowEpochMs: Long = System.currentTimeMillis(), + ): FolderAutopilotMobileState = copy( + approval = approval.decide(decision, expectedPlanHash, nowEpochMs), + ) + + fun requestUndo(): FolderAutopilotMobileState = copy(recentOutcome = recentOutcome.requestUndo()) +} + +private fun requireOpaqueIdentifier(value: String) { + require(OPAQUE_IDENTIFIER.matches(value)) { "identifier must be opaque and path-free" } +} + +private fun requireSafeText(value: String) { + require(SAFE_TEXT.matches(value) && value.trim() == value) { "text is not safe" } +} + +private fun requirePlanHash(value: String) { + require(PLAN_HASH.matches(value)) { "plan hash must be a lowercase SHA-256 value" } +} + +private fun requireReasonCode(value: String) { + require(REASON_CODE.matches(value)) { "reason code is not safe" } +} + +private fun parseExpiryEpochMs(value: String): Long? = runCatching { + Instant.parse(value).toEpochMilli() +}.getOrNull() diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt new file mode 100644 index 00000000..61ce9763 --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt @@ -0,0 +1,80 @@ +package com.databreeze.android.folderautopilot + +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.LocalStorePort +import com.databreeze.android.storage.SyncQueueEntity +import com.databreeze.android.sync.SyncScheduler +import java.security.MessageDigest + +/** + * Stores only resumable Folder Autopilot intent locally. The queue never receives a path, + * filename, source value, preview bytes, or an executable action. + */ +class FolderAutopilotOfflineActionQueue( + private val store: LocalStorePort, + private val scope: AccountWorkspaceScope, + private val scheduler: SyncScheduler?, + private val clock: () -> Long = { System.currentTimeMillis() }, +) { + suspend fun enqueuePause(assignment: FolderAutopilotAssignmentSummary): String { + check(assignment.state == FolderAutopilotAssignmentState.ACTIVE) { "assignment is not active" } + val mutationId = mutationId("pause", assignment.assignmentId, assignment.revision.toString()) + return enqueue( + mutationId = mutationId, + operationType = "autopilot.pause", + canonicalPayload = "$mutationId|${assignment.assignmentId}|${assignment.revision}", + ) + } + + suspend fun enqueueApproval( + approval: FolderAutopilotApprovalSummary, + decision: FolderAutopilotApprovalDecision, + expectedPlanHash: String = approval.planHash, + nowEpochMs: Long = clock(), + ): String { + val next = approval.decide(decision, expectedPlanHash, nowEpochMs) + val mutationId = mutationId("approval", next.approvalId, next.decision.name.lowercase()) + return enqueue( + mutationId = mutationId, + operationType = "autopilot.approval", + canonicalPayload = "$mutationId|${next.approvalId}|${next.planHash}|${next.decision}", + ) + } + + suspend fun enqueueUndo(outcome: FolderAutopilotOutcomeSummary): String { + check(outcome.undoState == FolderAutopilotUndoState.AVAILABLE) { "undo is not available" } + val mutationId = mutationId("undo", outcome.executionId) + return enqueue( + mutationId = mutationId, + operationType = "autopilot.undo", + canonicalPayload = "$mutationId|${outcome.executionId}", + ) + } + + private suspend fun enqueue( + mutationId: String, + operationType: String, + canonicalPayload: String, + ): String { + store.enqueue( + SyncQueueEntity( + accountId = scope.accountId, + workspaceId = scope.workspaceId, + mutationId = mutationId, + operationType = operationType, + payloadHash = "sha256:${sha256(canonicalPayload)}", + createdAtEpochMs = clock(), + ), + ) + scheduler?.enqueue(scope) + return mutationId + } + + private fun mutationId(action: String, vararg parts: String): String = + "autopilot-$action-${sha256(parts.joinToString("\\u0000")).take(48)}" +} + +private fun sha256(value: String): String = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt new file mode 100644 index 00000000..10c12c4c --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt @@ -0,0 +1,206 @@ +package com.databreeze.android.folderautopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.databreeze.android.R + +@Composable +private fun assignmentStateLabel(state: FolderAutopilotAssignmentState): String = when (state) { + FolderAutopilotAssignmentState.ACTIVE -> stringResource(R.string.autopilot_state_active) + FolderAutopilotAssignmentState.PAUSED -> stringResource(R.string.autopilot_state_paused) + FolderAutopilotAssignmentState.RETIRED -> stringResource(R.string.autopilot_state_retired) + FolderAutopilotAssignmentState.INVALID -> stringResource(R.string.autopilot_state_invalid) +} + +@Composable +private fun watcherStateLabel(state: FolderAutopilotWatcherState): String = when (state) { + FolderAutopilotWatcherState.HEALTHY -> stringResource(R.string.autopilot_watcher_healthy) + FolderAutopilotWatcherState.PAUSED -> stringResource(R.string.autopilot_watcher_paused) + FolderAutopilotWatcherState.OVERFLOWED -> stringResource(R.string.autopilot_watcher_overflowed) + FolderAutopilotWatcherState.OFFLINE -> stringResource(R.string.autopilot_watcher_offline) +} + +@Composable +private fun approvalDecisionLabel(decision: FolderAutopilotApprovalDecision): String = when (decision) { + FolderAutopilotApprovalDecision.PENDING -> stringResource(R.string.autopilot_decision_pending) + FolderAutopilotApprovalDecision.APPROVED -> stringResource(R.string.autopilot_decision_approved) + FolderAutopilotApprovalDecision.REJECTED -> stringResource(R.string.autopilot_decision_rejected) + FolderAutopilotApprovalDecision.EXPIRED -> stringResource(R.string.autopilot_decision_expired) +} + +@Composable +private fun outcomeLabel(outcome: FolderAutopilotOutcome): String = when (outcome) { + FolderAutopilotOutcome.QUEUED -> stringResource(R.string.autopilot_outcome_queued) + FolderAutopilotOutcome.WAITING_FOR_APPROVAL -> stringResource(R.string.autopilot_outcome_waiting) + FolderAutopilotOutcome.RUNNING -> stringResource(R.string.autopilot_outcome_running) + FolderAutopilotOutcome.HANDLED -> stringResource(R.string.autopilot_outcome_handled) + FolderAutopilotOutcome.EXCEPTION -> stringResource(R.string.autopilot_outcome_exception) + FolderAutopilotOutcome.UNDO_AVAILABLE -> stringResource(R.string.autopilot_outcome_undo_available) + FolderAutopilotOutcome.UNDO_EXPIRED -> stringResource(R.string.autopilot_outcome_undo_expired) +} + +@Composable +private fun undoStateLabel(state: FolderAutopilotUndoState): String = when (state) { + FolderAutopilotUndoState.AVAILABLE -> stringResource(R.string.autopilot_undo_available) + FolderAutopilotUndoState.REQUESTED -> stringResource(R.string.autopilot_undo_requested) + FolderAutopilotUndoState.COMPLETED -> stringResource(R.string.autopilot_undo_completed) + FolderAutopilotUndoState.CONFLICT -> stringResource(R.string.autopilot_undo_conflict) + FolderAutopilotUndoState.EXPIRED -> stringResource(R.string.autopilot_undo_expired) + FolderAutopilotUndoState.NOT_ELIGIBLE -> stringResource(R.string.autopilot_undo_not_eligible) +} + +@Composable +private fun severityLabel(value: String): String = when (value) { + "INFO" -> stringResource(R.string.autopilot_severity_info) + "WARNING" -> stringResource(R.string.autopilot_severity_warning) + "ERROR" -> stringResource(R.string.autopilot_severity_error) + else -> value +} + +@Composable +fun FolderAutopilotScreen( + state: FolderAutopilotMobileState, + onPause: () -> Unit, + onApprove: () -> Unit, + onReject: () -> Unit, + onUndo: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(20.dp) + .testTag("autopilot-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(R.string.autopilot_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.autopilot_body), style = MaterialTheme.typography.bodyLarge) + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.autopilot_assignment_heading), style = MaterialTheme.typography.titleMedium) + Text(state.assignment.displayName, style = MaterialTheme.typography.bodyLarge) + Text( + stringResource( + R.string.autopilot_assignment_state, + assignmentStateLabel(state.assignment.state), + state.assignment.revision, + ), + modifier = Modifier.testTag("autopilot-assignment-state"), + ) + Text(stringResource(R.string.autopilot_watcher_state, watcherStateLabel(state.assignment.watcherState))) + Button( + onClick = onPause, + enabled = state.assignment.state == FolderAutopilotAssignmentState.ACTIVE, + modifier = Modifier.testTag("autopilot-pause-button"), + ) { + Text( + if (state.assignment.state == FolderAutopilotAssignmentState.PAUSED) { + stringResource(R.string.autopilot_paused) + } else { + stringResource(R.string.autopilot_pause) + }, + ) + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.autopilot_approval_heading), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.autopilot_preview_id, state.approval.previewId)) + Text(stringResource(R.string.autopilot_plan_hash, state.approval.planHash.take(12))) + Text( + stringResource( + R.string.autopilot_approval_counts, + state.approval.affectedCount, + state.approval.blockedCount, + ), + ) + Text( + stringResource(R.string.autopilot_approval_state, approvalDecisionLabel(state.approval.decision)), + modifier = Modifier.testTag("autopilot-approval-state"), + ) + val approvalExpired = state.approval.isExpired() + if (approvalExpired && state.approval.decision == FolderAutopilotApprovalDecision.PENDING) { + Text( + stringResource(R.string.autopilot_approval_expired), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("autopilot-approval-expired"), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = onApprove, + enabled = state.approval.decision == FolderAutopilotApprovalDecision.PENDING && !approvalExpired, + modifier = Modifier.testTag("autopilot-approve-button"), + ) { + Text(stringResource(R.string.autopilot_approve)) + } + OutlinedButton( + onClick = onReject, + enabled = state.approval.decision == FolderAutopilotApprovalDecision.PENDING && !approvalExpired, + modifier = Modifier.testTag("autopilot-reject-button"), + ) { + Text(stringResource(R.string.autopilot_reject)) + } + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.autopilot_outcomes_heading), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.autopilot_outcome_state, outcomeLabel(state.recentOutcome.outcome))) + Text(stringResource(R.string.autopilot_affected_count, state.recentOutcome.affectedCount)) + Text( + stringResource(R.string.autopilot_undo_state, undoStateLabel(state.recentOutcome.undoState)), + modifier = Modifier.testTag("autopilot-undo-state"), + ) + Button( + onClick = onUndo, + enabled = state.recentOutcome.undoState == FolderAutopilotUndoState.AVAILABLE, + modifier = Modifier.testTag("autopilot-undo-button"), + ) { + Text(stringResource(R.string.autopilot_undo)) + } + } + } + + if (state.exceptions.isNotEmpty()) { + HorizontalDivider() + Text(stringResource(R.string.autopilot_exceptions_heading), style = MaterialTheme.typography.titleMedium) + state.exceptions.forEach { exception -> + Text( + stringResource(R.string.autopilot_exception, severityLabel(exception.severity), exception.reasonCode), + modifier = Modifier.testTag("autopilot-exception-${exception.exceptionId}"), + ) + } + } + } +} diff --git a/apps/android/app/src/main/res/values-en/strings.xml b/apps/android/app/src/main/res/values-en/strings.xml index d18c1478..d90477bf 100644 --- a/apps/android/app/src/main/res/values-en/strings.xml +++ b/apps/android/app/src/main/res/values-en/strings.xml @@ -9,4 +9,54 @@ Draft saved Save draft Back + Folder Autopilot + Review and approve safe actions without exposing source paths or file content. + Assignment + State: %1$s (revision %2$d) + Watcher state: %1$s + Pause assignment + Paused + Approval queue + Preview: %1$s + Plan hash: %1$s… + Affected %1$d • blocked %2$d + Decision: %1$s + Approval expired + Approve + Reject + Recent outcomes + Outcome: %1$s + Affected items: %1$d + Undo: %1$s + Undo + Exceptions + %1$s • %2$s + Active + Paused + Retired + Invalid + Healthy + Paused + Queue overflowed + Offline + Pending + Approved + Rejected + Expired + Queued + Waiting for approval + Running + Handled + Exception + Undo available + Undo expired + Available + Requested + Completed + Conflict + Expired + Not eligible + Information + Warning + Error diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index ec54177f..e92d1e23 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -9,4 +9,54 @@ Đã lưu bản nháp Lưu bản nháp Quay lại + Tự động hóa thư mục + Xem và phê duyệt thao tác an toàn mà không hiển thị đường dẫn nguồn hoặc nội dung tệp. + Phân công + Trạng thái: %1$s (phiên bản %2$d) + Trạng thái theo dõi: %1$s + Tạm dừng + Đã tạm dừng + Hàng đợi phê duyệt + Bản xem trước: %1$s + Mã kế hoạch: %1$s… + Ảnh hưởng %1$d • bị chặn %2$d + Quyết định: %1$s + Phê duyệt đã hết hạn + Phê duyệt + Từ chối + Kết quả gần đây + Kết quả: %1$s + Số mục ảnh hưởng: %1$d + Hoàn tác: %1$s + Hoàn tác + Ngoại lệ + %1$s • %2$s + Đang hoạt động + Đã tạm dừng + Đã nghỉ + Không hợp lệ + Bình thường + Đã tạm dừng + Hàng đợi quá tải + Ngoại tuyến + Đang chờ + Đã phê duyệt + Đã từ chối + Đã hết hạn + Đang xếp hàng + Đang chờ phê duyệt + Đang chạy + Đã xử lý + Ngoại lệ + Có thể hoàn tác + Hoàn tác đã hết hạn + Có thể thực hiện + Đã yêu cầu + Đã hoàn tất + Xung đột + Đã hết hạn + Không đủ điều kiện + Thông tin + Cảnh báo + Lỗi diff --git a/apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt b/apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt new file mode 100644 index 00000000..69ced9fd --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt @@ -0,0 +1,94 @@ +package com.databreeze.android.folderautopilot + +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.InMemoryLocalStore +import com.databreeze.android.sync.SyncScheduler +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Assert.assertTrue +import org.junit.Test + +class FolderAutopilotOfflineQueueTest { + private val scope = AccountWorkspaceScope("account-1", "workspace-1") + private val assignment = FolderAutopilotAssignmentSummary( + assignmentId = "assignment-1", + displayName = "Invoice intake", + state = FolderAutopilotAssignmentState.ACTIVE, + revision = 3, + watcherState = FolderAutopilotWatcherState.HEALTHY, + ) + private val approval = FolderAutopilotApprovalSummary( + approvalId = "approval-1", + previewId = "preview-1", + planHash = "a".repeat(64), + affectedCount = 1, + blockedCount = 0, + decision = FolderAutopilotApprovalDecision.PENDING, + expiresAt = "2026-08-05T00:00:00Z", + ) + private val outcome = FolderAutopilotOutcomeSummary( + executionId = "execution-1", + outcome = FolderAutopilotOutcome.UNDO_AVAILABLE, + affectedCount = 1, + undoState = FolderAutopilotUndoState.AVAILABLE, + ) + + @Test + fun queues_only_opaque_ids_revisions_and_hashes() = runBlocking { + val store = InMemoryLocalStore() + val scheduler = RecordingScheduler() + val queue = FolderAutopilotOfflineActionQueue(store, scope, scheduler) { 1_000L } + + queue.enqueuePause(assignment) + queue.enqueueApproval(approval, FolderAutopilotApprovalDecision.APPROVED) + queue.enqueueUndo(outcome) + + val queued = store.snapshotQueue(scope) + assertEquals(3, queued.size) + assertTrue(queued.all { it.operationType.startsWith("autopilot.") }) + assertTrue(queued.all { it.payloadHash.matches(Regex("sha256:[0-9a-f]{64}")) }) + assertTrue(queued.all { it.mutationId.contains("/").not() }) + assertEquals(3, scheduler.enqueued.size) + assertEquals(1_000L, queued.first().createdAtEpochMs) + } + + @Test + fun approval_queue_requires_pending_state_and_exact_plan_hash() = runBlocking { + val store = InMemoryLocalStore() + val queue = FolderAutopilotOfflineActionQueue(store, scope, null) { 2_000L } + + queue.enqueueApproval(approval, FolderAutopilotApprovalDecision.APPROVED) + val completed = approval.copy(decision = FolderAutopilotApprovalDecision.APPROVED) + try { + queue.enqueueApproval(completed, FolderAutopilotApprovalDecision.REJECTED) + fail("an already-decided approval must not be queued") + } catch (_: IllegalStateException) { + // Expected fail-closed behavior. + } + } + + @Test + fun expired_approval_is_rejected_before_it_enters_the_queue() = runBlocking { + val store = InMemoryLocalStore() + val queue = FolderAutopilotOfflineActionQueue(store, scope, null) { 1_800_000_000_000L } + + try { + queue.enqueueApproval(approval, FolderAutopilotApprovalDecision.APPROVED) + fail("an expired approval must not be queued") + } catch (error: IllegalStateException) { + assertEquals("approval has expired", error.message) + } + assertTrue(store.snapshotQueue(scope).isEmpty()) + } + + private class RecordingScheduler : SyncScheduler { + val enqueued = mutableListOf() + + override fun enqueue(scope: AccountWorkspaceScope, cursor: String?, revision: Long?) { + enqueued += scope + } + + override fun cancel(scope: AccountWorkspaceScope) = Unit + } +} diff --git a/apps/desktop/src/application/folder-grant.port.ts b/apps/desktop/src/application/folder-grant.port.ts new file mode 100644 index 00000000..d9840e0e --- /dev/null +++ b/apps/desktop/src/application/folder-grant.port.ts @@ -0,0 +1,3 @@ +export interface FolderGrantPort { + grantFolder(): Promise; +} diff --git a/apps/desktop/src/features/folder-autopilot/file-observation.ts b/apps/desktop/src/features/folder-autopilot/file-observation.ts new file mode 100644 index 00000000..1f6d4640 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/file-observation.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto'; + +// This adapter buffers the file before hashing; keep the bound below a +// practical single-buffer limit instead of advertising an unsafe 10 GiB read. +const MAX_FILE_BYTES = 512 * 1024 * 1024; +const NANOSECOND_TIMESTAMP = /^\d{1,32}$/u; + +export type StableFileCode = + | 'FILE_CHANGED_DURING_READ' + | 'FILE_STILL_IN_USE' + | 'INVALID_OBSERVATION' + | 'NOT_REGULAR_FILE' + | 'PATH_REPARSE_POINT' + | 'RESOURCE_LIMIT'; + +export class StableFileError extends Error { + readonly code: StableFileCode; + + constructor(code: StableFileCode) { + super(code); + this.name = 'StableFileError'; + this.code = code; + } +} + +export interface StableFileStat { + readonly isFile: boolean; + readonly isSymbolicLink: boolean; + readonly sizeBytes: number; + /** JSON-safe decimal epoch nanoseconds; never coerce a bigint to Number. */ + readonly modifiedAtNs: string; +} + +export interface StableFileOptions { + readonly maxAttempts?: number; + readonly intervalMs?: number; + readonly sleep?: (milliseconds: number) => Promise; +} + +export interface LocalFileObservation { + readonly observationId: string; + readonly displayName: string; + readonly sizeBytes: number; + readonly modifiedAtNs: string; + readonly contentSha256: string; + readonly stableExecutionKey: string; +} + +interface CaptureStableObservationInput extends StableFileOptions { + readonly observationId: string; + readonly displayName: string; + readonly readStat: () => Promise; + readonly readBytes: () => Promise; +} + +function reject(code: StableFileCode): never { + throw new StableFileError(code); +} + +function defaultSleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function validateStat(stat: StableFileStat): StableFileStat { + if ( + typeof stat !== 'object' || + stat === null || + typeof stat.isFile !== 'boolean' || + typeof stat.isSymbolicLink !== 'boolean' || + !Number.isSafeInteger(stat.sizeBytes) || + stat.sizeBytes < 0 || + stat.sizeBytes > MAX_FILE_BYTES || + typeof stat.modifiedAtNs !== 'string' || + !NANOSECOND_TIMESTAMP.test(stat.modifiedAtNs) + ) { + return reject('INVALID_OBSERVATION'); + } + if (stat.isSymbolicLink) return reject('PATH_REPARSE_POINT'); + if (!stat.isFile) return reject('NOT_REGULAR_FILE'); + return stat; +} + +function sameStat(first: StableFileStat, second: StableFileStat): boolean { + return ( + first.isFile === second.isFile && + first.isSymbolicLink === second.isSymbolicLink && + first.sizeBytes === second.sizeBytes && + first.modifiedAtNs === second.modifiedAtNs + ); +} + +export async function waitForStableFile( + readStat: () => Promise, + { maxAttempts = 5, intervalMs = 250, sleep = defaultSleep }: StableFileOptions = {}, +): Promise { + if ( + !Number.isSafeInteger(maxAttempts) || + maxAttempts < 2 || + maxAttempts > 20 || + !Number.isSafeInteger(intervalMs) || + intervalMs < 0 || + intervalMs > 5_000 + ) { + return reject('RESOURCE_LIMIT'); + } + + let previous: StableFileStat | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + let current: StableFileStat; + try { + current = validateStat(await readStat()); + } catch (error) { + if (error instanceof StableFileError && error.code !== 'FILE_STILL_IN_USE') throw error; + if (attempt === maxAttempts - 1) return reject('FILE_STILL_IN_USE'); + await sleep(intervalMs); + continue; + } + if (previous !== undefined && sameStat(previous, current)) return current; + previous = current; + if (attempt < maxAttempts - 1) await sleep(intervalMs); + } + return reject('FILE_STILL_IN_USE'); +} + +function isByteArray(value: unknown): value is Uint8Array { + return ( + ArrayBuffer.isView(value) && Object.prototype.toString.call(value) === '[object Uint8Array]' + ); +} + +export function fingerprintBytes(bytes: Uint8Array): string { + if (!isByteArray(bytes)) return reject('INVALID_OBSERVATION'); + return createHash('sha256').update(bytes).digest('hex'); +} + +function stableExecutionKey(observation: Omit): string { + const canonical = JSON.stringify({ + contentSha256: observation.contentSha256, + displayName: observation.displayName, + modifiedAtNs: observation.modifiedAtNs, + observationId: observation.observationId, + sizeBytes: observation.sizeBytes, + }); + return createHash('sha256').update(canonical, 'utf8').digest('hex'); +} + +function validateDisplayName(displayName: string): string { + if ( + typeof displayName !== 'string' || + displayName.length === 0 || + displayName.length > 255 || + displayName === '.' || + displayName === '..' || + displayName.includes('/') || + displayName.includes('\\') || + [...displayName].some( + (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127, + ) + ) { + return reject('INVALID_OBSERVATION'); + } + return displayName; +} + +function validateObservationId(observationId: string): string { + if ( + typeof observationId !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(observationId) + ) { + return reject('INVALID_OBSERVATION'); + } + return observationId; +} + +export async function captureStableObservation({ + observationId, + displayName, + readStat, + readBytes, + maxAttempts, + intervalMs, + sleep, +}: CaptureStableObservationInput): Promise { + const options: StableFileOptions = { + ...(maxAttempts === undefined ? {} : { maxAttempts }), + ...(intervalMs === undefined ? {} : { intervalMs }), + ...(sleep === undefined ? {} : { sleep }), + }; + const first = await waitForStableFile(readStat, options); + let bytes: Uint8Array; + try { + bytes = await readBytes(); + } catch { + return reject('FILE_STILL_IN_USE'); + } + if (!isByteArray(bytes) || bytes.byteLength !== first.sizeBytes) { + return reject('FILE_CHANGED_DURING_READ'); + } + let after: StableFileStat; + try { + after = validateStat(await readStat()); + } catch { + return reject('FILE_CHANGED_DURING_READ'); + } + if (!sameStat(first, after)) return reject('FILE_CHANGED_DURING_READ'); + const observation: Omit = { + observationId: validateObservationId(observationId), + displayName: validateDisplayName(displayName), + sizeBytes: first.sizeBytes, + modifiedAtNs: first.modifiedAtNs, + contentSha256: fingerprintBytes(bytes), + }; + return Object.freeze({ ...observation, stableExecutionKey: stableExecutionKey(observation) }); +} diff --git a/apps/desktop/src/features/folder-autopilot/local-actions.ts b/apps/desktop/src/features/folder-autopilot/local-actions.ts new file mode 100644 index 00000000..25a181dc --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/local-actions.ts @@ -0,0 +1,299 @@ +import path from 'node:path'; + +export type LocalAction = 'INSPECT' | 'VALIDATE' | 'RENAME' | 'COPY' | 'MOVE'; +export type LocalCollisionPolicy = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type LocalActionCode = + | 'APPROVAL_REQUIRED' + | 'DESTINATION_COLLISION' + | 'DESTINATION_RECURSION' + | 'EXCLUSIVE_RENAME_REQUIRED' + | 'INVALID_LOCAL_PATH' + | 'INVALID_PLAN' + | 'LOCAL_IO_FAILED' + | 'PATH_OUTSIDE_AUTHORIZATION' + | 'PATH_REPARSE_POINT' + | 'STALE_PLAN'; + +export class LocalActionError extends Error { + readonly code: LocalActionCode; + + constructor(code: LocalActionCode) { + super(code); + this.name = 'LocalActionError'; + this.code = code; + } +} + +export class LocalActionFailure extends LocalActionError { + public readonly appliedReceipts: readonly LocalActionReceipt[]; + + public constructor(code: LocalActionCode, appliedReceipts: readonly LocalActionReceipt[]) { + super(code); + this.name = 'LocalActionFailure'; + this.appliedReceipts = Object.freeze([...appliedReceipts]); + } +} + +export interface LocalPathGuard { + assertContained(candidate: string): string; +} + +export interface LocalFileSystem { + exists(path: string): Promise; + readFingerprint(path: string): Promise; + copyExclusive(source: string, destination: string): Promise; + /** The adapter must reject rather than replace an existing destination. */ + renameExclusive?(source: string, destination: string): Promise; + /** Legacy non-exclusive operation; the local executor never invokes it. */ + rename(source: string, destination: string): Promise; +} + +export interface LocalActionOperation { + readonly operationId: string; + readonly action: LocalAction; + readonly sourcePath: string; + readonly destinationPath?: string; + readonly sourceFingerprint: string; + readonly collisionPolicy?: LocalCollisionPolicy; + readonly approved?: boolean; +} + +export interface LocalActionPlan { + readonly operations: readonly LocalActionOperation[]; +} + +export interface LocalActionDependencies { + readonly sourceGuard: LocalPathGuard; + readonly destinationGuard: LocalPathGuard; + readonly fileSystem: LocalFileSystem; +} + +export interface LocalActionReceipt { + readonly operationId: string; + readonly action: LocalAction; + readonly status: 'APPLIED' | 'SKIPPED'; + readonly destinationPath?: string; +} + +const MAX_OPERATIONS = 100; +const MAX_UNIQUE_NAME_ATTEMPTS = 1_000; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const SHA256 = /^[0-9a-f]{64}$/; + +function reject(code: LocalActionCode): never { + throw new LocalActionError(code); +} + +const CONTAINMENT_CODES = [ + 'INVALID_LOCAL_PATH', + 'PATH_OUTSIDE_AUTHORIZATION', + 'PATH_REPARSE_POINT', +] as const; + +function isContainmentCode(value: unknown): value is (typeof CONTAINMENT_CODES)[number] { + return ( + typeof value === 'string' && + CONTAINMENT_CODES.includes(value as (typeof CONTAINMENT_CODES)[number]) + ); +} + +function failClosed(error: unknown): never { + if (error instanceof LocalActionError) return reject(error.code); + if (typeof error === 'object' && error !== null) { + const code = (error as { readonly code?: unknown }).code; + if (isContainmentCode(code)) return reject(code); + } + return reject('LOCAL_IO_FAILED'); +} + +function assertContained(guard: LocalPathGuard, candidate: string): string { + try { + const contained = guard.assertContained(candidate); + if (typeof contained !== 'string' || contained.length === 0) return reject('LOCAL_IO_FAILED'); + return contained; + } catch (error) { + return failClosed(error); + } +} + +async function pathExists(fileSystem: LocalFileSystem, candidate: string): Promise { + try { + const exists = await fileSystem.exists(candidate); + if (typeof exists !== 'boolean') return reject('LOCAL_IO_FAILED'); + return exists; + } catch (error) { + return failClosed(error); + } +} + +async function readFingerprint(fileSystem: LocalFileSystem, candidate: string): Promise { + try { + const fingerprint = await fileSystem.readFingerprint(candidate); + if (typeof fingerprint !== 'string') return reject('LOCAL_IO_FAILED'); + return fingerprint; + } catch (error) { + return failClosed(error); + } +} + +function isWriteAction(action: LocalAction): boolean { + return action === 'RENAME' || action === 'COPY' || action === 'MOVE'; +} + +function validateOperation(operation: LocalActionOperation): void { + if ( + typeof operation !== 'object' || + operation === null || + typeof operation.operationId !== 'string' || + !SAFE_ID.test(operation.operationId) || + !['INSPECT', 'VALIDATE', 'RENAME', 'COPY', 'MOVE'].includes(operation.action) || + typeof operation.sourcePath !== 'string' || + operation.sourcePath.length === 0 || + operation.sourcePath.includes('\0') || + typeof operation.sourceFingerprint !== 'string' || + !SHA256.test(operation.sourceFingerprint) + ) { + return reject('INVALID_PLAN'); + } + if (isWriteAction(operation.action)) { + if ( + typeof operation.destinationPath !== 'string' || + operation.destinationPath.length === 0 || + operation.destinationPath.includes('\0') + ) { + return reject('INVALID_PLAN'); + } + if ( + operation.collisionPolicy !== undefined && + !['REVIEW', 'SKIP', 'UNIQUE_NAME'].includes(operation.collisionPolicy) + ) { + return reject('INVALID_PLAN'); + } + if (operation.action === 'MOVE' && operation.approved !== true) { + return reject('APPROVAL_REQUIRED'); + } + } else if (operation.destinationPath !== undefined || operation.collisionPolicy !== undefined) { + return reject('INVALID_PLAN'); + } +} + +function uniqueDestinationName(destinationPath: string, index: number): string { + const parsed = path.win32.parse(destinationPath); + return path.win32.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); +} + +async function chooseDestination( + containedDestination: string, + collisionPolicy: LocalCollisionPolicy | undefined, + destinationGuard: LocalPathGuard, + fileSystem: LocalFileSystem, +): Promise<{ readonly path: string; readonly generated: boolean; readonly skipped: boolean }> { + const destination = containedDestination; + if (!(await pathExists(fileSystem, destination))) { + return { path: destination, generated: false, skipped: false }; + } + if (collisionPolicy === 'SKIP') return { path: destination, generated: false, skipped: true }; + if (collisionPolicy !== 'UNIQUE_NAME') return reject('DESTINATION_COLLISION'); + + for (let index = 1; index <= MAX_UNIQUE_NAME_ATTEMPTS; index += 1) { + const candidate = assertContained(destinationGuard, uniqueDestinationName(destination, index)); + if (!(await pathExists(fileSystem, candidate))) { + return { path: candidate, generated: true, skipped: false }; + } + } + return reject('DESTINATION_COLLISION'); +} + +export async function executeLocalPlan( + plan: LocalActionPlan, + { sourceGuard, destinationGuard, fileSystem }: LocalActionDependencies, +): Promise { + const candidate: unknown = plan; + if (typeof candidate !== 'object' || candidate === null) return reject('INVALID_PLAN'); + const operationsValue: unknown = (candidate as { readonly operations?: unknown }).operations; + if (!Array.isArray(operationsValue) || operationsValue.length > MAX_OPERATIONS) { + return reject('INVALID_PLAN'); + } + const operations = operationsValue as readonly LocalActionOperation[]; + + const receipts: LocalActionReceipt[] = []; + for (const operation of operations) { + try { + validateOperation(operation); + if ( + (operation.action === 'RENAME' || operation.action === 'MOVE') && + typeof fileSystem.renameExclusive !== 'function' + ) { + return reject('EXCLUSIVE_RENAME_REQUIRED'); + } + const source = assertContained(sourceGuard, operation.sourcePath); + const expectedFingerprint = await readFingerprint(fileSystem, source); + if (expectedFingerprint !== operation.sourceFingerprint) return reject('STALE_PLAN'); + + if (!isWriteAction(operation.action)) { + receipts.push({ + operationId: operation.operationId, + action: operation.action, + status: 'APPLIED', + }); + continue; + } + + const requestedDestination = assertContained( + destinationGuard, + operation.destinationPath as string, + ); + if (source.toLowerCase() === requestedDestination.toLowerCase()) { + return reject('DESTINATION_RECURSION'); + } + const destinationSelection = await chooseDestination( + requestedDestination, + operation.collisionPolicy, + destinationGuard, + fileSystem, + ); + const destination = destinationSelection.path; + if (destinationSelection.skipped) { + receipts.push({ + operationId: operation.operationId, + action: operation.action, + status: 'SKIPPED', + }); + continue; + } + if (await pathExists(fileSystem, destination)) { + if (operation.collisionPolicy === 'SKIP') { + receipts.push({ + operationId: operation.operationId, + action: operation.action, + status: 'SKIPPED', + }); + continue; + } + return reject('DESTINATION_COLLISION'); + } + try { + if (operation.action === 'COPY') await fileSystem.copyExclusive(source, destination); + else await fileSystem.renameExclusive!(source, destination); + } catch { + throw new LocalActionFailure('LOCAL_IO_FAILED', receipts); + } + receipts.push({ + operationId: operation.operationId, + action: operation.action, + status: 'APPLIED', + ...(destinationSelection.generated ? { destinationPath: destination } : {}), + }); + } catch (error) { + if (receipts.length > 0) { + if (error instanceof LocalActionFailure) throw error; + if (error instanceof LocalActionError) { + throw new LocalActionFailure(error.code, receipts); + } + throw new LocalActionFailure('LOCAL_IO_FAILED', receipts); + } + throw error; + } + } + return receipts; +} diff --git a/apps/desktop/src/features/folder-autopilot/local-journal.ts b/apps/desktop/src/features/folder-autopilot/local-journal.ts new file mode 100644 index 00000000..03f16cea --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/local-journal.ts @@ -0,0 +1,266 @@ +export type JournalState = + | 'PREPARED' + | 'COMMITTING' + | 'COMMITTED' + | 'COMPENSATING' + | 'COMPENSATED' + | 'CONFLICT' + | 'UNDOING' + | 'UNDONE'; +export type JournalStepState = 'PENDING' | 'COMMITTED' | 'COMPENSATED'; +export type JournalAction = 'RENAME' | 'COPY' | 'MOVE'; +export type JournalErrorCode = + | 'DUPLICATE_STEP' + | 'INVALID_JOURNAL' + | 'INVALID_TRANSITION' + | 'RECOVERY_CONFLICT' + | 'UNDO_CONFLICT' + | 'UNDO_EXPIRED' + | 'UNDO_NOT_AVAILABLE'; + +export class JournalError extends Error { + readonly code: JournalErrorCode; + + constructor(code: JournalErrorCode) { + super(code); + this.name = 'JournalError'; + this.code = code; + } +} + +export interface JournalStepInput { + readonly operationId: string; + readonly action: JournalAction; + readonly sourcePath: string; + readonly destinationPath: string; + readonly beforeFingerprint: string; + readonly undoable: boolean; +} + +export interface JournalStep extends JournalStepInput { + readonly state: JournalStepState; + readonly afterFingerprint: string | null; +} + +export interface LocalJournal { + readonly executionId: string; + readonly planHash: string; + readonly state: JournalState; + readonly steps: readonly JournalStep[]; + readonly createdAtMs: number; + readonly undoExpiresAtMs: number; + readonly revision: number; +} + +export interface UndoOperation { + readonly operationId: string; + readonly action: 'RENAME'; + readonly sourcePath: string; + readonly destinationPath: string; + readonly expectedSourceFingerprint: string; +} + +export interface UndoPlan { + readonly executionId: string; + readonly planHash: string; + readonly operations: readonly UndoOperation[]; +} + +const SHA256 = /^[0-9a-f]{64}$/; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const MAX_STEPS = 100; +const MIN_UNDO_WINDOW_MS = 60_000; +const MAX_UNDO_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +function reject(code: JournalErrorCode): never { + throw new JournalError(code); +} + +function cloneJournal(journal: LocalJournal, updates: Partial): LocalJournal { + return Object.freeze({ ...journal, ...updates, revision: journal.revision + 1 }); +} + +function validateStep(step: JournalStepInput): void { + if ( + typeof step !== 'object' || + step === null || + typeof step.operationId !== 'string' || + !SAFE_ID.test(step.operationId) || + !['RENAME', 'COPY', 'MOVE'].includes(step.action) || + typeof step.sourcePath !== 'string' || + typeof step.destinationPath !== 'string' || + step.sourcePath.length === 0 || + step.destinationPath.length === 0 || + step.sourcePath.includes('\0') || + step.destinationPath.includes('\0') || + typeof step.beforeFingerprint !== 'string' || + !SHA256.test(step.beforeFingerprint) || + typeof step.undoable !== 'boolean' || + (step.action === 'COPY' && step.undoable) + ) { + return reject('INVALID_JOURNAL'); + } +} + +export function createJournal({ + executionId, + planHash, + steps, + nowMs, + undoWindowMs, +}: { + readonly executionId: string; + readonly planHash: string; + readonly steps: readonly JournalStepInput[]; + readonly nowMs: number; + readonly undoWindowMs: number; +}): LocalJournal { + if ( + typeof executionId !== 'string' || + !SAFE_ID.test(executionId) || + typeof planHash !== 'string' || + !SHA256.test(planHash) || + !Number.isSafeInteger(nowMs) || + !Number.isSafeInteger(undoWindowMs) || + undoWindowMs < MIN_UNDO_WINDOW_MS || + undoWindowMs > MAX_UNDO_WINDOW_MS || + steps.length === 0 || + steps.length > MAX_STEPS || + new Set(steps.map((step) => step.operationId)).size !== steps.length + ) { + return reject('INVALID_JOURNAL'); + } + steps.forEach(validateStep); + return Object.freeze({ + executionId, + planHash, + state: 'PREPARED', + steps: Object.freeze( + steps.map((step) => Object.freeze({ ...step, state: 'PENDING', afterFingerprint: null })), + ), + createdAtMs: nowMs, + undoExpiresAtMs: nowMs + undoWindowMs, + revision: 0, + }); +} + +export function beginJournal(journal: LocalJournal): LocalJournal { + if (journal.state !== 'PREPARED') return reject('INVALID_TRANSITION'); + return cloneJournal(journal, { state: 'COMMITTING' }); +} + +export function recordJournalStep( + journal: LocalJournal, + operationId: string, + afterFingerprint: string, +): LocalJournal { + if ( + journal.state !== 'COMMITTING' || + typeof operationId !== 'string' || + typeof afterFingerprint !== 'string' || + !SHA256.test(afterFingerprint) + ) { + return reject('INVALID_TRANSITION'); + } + const index = journal.steps.findIndex((step) => step.operationId === operationId); + if (index < 0) return reject('INVALID_JOURNAL'); + const step = journal.steps[index]; + if (step === undefined) return reject('INVALID_JOURNAL'); + if (step.state !== 'PENDING') return reject('DUPLICATE_STEP'); + const nextSteps = journal.steps.slice(); + nextSteps[index] = Object.freeze({ ...step, state: 'COMMITTED', afterFingerprint }); + const nextState = nextSteps.every((candidate) => candidate.state === 'COMMITTED') + ? 'COMMITTED' + : 'COMMITTING'; + return cloneJournal(journal, { state: nextState, steps: Object.freeze(nextSteps) }); +} + +export function failJournal(journal: LocalJournal): LocalJournal { + if (journal.state !== 'COMMITTING' || !journal.steps.some((step) => step.state === 'COMMITTED')) { + return reject('INVALID_TRANSITION'); + } + return cloneJournal(journal, { state: 'COMPENSATING' }); +} + +export function compensateJournal(journal: LocalJournal, operationId: string): LocalJournal { + if (journal.state !== 'COMPENSATING') return reject('INVALID_TRANSITION'); + const index = journal.steps.findIndex((step) => step.operationId === operationId); + if (index < 0) return reject('INVALID_JOURNAL'); + const step = journal.steps[index]; + if (step === undefined) return reject('INVALID_JOURNAL'); + if (step.state !== 'COMMITTED') return reject('DUPLICATE_STEP'); + const nextSteps = journal.steps.slice(); + nextSteps[index] = Object.freeze({ ...step, state: 'COMPENSATED' }); + const nextState = nextSteps + .filter((candidate) => candidate.afterFingerprint !== null) + .every((candidate) => candidate.state === 'COMPENSATED') + ? 'COMPENSATED' + : 'COMPENSATING'; + return cloneJournal(journal, { state: nextState, steps: Object.freeze(nextSteps) }); +} + +export function recoverJournal( + journal: LocalJournal, + checkpoints: ReadonlyMap, +): LocalJournal { + if (journal.state !== 'COMMITTING') return reject('INVALID_TRANSITION'); + if ([...checkpoints.values()].some((state) => state === 'UNKNOWN')) { + return reject('RECOVERY_CONFLICT'); + } + const nextSteps = journal.steps.map((step) => { + if (checkpoints.get(step.operationId) === 'COMMITTED' && step.state === 'PENDING') { + return Object.freeze({ ...step, state: 'COMMITTED' as const }); + } + return step; + }); + const nextState = nextSteps.every((candidate) => candidate.state === 'COMMITTED') + ? 'COMMITTED' + : 'COMMITTING'; + return cloneJournal(journal, { state: nextState, steps: Object.freeze(nextSteps) }); +} + +export function buildUndoPlan( + journal: LocalJournal, + { + nowMs, + currentFingerprints, + }: { readonly nowMs: number; readonly currentFingerprints: ReadonlyMap }, +): UndoPlan { + if (journal.state !== 'COMMITTED') return reject('UNDO_NOT_AVAILABLE'); + if (nowMs > journal.undoExpiresAtMs) return reject('UNDO_EXPIRED'); + const operations: UndoOperation[] = []; + for (const step of [...journal.steps].reverse()) { + if (!step.undoable || step.afterFingerprint === null) return reject('UNDO_NOT_AVAILABLE'); + if (currentFingerprints.get(step.destinationPath) !== step.afterFingerprint) { + return reject('UNDO_CONFLICT'); + } + operations.push({ + operationId: `undo-${step.operationId}`, + action: 'RENAME', + sourcePath: step.destinationPath, + destinationPath: step.sourcePath, + expectedSourceFingerprint: step.afterFingerprint, + }); + } + return Object.freeze({ + executionId: journal.executionId, + planHash: journal.planHash, + operations: Object.freeze(operations), + }); +} + +export function beginUndo( + journal: LocalJournal, + options: { + readonly nowMs: number; + readonly currentFingerprints: ReadonlyMap; + }, +): { readonly journal: LocalJournal; readonly plan: UndoPlan } { + const plan = buildUndoPlan(journal, options); + return { journal: cloneJournal(journal, { state: 'UNDOING' }), plan }; +} + +export function completeUndo(journal: LocalJournal): LocalJournal { + if (journal.state !== 'UNDOING') return reject('INVALID_TRANSITION'); + return cloneJournal(journal, { state: 'UNDONE' }); +} diff --git a/apps/desktop/src/features/folder-autopilot/local-safety.ts b/apps/desktop/src/features/folder-autopilot/local-safety.ts new file mode 100644 index 00000000..4c7c62a8 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/local-safety.ts @@ -0,0 +1,141 @@ +export type LocalGrantStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'SUSPENDED'; +export type LocalRequestedEffect = 'READ' | 'WRITE'; +export type LocalApprovalState = 'APPROVED' | 'NOT_REQUIRED' | 'PENDING'; +export type LocalSafetyReasonCode = + | 'APPROVAL_REQUIRED' + | 'AUTHORIZED' + | 'CAPABILITY_DIGEST_MISMATCH' + | 'DEVICE_GRANT_EXPIRED' + | 'DEVICE_GRANT_REVOKED' + | 'DEVICE_GRANT_SUSPENDED'; + +export interface LocalExecutionAuthorization { + readonly deviceGrantId: string; + readonly grantStatus: LocalGrantStatus; + readonly expectedCapabilityDigest: string; + readonly actualCapabilityDigest: string; + readonly effectiveDataModePolicyRef: string; + readonly planHash: string; + readonly sourceFingerprint: string; + readonly requestedEffect: LocalRequestedEffect; + readonly requiresApproval: boolean; + readonly approvalState: LocalApprovalState; +} + +export interface LocalExecutionDecision { + readonly accepted: boolean; + readonly reasonCode: LocalSafetyReasonCode; +} + +export interface ContentFreeExecutionPayload { + readonly deviceGrantId: string; + readonly effectiveDataModePolicyRef: string; + readonly planHash: string; + readonly requestedEffect: LocalRequestedEffect; + readonly sourceFingerprint: string; +} + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const AUTHORIZATION_KEYS = [ + 'actualCapabilityDigest', + 'approvalState', + 'deviceGrantId', + 'effectiveDataModePolicyRef', + 'expectedCapabilityDigest', + 'grantStatus', + 'planHash', + 'requestedEffect', + 'requiresApproval', + 'sourceFingerprint', +] as const; + +function reject(): never { + throw new Error('INVALID_EXECUTION_AUTHORIZATION'); +} + +function validateAuthorization(value: unknown): LocalExecutionAuthorization { + if ( + typeof value !== 'object' || + value === null || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return reject(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== AUTHORIZATION_KEYS.length || + keys.some( + (key) => + typeof key !== 'string' || + !AUTHORIZATION_KEYS.includes(key as (typeof AUTHORIZATION_KEYS)[number]), + ) + ) { + return reject(); + } + const input = value as Record<(typeof AUTHORIZATION_KEYS)[number], unknown>; + if ( + typeof input.deviceGrantId !== 'string' || + !SAFE_ID.test(input.deviceGrantId) || + typeof input.effectiveDataModePolicyRef !== 'string' || + !SAFE_ID.test(input.effectiveDataModePolicyRef) || + typeof input.expectedCapabilityDigest !== 'string' || + !SHA256.test(input.expectedCapabilityDigest) || + typeof input.actualCapabilityDigest !== 'string' || + !SHA256.test(input.actualCapabilityDigest) || + typeof input.planHash !== 'string' || + !SHA256.test(input.planHash) || + typeof input.sourceFingerprint !== 'string' || + !SHA256.test(input.sourceFingerprint) || + !['ACTIVE', 'EXPIRED', 'REVOKED', 'SUSPENDED'].includes(input.grantStatus as string) || + !['READ', 'WRITE'].includes(input.requestedEffect as string) || + !['APPROVED', 'NOT_REQUIRED', 'PENDING'].includes(input.approvalState as string) || + typeof input.requiresApproval !== 'boolean' + ) { + return reject(); + } + return input as LocalExecutionAuthorization; +} + +export function authorizeLocalExecution( + value: LocalExecutionAuthorization, +): LocalExecutionDecision { + const authorization = validateAuthorization(value); + return evaluateAuthorization(authorization); +} + +function evaluateAuthorization(authorization: LocalExecutionAuthorization): LocalExecutionDecision { + if (authorization.grantStatus === 'REVOKED') { + return { accepted: false, reasonCode: 'DEVICE_GRANT_REVOKED' }; + } + if (authorization.grantStatus === 'EXPIRED') { + return { accepted: false, reasonCode: 'DEVICE_GRANT_EXPIRED' }; + } + if (authorization.grantStatus === 'SUSPENDED') { + return { accepted: false, reasonCode: 'DEVICE_GRANT_SUSPENDED' }; + } + if (authorization.expectedCapabilityDigest !== authorization.actualCapabilityDigest) { + return { accepted: false, reasonCode: 'CAPABILITY_DIGEST_MISMATCH' }; + } + if (authorization.requiresApproval && authorization.approvalState !== 'APPROVED') { + return { accepted: false, reasonCode: 'APPROVAL_REQUIRED' }; + } + return { accepted: true, reasonCode: 'AUTHORIZED' }; +} + +export function buildContentFreeExecutionPayload( + value: LocalExecutionAuthorization, +): ContentFreeExecutionPayload { + const authorization = validateAuthorization(value); + const decision = evaluateAuthorization(authorization); + if (!decision.accepted) { + throw new Error(`LOCAL_EXECUTION_NOT_AUTHORIZED:${decision.reasonCode}`); + } + return Object.freeze({ + deviceGrantId: authorization.deviceGrantId, + effectiveDataModePolicyRef: authorization.effectiveDataModePolicyRef, + planHash: authorization.planHash, + requestedEffect: authorization.requestedEffect, + sourceFingerprint: authorization.sourceFingerprint, + }); +} diff --git a/apps/desktop/src/features/folder-autopilot/path-containment.ts b/apps/desktop/src/features/folder-autopilot/path-containment.ts new file mode 100644 index 00000000..64b9deb5 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/path-containment.ts @@ -0,0 +1,99 @@ +import path from 'node:path'; + +export type ReparsePointPolicy = 'REJECT' | 'ALLOW_WITHIN_ROOT'; + +export type PathContainmentCode = + | 'INVALID_LOCAL_PATH' + | 'PATH_OUTSIDE_AUTHORIZATION' + | 'PATH_REPARSE_POINT'; + +export class PathContainmentError extends Error { + readonly code: PathContainmentCode; + + constructor(code: PathContainmentCode) { + super(code); + this.name = 'PathContainmentError'; + this.code = code; + } +} + +export interface PathContainmentOptions { + readonly canonicalRoot: string; + readonly realpath: (value: string) => string; + readonly isReparsePoint?: (value: string) => boolean; + readonly reparsePointPolicy?: ReparsePointPolicy; +} + +export interface PathContainmentGuard { + readonly canonicalRoot: string; + assertContained(candidate: string): string; + relativeName(candidate: string): string; +} + +function reject(code: PathContainmentCode): never { + throw new PathContainmentError(code); +} + +export function canonicalizeWindowsPath(value: string): string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) { + return reject('INVALID_LOCAL_PATH'); + } + const normalized = path.win32.normalize(value.replaceAll('/', '\\')); + if (!path.win32.isAbsolute(normalized)) return reject('INVALID_LOCAL_PATH'); + const parsed = path.win32.parse(normalized); + if (normalized !== parsed.root) return normalized.replace(/[\\]+$/, ''); + return parsed.root; +} + +function caseFold(value: string): string { + return value.toLowerCase(); +} + +function isContained(root: string, candidate: string): boolean { + const relative = path.win32.relative(caseFold(root), caseFold(candidate)); + return ( + relative.length === 0 || + (!relative.startsWith('..\\') && relative !== '..' && !path.win32.isAbsolute(relative)) + ); +} + +export function createPathContainmentGuard({ + canonicalRoot, + realpath, + isReparsePoint = () => false, + reparsePointPolicy = 'REJECT', +}: PathContainmentOptions): PathContainmentGuard { + const root = canonicalizeWindowsPath(canonicalRoot); + let resolvedRoot: string; + try { + resolvedRoot = canonicalizeWindowsPath(realpath(root)); + } catch { + return reject('INVALID_LOCAL_PATH'); + } + + const assertContained = (candidate: string): string => { + const canonicalCandidate = canonicalizeWindowsPath(candidate); + if (reparsePointPolicy === 'REJECT' && isReparsePoint(canonicalCandidate)) { + return reject('PATH_REPARSE_POINT'); + } + let resolvedCandidate: string; + try { + resolvedCandidate = canonicalizeWindowsPath(realpath(canonicalCandidate)); + } catch { + return reject('INVALID_LOCAL_PATH'); + } + if (!isContained(resolvedRoot, resolvedCandidate)) { + return reject('PATH_OUTSIDE_AUTHORIZATION'); + } + return resolvedCandidate; + }; + + return Object.freeze({ + canonicalRoot: resolvedRoot, + assertContained, + relativeName: (candidate: string): string => { + const resolvedCandidate = assertContained(candidate); + return path.win32.relative(resolvedRoot, resolvedCandidate); + }, + }); +} diff --git a/apps/desktop/src/main/adapters/electron-folder-grant.adapter.ts b/apps/desktop/src/main/adapters/electron-folder-grant.adapter.ts new file mode 100644 index 00000000..7c0fd125 --- /dev/null +++ b/apps/desktop/src/main/adapters/electron-folder-grant.adapter.ts @@ -0,0 +1,83 @@ +import { opendir as openDirectory } from 'node:fs/promises'; + +import type { FolderGrantPort } from '../../application/folder-grant.port.ts'; +import { parseFolderGrantState, type FolderGrantState } from '../../shared/desktop-contract-v1.ts'; + +const MAX_FOLDER_FILES = 10_000; + +interface FolderDialogLike { + showOpenDialog(options: { + readonly properties: readonly ['openDirectory']; + }): Promise<{ readonly canceled: boolean; readonly filePaths: readonly string[] }>; +} + +interface FolderEntryLike { + isFile(): boolean; + isSymbolicLink(): boolean; +} + +interface FolderDirectoryLike extends AsyncIterable { + close(): Promise; +} + +type OpenDirectory = (folderPath: string) => Promise; + +export interface ElectronFolderGrantAdapterInput { + readonly dialog: FolderDialogLike; + readonly now?: () => Date; + readonly opendir?: OpenDirectory; +} + +function notGranted(): FolderGrantState { + return parseFolderGrantState({ fileCount: 0, lastScanAt: null, status: 'not-granted' }); +} + +/** Selects and audits a local folder while returning no path or file names to the renderer. */ +export class ElectronFolderGrantAdapter implements FolderGrantPort { + readonly #dialog: FolderDialogLike; + readonly #now: () => Date; + readonly #opendir: OpenDirectory; + + public constructor({ + dialog, + now = () => new Date(), + opendir = openDirectory, + }: ElectronFolderGrantAdapterInput) { + this.#dialog = dialog; + this.#now = now; + this.#opendir = opendir; + } + + public async grantFolder(): Promise { + let directory: FolderDirectoryLike | undefined; + try { + const selection = await this.#dialog.showOpenDialog({ properties: ['openDirectory'] }); + const folderPath = selection.filePaths[0]; + if (selection.canceled || folderPath === undefined || folderPath.length === 0) + return notGranted(); + directory = await this.#opendir(folderPath); + let fileCount = 0; + for await (const entry of directory) { + if (entry.isFile() && !entry.isSymbolicLink()) { + fileCount += 1; + if (fileCount > MAX_FOLDER_FILES) return notGranted(); + } + } + return parseFolderGrantState({ + fileCount, + lastScanAt: this.#now().toISOString(), + status: 'granted', + }); + } catch { + return notGranted(); + } finally { + if (directory !== undefined) { + try { + await directory.close(); + } catch { + // The result remains content-free even when cleanup fails. + } + } + } + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1e343c63..6fad2b11 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; -import { app, BrowserWindow, ipcMain, session } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, session } from 'electron'; +import { ElectronFolderGrantAdapter } from './adapters/electron-folder-grant.adapter.ts'; import { LockedLocalStateAdapter } from './adapters/locked-local-state.adapter.ts'; import { UnavailableSidecarAdapter } from './adapters/unavailable-sidecar.adapter.ts'; import { @@ -26,6 +27,11 @@ async function openDesktopWindow(): Promise { applicationVersion: app.getVersion(), locale: 'vi-VN', }); + const folderGrant = new ElectronFolderGrantAdapter({ + dialog: { + showOpenDialog: (options) => dialog.showOpenDialog({ properties: [...options.properties] }), + }, + }); const sidecar = new UnavailableSidecarAdapter(); await createDesktopWindow({ @@ -36,6 +42,7 @@ async function openDesktopWindow(): Promise { expectedRendererUrl, getActiveWindow: () => activeWindow as unknown as DesktopWindowLike, ipcMain: ipcMain as unknown as DesktopIpcRegistrationInput['ipcMain'], + folderGrant, localState, sidecar, }); diff --git a/apps/desktop/src/main/ipc-registry.ts b/apps/desktop/src/main/ipc-registry.ts index e2dbbfdb..7d603638 100644 --- a/apps/desktop/src/main/ipc-registry.ts +++ b/apps/desktop/src/main/ipc-registry.ts @@ -1,8 +1,10 @@ import type { LocalStatePort } from '../application/local-state.port.ts'; +import type { FolderGrantPort } from '../application/folder-grant.port.ts'; import type { SidecarLifecyclePort } from '../application/sidecar-lifecycle.port.ts'; import { DESKTOP_IPC_CHANNELS, parseDesktopSafeState, + parseFolderGrantState, parseSidecarSafeStatus, type DesktopIpcChannel, } from '../shared/desktop-contract-v1.ts'; @@ -36,6 +38,7 @@ export interface DesktopIpcRegistrationInput { readonly expectedRendererUrl: string; readonly getActiveWindow: () => WindowLike | null; readonly ipcMain: IpcMainLike; + readonly folderGrant?: FolderGrantPort; readonly localState: LocalStatePort; readonly sidecar: SidecarLifecyclePort; } @@ -99,6 +102,7 @@ export function registerDesktopIpcV1({ expectedRendererUrl, getActiveWindow, ipcMain, + folderGrant, localState, sidecar, }: DesktopIpcRegistrationInput): () => void { @@ -106,7 +110,17 @@ export function registerDesktopIpcV1({ if (previous !== undefined) previous.active = false; for (const channel of Object.values(DESKTOP_IPC_CHANNELS)) ipcMain.removeHandler(channel); - const handlers: Record = { + const handlers: Partial> = { + ...(folderGrant === undefined + ? {} + : { + [DESKTOP_IPC_CHANNELS.folderGrant]: guardedHandler( + expectedRendererUrl, + getActiveWindow, + () => folderGrant.grantFolder(), + parseFolderGrantState, + ), + }), [DESKTOP_IPC_CHANNELS.sessionGetSafeState]: guardedHandler( expectedRendererUrl, getActiveWindow, diff --git a/apps/desktop/src/preload/bridge-v1.ts b/apps/desktop/src/preload/bridge-v1.ts index 1e87af80..72df103c 100644 --- a/apps/desktop/src/preload/bridge-v1.ts +++ b/apps/desktop/src/preload/bridge-v1.ts @@ -1,6 +1,7 @@ import { DESKTOP_IPC_CHANNELS, parseDesktopSafeState, + parseFolderGrantState, parseSidecarSafeStatus, type DesktopBridgeV1, type DesktopIpcChannel, @@ -19,11 +20,17 @@ export function createDesktopBridgeV1(invoke: DesktopInvoke): DesktopBridgeV1 { return parseDesktopSafeState(await invoke(DESKTOP_IPC_CHANNELS.sessionGetSafeState)); }, }); + const folder = Object.freeze({ + grant: async (...argumentsList: unknown[]) => { + rejectUnexpectedArguments(argumentsList); + return parseFolderGrantState(await invoke(DESKTOP_IPC_CHANNELS.folderGrant)); + }, + }); const sidecar = Object.freeze({ getStatus: async (...argumentsList: unknown[]) => { rejectUnexpectedArguments(argumentsList); return parseSidecarSafeStatus(await invoke(DESKTOP_IPC_CHANNELS.sidecarGetStatus)); }, }); - return Object.freeze({ v1: Object.freeze({ session, sidecar }) }); + return Object.freeze({ v1: Object.freeze({ folder, session, sidecar }) }); } diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 9b9d4fa8..f0302f0a 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -3,6 +3,7 @@ import wordmarkUrl from '@databreeze/design-tokens/brand/generated/web/navigatio import type { DesktopLocale, DesktopSafeState, + FolderGrantState, SidecarSafeStatus, } from '../shared/desktop-contract-v1.ts'; @@ -18,6 +19,11 @@ const messages = { privacy: 'Không có đường dẫn hoặc nội dung tệp nào được gửi tới giao diện này.', privacyTitle: 'Ranh giới riêng tư', version: 'Phiên bản ứng dụng', + folderTitle: 'Thư mục được cấp quyền', + folderPick: 'Chọn thư mục để kiểm tra', + folderGranted: 'Đã cấp quyền cục bộ', + folderNotGranted: 'Chưa cấp quyền', + folderFiles: 'Tệp đã phát hiện', }, en: { agentDetail: 'The agent shows safe status only and contains no workspace data.', @@ -30,6 +36,11 @@ const messages = { privacy: 'No file path or file content is sent to this interface.', privacyTitle: 'Privacy boundary', version: 'Application version', + folderTitle: 'Approved folder', + folderPick: 'Choose a folder to audit', + folderGranted: 'Local permission granted', + folderNotGranted: 'No folder granted', + folderFiles: 'Files discovered', }, } as const; @@ -45,13 +56,29 @@ const initialSidecar: SidecarSafeStatus = { lifecycle: 'not-installed', protocolVersion: null, }; +const initialFolder: FolderGrantState = { + fileCount: 0, + lastScanAt: null, + status: 'not-granted', +}; export function DesktopApp() { const [locale, setLocale] = useState('vi-VN'); const [safeState, setSafeState] = useState(initialState); const [sidecarStatus, setSidecarStatus] = useState(initialSidecar); + const [folderState, setFolderState] = useState(initialFolder); const copy = messages[locale]; + async function grantFolder(): Promise { + const folder = window.databreezeDesktop?.v1.folder; + if (folder === undefined) return; + try { + setFolderState(await folder.grant()); + } catch { + setFolderState(initialFolder); + } + } + useEffect(() => { let active = true; const bridge = window.databreezeDesktop; @@ -127,6 +154,21 @@ export function DesktopApp() { +
+
+

{copy.folderTitle}

+

{folderState.status === 'granted' ? copy.folderGranted : copy.folderNotGranted}

+
+
+ + {copy.folderFiles}: {new Intl.NumberFormat(locale).format(folderState.fileCount)} + + +
+
+