Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
54cc170
docs(folder-autopilot): add module release evidence checklist
BeforeLights Aug 4, 2026
5114680
feat(engine): add content-free file observations
BeforeLights Aug 4, 2026
c7e3c41
feat(engine): evaluate bounded autopilot plans
BeforeLights Aug 4, 2026
92dd2ed
test(web): specify safe Folder Autopilot API boundary
BeforeLights Aug 4, 2026
ec28838
feat(web): add content-free Folder Autopilot API client
BeforeLights Aug 4, 2026
67fd769
feat(engine): register typed autopilot plan action
BeforeLights Aug 4, 2026
c5494db
test(fa): specify profile binding and assignment invariants
BeforeLights Aug 4, 2026
deda24a
feat(domain): add folder autopilot contracts
BeforeLights Aug 4, 2026
b448a51
test(domain): register folder autopilot public entry point
BeforeLights Aug 4, 2026
eb6cffd
test(fa): specify tenant-scoped service behavior
BeforeLights Aug 4, 2026
2976f9a
feat(fa): add tenant-scoped profile binding assignment service
BeforeLights Aug 4, 2026
d698e85
feat(desktop): enforce local folder path containment
BeforeLights Aug 4, 2026
ab0e1a7
feat(desktop): stabilize and fingerprint local files
BeforeLights Aug 4, 2026
7a63380
feat(fa): add durable folder autopilot persistence boundary
BeforeLights Aug 4, 2026
66d29b9
feat(web): add Folder Autopilot workspace surfaces
BeforeLights Aug 4, 2026
cc5c8e0
feat(desktop): add guarded local action execution
BeforeLights Aug 4, 2026
7678c26
feat(fa): expose tenant-scoped Nest API
BeforeLights Aug 4, 2026
6d5b2fe
feat(desktop): journal staged actions and undo plans
BeforeLights Aug 4, 2026
2491760
fix(desktop): keep autopilot safety tests strict-safe
BeforeLights Aug 4, 2026
79d9d94
feat(desktop): track undo completion state
BeforeLights Aug 4, 2026
c7df4d1
feat(android): add Folder Autopilot state model
BeforeLights Aug 4, 2026
87a9b61
feat(desktop): gate local execution on content-free grants
BeforeLights Aug 4, 2026
4500e89
feat(android): add Folder Autopilot review companion
BeforeLights Aug 4, 2026
f32ab4b
feat(desktop): allocate bounded autopilot collision names
BeforeLights Aug 4, 2026
26bf02f
feat(contracts): add folder autopilot v1 schemas
BeforeLights Aug 4, 2026
59bbb2f
feat(fa): add dashboard and authority facades
BeforeLights Aug 4, 2026
4da920a
test(android): specify Folder Autopilot offline intent queue
BeforeLights Aug 4, 2026
8b0b449
feat(android): queue Folder Autopilot intents offline
BeforeLights Aug 4, 2026
0f645aa
fix(desktop): fail closed on unsafe local actions
BeforeLights Aug 4, 2026
204191f
docs: record Folder Autopilot client slice evidence
BeforeLights Aug 4, 2026
e6f6d05
fix(android): persist Autopilot actions before local state
BeforeLights Aug 4, 2026
2685abd
fix(android): bound Autopilot offline mutation ids
BeforeLights Aug 4, 2026
5170417
docs: record bounded offline identifiers
BeforeLights Aug 4, 2026
6126688
fix(web): show Folder Autopilot profile list
BeforeLights Aug 4, 2026
b030e95
feat(api): expose content-free autopilot dashboard
BeforeLights Aug 4, 2026
2091f61
fix(web): align autopilot mutations with authority contracts
BeforeLights Aug 4, 2026
f7dae2f
fix(api): satisfy folder autopilot lint boundaries
BeforeLights Aug 4, 2026
7742975
fix(web): localize Autopilot profile status
BeforeLights Aug 4, 2026
946713e
test(api): verify folder autopilot composition
BeforeLights Aug 4, 2026
ebf79ef
docs: capture final Autopilot client boundary checks
BeforeLights Aug 4, 2026
62ecd92
chore(api): publish Folder Autopilot OpenAPI routes
BeforeLights Aug 4, 2026
4496355
docs: document Folder Autopilot slice evidence
BeforeLights Aug 4, 2026
8ebf26e
style(folder-autopilot): normalize module formatting
BeforeLights Aug 4, 2026
84b1db0
fix(contracts): preserve published recipe schema bytes
BeforeLights Aug 4, 2026
571d4ac
chore(contracts): protect published schema formatting
BeforeLights Aug 4, 2026
affc4ae
test(contracts): cover Folder Autopilot fixtures
BeforeLights Aug 4, 2026
06c26fc
test(web): bound Vitest worker concurrency
BeforeLights Aug 4, 2026
90ff0bd
test(api): register Folder Autopilot OpenAPI paths
BeforeLights Aug 4, 2026
575d4ae
fix(engine): refresh reviewed autopilot digest
BeforeLights Aug 4, 2026
53c3298
fix(folder-autopilot): harden local observations and execution safety
BeforeLights Aug 4, 2026
1eced0f
fix(api): harden Folder Autopilot persistence outcomes
BeforeLights Aug 4, 2026
fe252e2
fix(web): align Folder Autopilot dashboard contract
BeforeLights Aug 4, 2026
a723bbe
fix(android): expire and localize autopilot approvals
BeforeLights Aug 4, 2026
bfd7753
docs: record CodeRabbit PR 51 disposition
BeforeLights Aug 4, 2026
aada211
style(folder-autopilot): apply repository formatting
BeforeLights Aug 4, 2026
365c080
test(desktop): avoid unbound mock assertions
BeforeLights Aug 4, 2026
c34e114
fix(engine): normalize invalid autopilot observations
BeforeLights Aug 4, 2026
44bc18b
fix(desktop): retain receipts across late action failures
BeforeLights Aug 4, 2026
a5d0d37
fix(api): preserve autopilot facade availability status
BeforeLights Aug 4, 2026
d0f8710
test(api): prove autopilot assignment ownership
BeforeLights Aug 4, 2026
a41514c
docs: record automated follow-up review findings
BeforeLights Aug 4, 2026
430c9ff
fix(engine): refresh reviewed autopilot digest
BeforeLights Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
115 changes: 113 additions & 2 deletions apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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)) }) },
Expand All @@ -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(
Expand All @@ -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()
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FolderAutopilotExceptionSummary>,
) {
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()
Loading
Loading