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/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/test/folder-autopilot-journal.test.ts b/apps/desktop/test/folder-autopilot-journal.test.ts new file mode 100644 index 00000000..56bf4ab8 --- /dev/null +++ b/apps/desktop/test/folder-autopilot-journal.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { + JournalError, + beginJournal, + beginUndo, + buildUndoPlan, + completeUndo, + compensateJournal, + createJournal, + failJournal, + recordJournalStep, + recoverJournal, + type JournalStepInput, +} from '../src/features/folder-autopilot/local-journal.ts'; + +const source = 'C:\\Approved\\invoice.csv'; +const destination = 'C:\\Output\\invoice-reviewed.csv'; +const before = 'a'.repeat(64); +const after = 'b'.repeat(64); + +const steps: readonly JournalStepInput[] = [ + { + operationId: 'rename-1', + action: 'RENAME', + sourcePath: source, + destinationPath: destination, + beforeFingerprint: before, + undoable: true, + }, + { + operationId: 'move-1', + action: 'MOVE', + sourcePath: destination, + destinationPath: 'C:\\Archive\\invoice-reviewed.csv', + beforeFingerprint: after, + undoable: true, + }, +]; + +function committedJournal() { + let journal = createJournal({ + executionId: 'execution-1', + planHash: 'c'.repeat(64), + steps, + nowMs: 1_000, + undoWindowMs: 60_000, + }); + journal = beginJournal(journal); + journal = recordJournalStep(journal, 'rename-1', after); + journal = recordJournalStep(journal, 'move-1', 'd'.repeat(64)); + return journal; +} + +describe('Folder Autopilot local journal', () => { + it('commits staged steps exactly once and exposes a reverse undo plan', () => { + const journal = committedJournal(); + expect(journal.state).toBe('COMMITTED'); + expect(journal.steps.every((step) => step.state === 'COMMITTED')).toBe(true); + + const undo = buildUndoPlan(journal, { + nowMs: 2_000, + currentFingerprints: new Map([ + ['C:\\Archive\\invoice-reviewed.csv', 'd'.repeat(64)], + [destination, after], + ]), + }); + + expect(undo.operations.map((operation) => operation.sourcePath)).toEqual([ + 'C:\\Archive\\invoice-reviewed.csv', + destination, + ]); + expect(undo.operations[0]!.destinationPath).toBe(destination); + expect(undo.planHash).toBe('c'.repeat(64)); + }); + + it('refuses undo when a later user edit changed an affected file', () => { + const journal = committedJournal(); + expect(() => + buildUndoPlan(journal, { + nowMs: 2_000, + currentFingerprints: new Map([ + ['C:\\Archive\\invoice-reviewed.csv', 'changed'.padEnd(64, '0')], + [destination, after], + ]), + }), + ).toThrowError(new JournalError('UNDO_CONFLICT')); + }); + + it('recovers a crashed commit or enters an explained conflict', () => { + let journal = beginJournal( + createJournal({ + executionId: 'execution-1', + planHash: 'c'.repeat(64), + steps, + nowMs: 1_000, + undoWindowMs: 60_000, + }), + ); + journal = recordJournalStep(journal, 'rename-1', after); + expect(recoverJournal(journal, new Map([['rename-1', 'COMMITTED']])).state).toBe('COMMITTING'); + const fullyRecovered = recoverJournal( + journal, + new Map([ + ['rename-1', 'COMMITTED'], + ['move-1', 'COMMITTED'], + ]), + ); + expect(fullyRecovered.state).toBe('COMMITTED'); + expect(() => recoverJournal(journal, new Map([['rename-1', 'UNKNOWN']]))).toThrow( + 'RECOVERY_CONFLICT', + ); + }); + + it('supports compensation after a staged failure and bounds undo expiry', () => { + let journal = beginJournal( + createJournal({ + executionId: 'execution-1', + planHash: 'c'.repeat(64), + steps, + nowMs: 1_000, + undoWindowMs: 60_000, + }), + ); + journal = recordJournalStep(journal, 'rename-1', after); + journal = failJournal(journal); + journal = compensateJournal(journal, 'rename-1'); + expect(journal.state).toBe('COMPENSATED'); + expect(() => + buildUndoPlan(committedJournal(), { + nowMs: 61_001, + currentFingerprints: new Map(), + }), + ).toThrowError(new JournalError('UNDO_EXPIRED')); + }); + + it('rejects COPY steps marked undoable until delete effects are modeled', () => { + expect(() => + createJournal({ + executionId: 'execution-copy', + planHash: 'c'.repeat(64), + steps: [ + { + operationId: 'copy-1', + action: 'COPY', + sourcePath: source, + destinationPath: destination, + beforeFingerprint: before, + undoable: true, + }, + ], + nowMs: 1_000, + undoWindowMs: 60_000, + }), + ).toThrowError(new JournalError('INVALID_JOURNAL')); + }); + + it('tracks the undo lifecycle without erasing the original journal', () => { + const { journal: undoing, plan } = beginUndo(committedJournal(), { + nowMs: 2_000, + currentFingerprints: new Map([ + ['C:\\Archive\\invoice-reviewed.csv', 'd'.repeat(64)], + [destination, after], + ]), + }); + expect(undoing.state).toBe('UNDOING'); + expect(plan.operations).toHaveLength(2); + expect(completeUndo(undoing).state).toBe('UNDONE'); + expect(() => completeUndo(completeUndo(undoing))).toThrow('INVALID_TRANSITION'); + }); +}); diff --git a/apps/desktop/test/folder-autopilot-local-actions.test.ts b/apps/desktop/test/folder-autopilot-local-actions.test.ts new file mode 100644 index 00000000..dcb64a36 --- /dev/null +++ b/apps/desktop/test/folder-autopilot-local-actions.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + executeLocalPlan, + LocalActionFailure, + type LocalActionPlan, + type LocalFileSystem, +} from '../src/features/folder-autopilot/local-actions.ts'; + +const sourcePath = 'C:\\Approved\\invoice.csv'; +const destinationPath = 'C:\\Output\\invoice-reviewed.csv'; + +function dependencies(overrides: Partial = {}) { + const fileSystem: LocalFileSystem = { + exists: vi.fn(() => Promise.resolve(false)), + readFingerprint: vi.fn(() => Promise.resolve('a'.repeat(64))), + copyExclusive: vi.fn(() => Promise.resolve()), + renameExclusive: vi.fn(() => Promise.resolve()), + rename: vi.fn(() => Promise.resolve()), + ...overrides, + }; + return { + fileSystem, + sourceGuard: { assertContained: vi.fn((value: string) => value) }, + destinationGuard: { assertContained: vi.fn((value: string) => value) }, + }; +} + +function plan(...operations: LocalActionPlan['operations']): LocalActionPlan { + return { operations }; +} + +describe('Folder Autopilot local typed actions', () => { + it('evaluates inspect and validate without a filesystem mutation', async () => { + const deps = dependencies(); + const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); + const rename = vi.spyOn(deps.fileSystem, 'rename'); + const result = await executeLocalPlan( + plan( + { + operationId: 'inspect-1', + action: 'INSPECT', + sourcePath, + sourceFingerprint: 'a'.repeat(64), + }, + { + operationId: 'validate-1', + action: 'VALIDATE', + sourcePath, + sourceFingerprint: 'a'.repeat(64), + }, + ), + deps, + ); + + expect(result.map((item) => item.status)).toEqual(['APPLIED', 'APPLIED']); + expect(copyExclusive).not.toHaveBeenCalled(); + expect(rename).not.toHaveBeenCalled(); + }); + + it('revalidates containment and source fingerprint before a rename', async () => { + const deps = dependencies(); + const renameExclusive = vi.spyOn(deps.fileSystem, 'renameExclusive'); + const result = await executeLocalPlan( + plan({ + operationId: 'rename-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + deps, + ); + + expect(result[0]!.status).toBe('APPLIED'); + expect(deps.sourceGuard.assertContained).toHaveBeenCalledWith(sourcePath); + expect(deps.destinationGuard.assertContained).toHaveBeenCalledWith(destinationPath); + expect(renameExclusive).toHaveBeenCalledWith(sourcePath, destinationPath); + }); + + it('never overwrites a destination and handles SKIP explicitly', async () => { + const deps = dependencies({ exists: vi.fn(() => Promise.resolve(true)) }); + const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); + const result = await executeLocalPlan( + plan({ + operationId: 'copy-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + collisionPolicy: 'SKIP', + }), + deps, + ); + + expect(result).toEqual([{ operationId: 'copy-1', action: 'COPY', status: 'SKIPPED' }]); + expect(copyExclusive).not.toHaveBeenCalled(); + }); + + it('allocates a bounded deterministic unique name and returns the chosen destination', async () => { + const exists = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + const deps = dependencies({ exists }); + const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); + await expect( + executeLocalPlan( + plan({ + operationId: 'copy-unique-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + collisionPolicy: 'UNIQUE_NAME', + }), + deps, + ), + ).resolves.toEqual([ + { + operationId: 'copy-unique-1', + action: 'COPY', + status: 'APPLIED', + destinationPath: 'C:\\Output\\invoice-reviewed (2).csv', + }, + ]); + expect(copyExclusive).toHaveBeenCalledWith(sourcePath, 'C:\\Output\\invoice-reviewed (2).csv'); + expect(deps.destinationGuard.assertContained).toHaveBeenNthCalledWith( + 2, + 'C:\\Output\\invoice-reviewed (1).csv', + ); + expect(deps.destinationGuard.assertContained).toHaveBeenNthCalledWith( + 3, + 'C:\\Output\\invoice-reviewed (2).csv', + ); + }); + + it('prefers the exclusive rename port when the adapter provides it', async () => { + const renameExclusive = vi.fn>(() => + Promise.resolve(), + ); + const deps = dependencies({ renameExclusive }); + const rename = vi.spyOn(deps.fileSystem, 'rename'); + + await executeLocalPlan( + plan({ + operationId: 'rename-exclusive-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + deps, + ); + + expect(renameExclusive).toHaveBeenCalledWith(sourcePath, destinationPath); + expect(rename).not.toHaveBeenCalled(); + }); + + it('fails closed when only a non-exclusive rename primitive is available', async () => { + const deps = dependencies(); + delete deps.fileSystem.renameExclusive; + const rename = vi.spyOn(deps.fileSystem, 'rename'); + + await expect( + executeLocalPlan( + plan({ + operationId: 'rename-unsafe-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + deps, + ), + ).rejects.toMatchObject({ code: 'EXCLUSIVE_RENAME_REQUIRED' }); + expect(rename).not.toHaveBeenCalled(); + }); + + it('maps guard and filesystem failures to content-free stable errors', async () => { + const sourceGuardFailure = dependencies({ + readFingerprint: vi.fn(() => Promise.reject(new Error('source C:\\secret\\file.csv'))), + }); + await expect( + executeLocalPlan( + plan({ + operationId: 'read-failure-1', + action: 'INSPECT', + sourcePath, + sourceFingerprint: 'a'.repeat(64), + }), + sourceGuardFailure, + ), + ).rejects.toMatchObject({ code: 'LOCAL_IO_FAILED', message: 'LOCAL_IO_FAILED' }); + + const guardFailure = dependencies(); + guardFailure.destinationGuard.assertContained = vi.fn(() => { + throw new Error('destination C:\\secret\\file.csv'); + }); + await expect( + executeLocalPlan( + plan({ + operationId: 'guard-failure-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + guardFailure, + ), + ).rejects.toMatchObject({ code: 'LOCAL_IO_FAILED', message: 'LOCAL_IO_FAILED' }); + }); + + it('rejects unique-name exhaustion at the bounded allocation limit', async () => { + const exists = vi.fn(() => Promise.resolve(true)); + const deps = dependencies({ exists }); + + await expect( + executeLocalPlan( + plan({ + operationId: 'copy-unique-exhausted', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + collisionPolicy: 'UNIQUE_NAME', + }), + deps, + ), + ).rejects.toMatchObject({ code: 'DESTINATION_COLLISION' }); + expect(exists).toHaveBeenCalledTimes(1_001); + }); + + it('fails closed for collisions, stale plans, and unknown local effects', async () => { + const collisionDeps = dependencies({ exists: vi.fn(() => Promise.resolve(true)) }); + await expect( + executeLocalPlan( + plan({ + operationId: 'copy-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + collisionDeps, + ), + ).rejects.toMatchObject({ code: 'DESTINATION_COLLISION' }); + + const staleDeps = dependencies({ + readFingerprint: vi.fn(() => Promise.resolve('b'.repeat(64))), + }); + await expect( + executeLocalPlan( + plan({ + operationId: 'rename-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + staleDeps, + ), + ).rejects.toMatchObject({ code: 'STALE_PLAN' }); + }); + + it('preserves receipts when a later write fails', async () => { + const copyExclusive = vi + .fn() + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error('disk full')); + const deps = dependencies({ copyExclusive }); + try { + await executeLocalPlan( + plan( + { + operationId: 'copy-first', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }, + { + operationId: 'copy-second', + action: 'COPY', + sourcePath, + destinationPath: 'C:\\Output\\second.csv', + sourceFingerprint: 'a'.repeat(64), + }, + ), + deps, + ); + throw new Error('expected local action failure'); + } catch (error) { + expect(error).toBeInstanceOf(LocalActionFailure); + expect(error).toMatchObject({ code: 'LOCAL_IO_FAILED' }); + expect((error as LocalActionFailure).appliedReceipts).toHaveLength(1); + expect((error as LocalActionFailure).appliedReceipts[0]?.operationId).toBe('copy-first'); + } + }); + + it('preserves receipts when a later operation has a stale source', async () => { + const readFingerprint = vi + .fn() + .mockResolvedValueOnce('a'.repeat(64)) + .mockResolvedValueOnce('b'.repeat(64)); + const deps = dependencies({ readFingerprint }); + + await expect( + executeLocalPlan( + plan( + { + operationId: 'copy-first', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }, + { + operationId: 'copy-stale', + action: 'COPY', + sourcePath, + destinationPath: 'C:\\Output\\stale.csv', + sourceFingerprint: 'a'.repeat(64), + }, + ), + deps, + ), + ).rejects.toMatchObject({ + code: 'STALE_PLAN', + appliedReceipts: [{ operationId: 'copy-first', status: 'APPLIED' }], + }); + }); + + it('rejects missing operation identifiers before filesystem access', async () => { + const deps = dependencies(); + const readFingerprint = vi.spyOn(deps.fileSystem, 'readFingerprint'); + await expect( + executeLocalPlan( + { + operations: [ + { + operationId: undefined, + action: 'INSPECT', + sourcePath, + sourceFingerprint: 'a'.repeat(64), + }, + ], + } as unknown as LocalActionPlan, + deps, + ), + ).rejects.toMatchObject({ code: 'INVALID_PLAN' }); + expect(readFingerprint).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/test/folder-autopilot-local-safety.test.ts b/apps/desktop/test/folder-autopilot-local-safety.test.ts new file mode 100644 index 00000000..8ea9392d --- /dev/null +++ b/apps/desktop/test/folder-autopilot-local-safety.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + authorizeLocalExecution, + buildContentFreeExecutionPayload, + type LocalExecutionAuthorization, +} from '../src/features/folder-autopilot/local-safety.ts'; + +const valid: LocalExecutionAuthorization = { + deviceGrantId: 'grant-001', + grantStatus: 'ACTIVE', + expectedCapabilityDigest: 'a'.repeat(64), + actualCapabilityDigest: 'a'.repeat(64), + effectiveDataModePolicyRef: 'policy-001', + planHash: 'b'.repeat(64), + sourceFingerprint: 'c'.repeat(64), + requestedEffect: 'WRITE', + requiresApproval: true, + approvalState: 'APPROVED', +}; + +describe('Folder Autopilot local execution safety boundary', () => { + it('accepts an active matching grant and emits only content-free metadata', () => { + const decision = authorizeLocalExecution(valid); + expect(decision).toEqual({ accepted: true, reasonCode: 'AUTHORIZED' }); + + const payload = buildContentFreeExecutionPayload(valid); + expect(payload).toEqual({ + deviceGrantId: 'grant-001', + effectiveDataModePolicyRef: 'policy-001', + planHash: 'b'.repeat(64), + requestedEffect: 'WRITE', + sourceFingerprint: 'c'.repeat(64), + }); + expect(JSON.stringify(payload)).not.toMatch(/path|handle|bytes|content/i); + }); + + it('fails closed for revoked grants, capability drift, and missing approval', () => { + expect(authorizeLocalExecution({ ...valid, grantStatus: 'REVOKED' })).toEqual({ + accepted: false, + reasonCode: 'DEVICE_GRANT_REVOKED', + }); + expect(authorizeLocalExecution({ ...valid, actualCapabilityDigest: 'd'.repeat(64) })).toEqual({ + accepted: false, + reasonCode: 'CAPABILITY_DIGEST_MISMATCH', + }); + expect(authorizeLocalExecution({ ...valid, approvalState: 'PENDING' })).toEqual({ + accepted: false, + reasonCode: 'APPROVAL_REQUIRED', + }); + expect(() => buildContentFreeExecutionPayload({ ...valid, grantStatus: 'REVOKED' })).toThrow( + 'LOCAL_EXECUTION_NOT_AUTHORIZED:DEVICE_GRANT_REVOKED', + ); + }); + + it('rejects malformed metadata before any local action can run', () => { + expect(() => authorizeLocalExecution({ ...valid, deviceGrantId: 'C:\\secret' })).toThrow( + 'INVALID_EXECUTION_AUTHORIZATION', + ); + expect(() => + buildContentFreeExecutionPayload({ ...valid, sourceFingerprint: 'not-a-digest' }), + ).toThrow('INVALID_EXECUTION_AUTHORIZATION'); + }); +}); diff --git a/apps/desktop/test/folder-autopilot-observation.test.ts b/apps/desktop/test/folder-autopilot-observation.test.ts new file mode 100644 index 00000000..8ced5b42 --- /dev/null +++ b/apps/desktop/test/folder-autopilot-observation.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + captureStableObservation, + fingerprintBytes, + waitForStableFile, + type StableFileStat, +} from '../src/features/folder-autopilot/file-observation.ts'; + +const stableStat: StableFileStat = { + isFile: true, + isSymbolicLink: false, + sizeBytes: 4, + modifiedAtNs: '10', +}; + +describe('Folder Autopilot stable local observations', () => { + it('waits for two identical metadata samples before hashing', async () => { + const readStat = vi + .fn() + .mockResolvedValueOnce({ ...stableStat, sizeBytes: 3 }) + .mockResolvedValue(stableStat); + const sleep = vi.fn(() => Promise.resolve()); + + await expect(waitForStableFile(readStat, { maxAttempts: 4, sleep })).resolves.toEqual( + stableStat, + ); + expect(readStat).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it('retries transient lock failures and reports a bounded stable result', async () => { + const readStat = vi + .fn() + .mockRejectedValueOnce(new Error('sharing violation')) + .mockResolvedValue(stableStat); + + await expect( + waitForStableFile(readStat, { maxAttempts: 4, sleep: () => Promise.resolve() }), + ).resolves.toEqual(stableStat); + }); + + it('rejects links and non-files before bytes are read', async () => { + const readStat = vi.fn().mockResolvedValue({ + ...stableStat, + isSymbolicLink: true, + }); + await expect(waitForStableFile(readStat)).rejects.toMatchObject({ + code: 'PATH_REPARSE_POINT', + }); + }); + + it('fingerprints bytes and captures a content-free immutable observation', async () => { + const bytes = new TextEncoder().encode('data'); + expect(fingerprintBytes(bytes)).toBe( + '3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7', + ); + const observation = await captureStableObservation({ + observationId: 'obs-001', + displayName: 'Báo cáo.csv', + readStat: vi.fn().mockResolvedValue(stableStat), + readBytes: vi.fn(() => Promise.resolve(bytes)), + sleep: () => Promise.resolve(), + }); + + expect(observation.sizeBytes).toBe(4); + expect(observation.contentSha256).toBe(fingerprintBytes(bytes)); + expect(observation.stableExecutionKey).toHaveLength(64); + expect('path' in observation).toBe(false); + }); + + it('refuses bytes when the file changes while it is being read', async () => { + const readStat = vi + .fn() + .mockResolvedValueOnce(stableStat) + .mockResolvedValueOnce(stableStat) + .mockResolvedValue({ ...stableStat, modifiedAtNs: '11' }); + await expect( + captureStableObservation({ + observationId: 'obs-001', + displayName: 'report.csv', + readStat, + readBytes: () => Promise.resolve(new TextEncoder().encode('data')), + sleep: () => Promise.resolve(), + }), + ).rejects.toMatchObject({ code: 'FILE_CHANGED_DURING_READ' }); + }); + + it('preserves current-scale nanosecond timestamps as decimal strings', async () => { + const timestamp = '1764891234567890123'; + const observation = await captureStableObservation({ + observationId: 'obs-ns', + displayName: 'report.csv', + readStat: vi.fn().mockResolvedValue({ ...stableStat, modifiedAtNs: timestamp }), + readBytes: () => Promise.resolve(new TextEncoder().encode('data')), + sleep: () => Promise.resolve(), + }); + expect(observation.modifiedAtNs).toBe(timestamp); + }); +}); diff --git a/apps/desktop/test/folder-autopilot-path-containment.test.ts b/apps/desktop/test/folder-autopilot-path-containment.test.ts new file mode 100644 index 00000000..f6409579 --- /dev/null +++ b/apps/desktop/test/folder-autopilot-path-containment.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + PathContainmentError, + canonicalizeWindowsPath, + createPathContainmentGuard, +} from '../src/features/folder-autopilot/path-containment.ts'; + +describe('Folder Autopilot path containment', () => { + it('canonicalizes case and separators while preserving the root boundary', () => { + expect(canonicalizeWindowsPath('C:\\Approved\\')).toBe('C:\\Approved'); + expect(() => canonicalizeWindowsPath('Approved\\relative')).toThrow('INVALID_LOCAL_PATH'); + + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + }); + + expect(guard.assertContained('c:\\APPROVED\\Invoices\\01.csv')).toBe( + 'c:\\APPROVED\\Invoices\\01.csv', + ); + expect(() => guard.assertContained('C:\\Approved-neighbor\\01.csv')).toThrow( + 'PATH_OUTSIDE_AUTHORIZATION', + ); + }); + + it('rejects dot traversal after canonicalization', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + }); + + expect(() => guard.assertContained('C:\\Approved\\..\\Secrets\\payroll.csv')).toThrow( + 'PATH_OUTSIDE_AUTHORIZATION', + ); + }); + + it('rejects a symlink or junction that resolves outside the authorized root', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => + value.toLowerCase().includes('linked') ? 'C:\\Secrets\\payroll.csv' : value, + }); + + expect(() => guard.assertContained('C:\\Approved\\linked\\payroll.csv')).toThrow( + 'PATH_OUTSIDE_AUTHORIZATION', + ); + }); + + it('rejects reparse points before local access under the strict policy', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + isReparsePoint: (value) => value.toLowerCase().includes('junction'), + reparsePointPolicy: 'REJECT', + }); + + expect(() => guard.assertContained('C:\\Approved\\junction\\file.csv')).toThrow( + 'PATH_REPARSE_POINT', + ); + }); + + it('exposes only a content-free relative name after containment succeeds', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + }); + + expect(guard.relativeName('C:\\Approved\\Invoices\\01.csv')).toBe('Invoices\\01.csv'); + expect(() => guard.relativeName('C:\\Other\\01.csv')).toThrow(PathContainmentError); + }); +}); diff --git a/apps/web/src/app/feature-registry.ts b/apps/web/src/app/feature-registry.ts index 7bb5babf..f7828bc1 100644 --- a/apps/web/src/app/feature-registry.ts +++ b/apps/web/src/app/feature-registry.ts @@ -13,6 +13,7 @@ export const WEB_FEATURE_REGISTRY = Object.freeze([ { key: 'inbox', messageKey: 'nav.inbox', path: 'inbox' }, { key: 'jobs', messageKey: 'nav.jobs', path: 'jobs' }, { key: 'reviews', messageKey: 'nav.reviews', path: 'reviews' }, + { key: 'autopilot', path: 'autopilot' }, { key: 'approvals', messageKey: 'nav.approvals', path: 'approvals' }, { key: 'reports', messageKey: 'nav.reports', path: 'reports' }, { key: 'devices', messageKey: 'nav.devices', path: 'devices' }, diff --git a/apps/web/src/app/messages.ts b/apps/web/src/app/messages.ts index eb02efc5..93da5c99 100644 --- a/apps/web/src/app/messages.ts +++ b/apps/web/src/app/messages.ts @@ -84,6 +84,63 @@ const vietnameseMessages = { 'spreadsheetAudit.blocked.externalLink': 'Tệp có liên kết ngoài và không được làm mới.', 'spreadsheetAudit.blocked.unsupportedXml': 'Một phần XML không được hỗ trợ.', 'spreadsheetAudit.unknownSheet': 'Trang tính không xác định', + 'autopilot.heading': 'Folder Autopilot', + 'autopilot.caption': + 'Tạo và xem xét các quy trình thư mục được kiểm soát. Chỉ hiển thị mã, trạng thái và bằng chứng an toàn.', + 'autopilot.loading': 'Đang tải Folder Autopilot…', + 'autopilot.error': 'Không thể tải Folder Autopilot. Không có thay đổi nào được gửi.', + 'autopilot.retry': 'Tải lại an toàn', + 'autopilot.profile.heading': 'Hồ sơ', + 'autopilot.profile.facade': 'Lớp kiểm tra', + 'autopilot.profile.version': 'Phiên bản', + 'autopilot.profile.name': 'Tên hiển thị', + 'autopilot.profile.stabilization': 'Thời gian ổn định (giây)', + 'autopilot.profile.collision': 'Xử lý xung đột', + 'autopilot.profile.confidence': 'Ngưỡng tin cậy', + 'autopilot.profile.undoWindow': 'Thời gian hoàn tác (giờ)', + 'autopilot.profile.approval': 'Yêu cầu phê duyệt', + 'autopilot.profile.required': 'Bắt buộc', + 'autopilot.profile.optional': 'Tùy chọn', + 'autopilot.profile.dataMode': 'Chế độ dữ liệu', + 'autopilot.profile.save': 'Lưu hồ sơ', + 'autopilot.profile.saved': 'Hồ sơ đã được gửi để kiểm tra.', + 'autopilot.assignment.heading': 'Phân công', + 'autopilot.assignment.name': 'Phân công', + 'autopilot.assignment.state': 'Trạng thái', + 'autopilot.assignment.revision': 'Phiên bản', + 'autopilot.assignment.health': 'Sức khỏe watcher', + 'autopilot.assignment.pause': 'Tạm dừng phân công', + 'autopilot.assignment.paused': 'Đã tạm dừng', + 'autopilot.assignment.active': 'Đang hoạt động', + 'autopilot.approval.heading': 'Hàng đợi phê duyệt', + 'autopilot.approval.preview': 'Bản xem trước', + 'autopilot.approval.plan': 'Mã kế hoạch', + 'autopilot.approval.affected': 'Số mục ảnh hưởng', + 'autopilot.approval.blocked': 'Bị chặn', + 'autopilot.approval.approve': 'Phê duyệt preview', + 'autopilot.approval.reject': 'Từ chối preview', + 'autopilot.approval.pending': 'Đang chờ', + 'autopilot.approval.approved': 'Đã phê duyệt', + 'autopilot.approval.rejected': 'Đã từ chối', + 'autopilot.exceptions.heading': 'Ngoại lệ', + 'autopilot.exceptions.reason': 'Mã lý do', + 'autopilot.exceptions.severity': 'Mức độ', + 'autopilot.exceptions.status': 'Trạng thái', + 'autopilot.exceptions.open': 'Mở', + 'autopilot.outcomes.heading': 'Kết quả gần đây', + 'autopilot.outcomes.outcome': 'Kết quả', + 'autopilot.outcomes.affected': 'Ảnh hưởng', + 'autopilot.outcomes.undo': 'Hoàn tác', + 'autopilot.outcomes.undoRequested': 'Đã yêu cầu hoàn tác', + 'autopilot.outcomes.undoAvailable': 'Có thể hoàn tác', + 'autopilot.outcomes.handled': 'Đã xử lý', + 'autopilot.outcomes.exception': 'Ngoại lệ', + 'autopilot.reason.collision': 'Đích có xung đột', + 'autopilot.reason.collisionSkipped': 'Đã bỏ qua đích xung đột', + 'autopilot.reason.moveApproval': 'Di chuyển cần phê duyệt', + 'autopilot.reason.none': 'Không có mã lý do', + 'autopilot.actions': 'Thao tác', + 'autopilot.dataMode.hybrid': 'Lai', 'locale.english': 'English', 'locale.vietnamese': 'Tiếng Việt', 'nav.administration': 'Quản trị', @@ -191,6 +248,63 @@ const englishMessages: Readonly> = { 'spreadsheetAudit.blocked.externalLink': 'External links were detected and not refreshed.', 'spreadsheetAudit.blocked.unsupportedXml': 'Some XML content is unsupported.', 'spreadsheetAudit.unknownSheet': 'Unknown sheet', + 'autopilot.heading': 'Folder Autopilot', + 'autopilot.caption': + 'Author and review governed folder workflows. Only safe identifiers, statuses, and evidence are shown.', + 'autopilot.loading': 'Loading Folder Autopilot…', + 'autopilot.error': 'Folder Autopilot could not load. No changes were sent.', + 'autopilot.retry': 'Retry safely', + 'autopilot.profile.heading': 'Profiles', + 'autopilot.profile.facade': 'Validation facade', + 'autopilot.profile.version': 'Version', + 'autopilot.profile.name': 'Display name', + 'autopilot.profile.stabilization': 'Stabilization (seconds)', + 'autopilot.profile.collision': 'Collision policy', + 'autopilot.profile.confidence': 'Confidence threshold', + 'autopilot.profile.undoWindow': 'Undo window (hours)', + 'autopilot.profile.approval': 'Require approval', + 'autopilot.profile.required': 'Required', + 'autopilot.profile.optional': 'Optional', + 'autopilot.profile.dataMode': 'Data mode', + 'autopilot.profile.save': 'Save profile', + 'autopilot.profile.saved': 'Profile submitted for validation.', + 'autopilot.assignment.heading': 'Assignments', + 'autopilot.assignment.name': 'Assignment', + 'autopilot.assignment.state': 'State', + 'autopilot.assignment.revision': 'Revision', + 'autopilot.assignment.health': 'Watcher health', + 'autopilot.assignment.pause': 'Pause assignment', + 'autopilot.assignment.paused': 'Paused', + 'autopilot.assignment.active': 'Active', + 'autopilot.approval.heading': 'Approval queue', + 'autopilot.approval.preview': 'Preview', + 'autopilot.approval.plan': 'Plan hash', + 'autopilot.approval.affected': 'Affected', + 'autopilot.approval.blocked': 'Blocked', + 'autopilot.approval.approve': 'Approve preview', + 'autopilot.approval.reject': 'Reject preview', + 'autopilot.approval.pending': 'Pending', + 'autopilot.approval.approved': 'Approved', + 'autopilot.approval.rejected': 'Rejected', + 'autopilot.exceptions.heading': 'Exceptions', + 'autopilot.exceptions.reason': 'Reason code', + 'autopilot.exceptions.severity': 'Severity', + 'autopilot.exceptions.status': 'Status', + 'autopilot.exceptions.open': 'Open', + 'autopilot.outcomes.heading': 'Recent outcomes', + 'autopilot.outcomes.outcome': 'Outcome', + 'autopilot.outcomes.affected': 'Affected', + 'autopilot.outcomes.undo': 'Undo', + 'autopilot.outcomes.undoRequested': 'Undo requested', + 'autopilot.outcomes.undoAvailable': 'Undo available', + 'autopilot.outcomes.handled': 'Handled', + 'autopilot.outcomes.exception': 'Exception', + 'autopilot.reason.collision': 'Destination collision', + 'autopilot.reason.collisionSkipped': 'Destination collision skipped', + 'autopilot.reason.moveApproval': 'Move requires approval', + 'autopilot.reason.none': 'No reason codes', + 'autopilot.actions': 'Actions', + 'autopilot.dataMode.hybrid': 'Hybrid', 'locale.english': 'English', 'locale.vietnamese': 'Tiếng Việt', 'nav.administration': 'Administration', diff --git a/apps/web/src/app/navigation.ts b/apps/web/src/app/navigation.ts index 92945369..df755372 100644 --- a/apps/web/src/app/navigation.ts +++ b/apps/web/src/app/navigation.ts @@ -20,6 +20,7 @@ export interface WebAccessContext { export type NavigationKey = | 'administration' | 'approvals' + | 'autopilot' | 'audit' | 'devices' | 'inbox' @@ -56,6 +57,7 @@ export const WEB_NAVIGATION_REGISTRY = Object.freeze([ navigationItem('inbox', 'inbox', [PERMISSIONS_V1.ARTIFACT_RECORD_READ]), navigationItem('jobs', 'jobs', [PERMISSIONS_V1.JOB_EXECUTION_READ], ['automation']), navigationItem('reviews', 'reviews', [PERMISSIONS_V1.JOB_EXECUTION_READ], ['automation']), + navigationItem('autopilot', 'autopilot', [PERMISSIONS_V1.JOB_EXECUTION_READ], ['automation']), navigationItem('approvals', 'approvals', [PERMISSIONS_V1.APPROVAL_REQUEST_READ], ['governance']), navigationItem('reports', 'reports', [PERMISSIONS_V1.ARTIFACT_RECORD_READ], ['reports']), navigationItem('devices', 'devices', [PERMISSIONS_V1.DEVICE_IDENTITY_READ], ['devices']), diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index c25afccd..cb90f38b 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,4 +1,5 @@ import { DEFAULT_LOCALE_V1, SUPPORTED_LOCALES_V1 } from '@databreeze/i18n/v1'; +import { lazy, Suspense } from 'react'; import { Navigate, createBrowserRouter, @@ -20,6 +21,20 @@ import { SpreadsheetAuditPage } from '../features/spreadsheet-auditor/spreadshee import { WEB_FEATURE_REGISTRY } from './feature-registry.ts'; import { DEFAULT_ACCESS_CONTEXT, type WebAccessContext } from './navigation.ts'; +const LazyFolderAutopilotPage = lazy(() => + import('../features/folder-autopilot/folder-autopilot-page.tsx').then((module) => ({ + default: module.FolderAutopilotPage, + })), +); + +function FolderAutopilotRoute() { + return ( + }> + + + ); +} + const logicalRoots = new Set(WEB_FEATURE_REGISTRY.map((feature) => feature.path)); function canonicalPathname(pathname: string): string | undefined { @@ -58,7 +73,9 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] { ...WEB_FEATURE_REGISTRY.filter((feature) => feature.key !== 'workspace').map((feature) => ({ path: feature.path, element: - feature.key === 'inbox' ? ( + feature.key === 'autopilot' ? ( + + ) : feature.key === 'inbox' ? ( ) : feature.key === 'audit' ? ( diff --git a/apps/web/src/components/shell-layout.tsx b/apps/web/src/components/shell-layout.tsx index a16535af..fb8ef376 100644 --- a/apps/web/src/components/shell-layout.tsx +++ b/apps/web/src/components/shell-layout.tsx @@ -40,6 +40,7 @@ function navigationLabel(locale: 'en' | 'vi-VN', key: NavigationKey): string { return formatMessageV1(locale, registration.messageKey); if (key === 'usage') return appMessage(locale, 'nav.usage'); if (key === 'administration') return appMessage(locale, 'nav.administration'); + if (key === 'autopilot') return appMessage(locale, 'autopilot.heading'); return appMessage(locale, 'nav.audit'); } diff --git a/apps/web/src/features/folder-autopilot/folder-autopilot-api.ts b/apps/web/src/features/folder-autopilot/folder-autopilot-api.ts new file mode 100644 index 00000000..238b2a00 --- /dev/null +++ b/apps/web/src/features/folder-autopilot/folder-autopilot-api.ts @@ -0,0 +1,625 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +const UUID_ERROR = 'AUTOPILOT_RESPONSE_INVALID'; +const SAFE_TOKEN = /^[A-Z][A-Z0-9_.-]{1,63}$/u; +const SAFE_TEXT_LENGTH = 128; + +export type FolderAutopilotAssignmentState = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'RETIRED' | 'INVALID'; +export type FolderAutopilotCollisionPolicy = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type FolderAutopilotDataMode = 'LOCAL' | 'HYBRID' | 'CLOUD'; +export type FolderAutopilotPreviewStatus = 'READY' | 'NEEDS_APPROVAL' | 'BLOCKED' | 'EXPIRED'; +export type FolderAutopilotDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXPIRED'; +export type FolderAutopilotActionType = + | 'INSPECT' + | 'RENAME' + | 'COPY' + | 'MOVE' + | 'CONVERT' + | 'ROUTE'; +export type FolderAutopilotCollision = 'NONE' | 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type FolderAutopilotOutcome = + | 'QUEUED' + | 'WAITING_FOR_APPROVAL' + | 'RUNNING' + | 'HANDLED' + | 'EXCEPTION' + | 'UNDO_AVAILABLE' + | 'UNDO_EXPIRED'; +export type FolderAutopilotUndoState = + | 'AVAILABLE' + | 'REQUESTED' + | 'COMPLETED' + | 'CONFLICT' + | 'EXPIRED' + | 'NOT_ELIGIBLE'; + +export interface FolderAutopilotProfile { + readonly profileId: string; + readonly version: number; + readonly stabilizationSeconds: number; + readonly collisionPolicy: FolderAutopilotCollisionPolicy; + readonly confidenceThreshold: number; + readonly undoWindowHours: number; + readonly approvalRequired: boolean; + readonly recipeHash: string; + readonly updatedAt: string; +} + +export interface FolderAutopilotProfileInput { + readonly stabilizationSeconds: number; + readonly collisionPolicy: FolderAutopilotCollisionPolicy; + readonly undoWindowHours: number; +} + +export interface FolderAutopilotAssignment { + readonly assignmentId: string; + readonly profileId: string; + readonly jraRecipeVersionId: string; + readonly deviceId: string; + readonly inputBindingId: string; + readonly outputBindingId: string; + readonly dataModeConstraint?: FolderAutopilotDataMode; + readonly state: FolderAutopilotAssignmentState; + readonly approvalRequired: boolean; + readonly revision: number; + readonly updatedAt: string; +} + +export interface FolderAutopilotActionPlan { + readonly stepId: string; + readonly actionType: FolderAutopilotActionType; + readonly sourceArtifactVersionId: string; + readonly destinationBindingId?: string; + readonly collision: FolderAutopilotCollision; + readonly requiresApproval: boolean; +} + +export interface FolderAutopilotPreview { + readonly previewId: string; + readonly assignmentId: string; + readonly jraRecipeVersionId: string; + readonly planHash: string; + readonly status: FolderAutopilotPreviewStatus; + readonly affectedCount: number; + readonly blockedCount: number; + readonly actions: readonly FolderAutopilotActionPlan[]; + readonly reasonCodes: readonly string[]; + readonly createdAt: string; + readonly expiresAt: string; +} + +export interface FolderAutopilotApproval { + readonly approvalId: string; + readonly previewId: string; + readonly subjectHash: string; + readonly planHash: string; + readonly decision: FolderAutopilotDecision; + readonly expiresAt: string; + readonly updatedAt: string; +} + +export interface FolderAutopilotExecution { + readonly executionId: string; + readonly assignmentId: string; + readonly jraJobId: string; + readonly resultManifestId: string; + readonly planHash: string; + readonly revision: number; + readonly outcome: FolderAutopilotOutcome; + readonly affectedCount: number; + readonly handledCount: number; + readonly exceptionCount: number; + readonly reasonCodes: readonly string[]; + readonly undoState: FolderAutopilotUndoState; + readonly updatedAt: string; +} + +export interface FolderAutopilotException { + readonly exceptionId: string; + readonly assignmentId: string; + readonly executionId?: string; + readonly severity: 'INFO' | 'WARNING' | 'ERROR'; + readonly reasonCode: string; + readonly status: 'OPEN' | 'RESOLVED' | 'IGNORED'; + readonly createdAt: string; +} + +export interface FolderAutopilotHealth { + readonly assignmentId: string; + readonly watcherState: 'HEALTHY' | 'PAUSED' | 'OVERFLOWED' | 'OFFLINE'; + readonly lastHeartbeatAt: string; + readonly queueAgeSeconds: number; + readonly queuedCount: number; + readonly syncLagSeconds: number; +} + +export interface FolderAutopilotDashboard { + readonly schemaVersion: 1; + readonly profiles: readonly FolderAutopilotProfile[]; + readonly assignments: readonly FolderAutopilotAssignment[]; + readonly previews: readonly FolderAutopilotPreview[]; + readonly approvals: readonly FolderAutopilotApproval[]; + readonly executions: readonly FolderAutopilotExecution[]; + readonly exceptions: readonly FolderAutopilotException[]; + readonly health: readonly FolderAutopilotHealth[]; +} + +function apiBaseUrl(): string { + const configured: unknown = import.meta.env['VITE_DATABREEZE_API_BASE_URL']; + return typeof configured === 'string' && configured.trim() !== '' + ? configured.replace(/\/$/u, '') + : ''; +} + +function object(input: unknown): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new Error(UUID_ERROR); + return input as Record; +} + +function only(input: Record, keys: readonly string[]): void { + const allowed = new Set(keys); + if (Object.keys(input).some((key) => !allowed.has(key))) throw new Error(UUID_ERROR); +} + +function id(input: unknown): string { + const parsed = parseStableIdentifierV1(input); + if (!parsed.accepted) throw new Error(UUID_ERROR); + return parsed.value; +} + +function timestamp(input: unknown): string { + const parsed = parseStrictUtcTimestampV1(input); + if (!parsed.accepted) throw new Error(UUID_ERROR); + return parsed.value; +} + +function text(input: unknown): string { + if ( + typeof input !== 'string' || + input.length === 0 || + input.length > SAFE_TEXT_LENGTH || + input.trim() !== input || + Array.from(input).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) + throw new Error(UUID_ERROR); + return input; +} + +function token(input: unknown): string { + if (typeof input !== 'string' || !SAFE_TOKEN.test(input)) throw new Error(UUID_ERROR); + return input; +} + +function hash(input: unknown): string { + if (typeof input !== 'string' || !/^[0-9a-f]{64}$/u.test(input)) throw new Error(UUID_ERROR); + return input; +} + +function count(input: unknown): number { + if (typeof input !== 'number' || !Number.isSafeInteger(input) || input < 0) + throw new Error(UUID_ERROR); + return input; +} + +function revision(input: unknown): number { + const value = count(input); + if (value < 1) throw new Error(UUID_ERROR); + return value; +} + +function decimal(input: unknown): number { + if (typeof input !== 'number' || !Number.isFinite(input) || input < 0 || input > 1) + throw new Error(UUID_ERROR); + return input; +} + +function boundedSeconds(input: unknown, maximum: number): number { + const value = count(input); + if (value > maximum) throw new Error(UUID_ERROR); + return value; +} + +function versionNumber(input: unknown): number { + const value = boundedSeconds(input, 10_000); + if (value < 1) throw new Error(UUID_ERROR); + return value; +} + +function oneOf(input: unknown, values: readonly TValue[]): TValue { + if (typeof input !== 'string' || !values.includes(input as TValue)) throw new Error(UUID_ERROR); + return input as TValue; +} + +function list(input: unknown): readonly unknown[] { + if (!Array.isArray(input) || input.length > 512) throw new Error(UUID_ERROR); + return input; +} + +function parseProfile(input: unknown): FolderAutopilotProfile { + const value = object(input); + only(value, [ + 'profileId', + 'version', + 'stabilizationSeconds', + 'collisionPolicy', + 'confidenceThreshold', + 'undoWindowHours', + 'approvalRequired', + 'recipeHash', + 'updatedAt', + ]); + if (typeof value['approvalRequired'] !== 'boolean') throw new Error(UUID_ERROR); + return Object.freeze({ + profileId: id(value['profileId']), + version: versionNumber(value['version']), + stabilizationSeconds: boundedSeconds(value['stabilizationSeconds'], 86_400), + collisionPolicy: oneOf(value['collisionPolicy'], ['REVIEW', 'SKIP', 'UNIQUE_NAME']), + confidenceThreshold: decimal(value['confidenceThreshold']), + undoWindowHours: boundedSeconds(value['undoWindowHours'], 168), + approvalRequired: value['approvalRequired'], + recipeHash: hash(value['recipeHash']), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseAssignment(input: unknown): FolderAutopilotAssignment { + const value = object(input); + only(value, [ + 'assignmentId', + 'profileId', + 'jraRecipeVersionId', + 'deviceId', + 'inputBindingId', + 'outputBindingId', + 'dataModeConstraint', + 'state', + 'approvalRequired', + 'revision', + 'updatedAt', + ]); + if (typeof value['approvalRequired'] !== 'boolean') throw new Error(UUID_ERROR); + return Object.freeze({ + assignmentId: id(value['assignmentId']), + profileId: id(value['profileId']), + jraRecipeVersionId: id(value['jraRecipeVersionId']), + deviceId: id(value['deviceId']), + inputBindingId: id(value['inputBindingId']), + outputBindingId: id(value['outputBindingId']), + ...(value['dataModeConstraint'] === undefined + ? {} + : { dataModeConstraint: oneOf(value['dataModeConstraint'], ['LOCAL', 'HYBRID', 'CLOUD']) }), + state: oneOf(value['state'], ['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED', 'INVALID']), + approvalRequired: value['approvalRequired'], + revision: revision(value['revision']), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseAction(input: unknown): FolderAutopilotActionPlan { + const value = object(input); + only(value, [ + 'stepId', + 'actionType', + 'sourceArtifactVersionId', + 'destinationBindingId', + 'collision', + 'requiresApproval', + ]); + if (typeof value['requiresApproval'] !== 'boolean') throw new Error(UUID_ERROR); + const destinationBindingId = value['destinationBindingId']; + return Object.freeze({ + stepId: text(value['stepId']), + actionType: oneOf(value['actionType'], [ + 'INSPECT', + 'RENAME', + 'COPY', + 'MOVE', + 'CONVERT', + 'ROUTE', + ]), + sourceArtifactVersionId: id(value['sourceArtifactVersionId']), + ...(destinationBindingId === undefined + ? {} + : { destinationBindingId: id(destinationBindingId) }), + collision: oneOf(value['collision'], ['NONE', 'REVIEW', 'SKIP', 'UNIQUE_NAME']), + requiresApproval: value['requiresApproval'], + }); +} + +function reasonCodes(input: unknown): readonly string[] { + return Object.freeze(list(input).map(token)); +} + +function parsePreview(input: unknown): FolderAutopilotPreview { + const value = object(input); + only(value, [ + 'previewId', + 'assignmentId', + 'jraRecipeVersionId', + 'planHash', + 'status', + 'affectedCount', + 'blockedCount', + 'actions', + 'reasonCodes', + 'createdAt', + 'expiresAt', + ]); + return Object.freeze({ + previewId: id(value['previewId']), + assignmentId: id(value['assignmentId']), + jraRecipeVersionId: id(value['jraRecipeVersionId']), + planHash: hash(value['planHash']), + status: oneOf(value['status'], ['READY', 'NEEDS_APPROVAL', 'BLOCKED', 'EXPIRED']), + affectedCount: count(value['affectedCount']), + blockedCount: count(value['blockedCount']), + actions: Object.freeze(list(value['actions']).map(parseAction)), + reasonCodes: reasonCodes(value['reasonCodes']), + createdAt: timestamp(value['createdAt']), + expiresAt: timestamp(value['expiresAt']), + }); +} + +function parseApproval(input: unknown): FolderAutopilotApproval { + const value = object(input); + only(value, [ + 'approvalId', + 'previewId', + 'subjectHash', + 'planHash', + 'decision', + 'expiresAt', + 'updatedAt', + ]); + return Object.freeze({ + approvalId: id(value['approvalId']), + previewId: id(value['previewId']), + subjectHash: hash(value['subjectHash']), + planHash: hash(value['planHash']), + decision: oneOf(value['decision'], ['PENDING', 'APPROVED', 'REJECTED', 'EXPIRED']), + expiresAt: timestamp(value['expiresAt']), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseExecution(input: unknown): FolderAutopilotExecution { + const value = object(input); + only(value, [ + 'executionId', + 'assignmentId', + 'jraJobId', + 'resultManifestId', + 'planHash', + 'revision', + 'outcome', + 'affectedCount', + 'handledCount', + 'exceptionCount', + 'reasonCodes', + 'undoState', + 'updatedAt', + ]); + return Object.freeze({ + executionId: id(value['executionId']), + assignmentId: id(value['assignmentId']), + jraJobId: id(value['jraJobId']), + resultManifestId: id(value['resultManifestId']), + planHash: hash(value['planHash']), + revision: revision(value['revision']), + outcome: oneOf(value['outcome'], [ + 'QUEUED', + 'WAITING_FOR_APPROVAL', + 'RUNNING', + 'HANDLED', + 'EXCEPTION', + 'UNDO_AVAILABLE', + 'UNDO_EXPIRED', + ]), + affectedCount: count(value['affectedCount']), + handledCount: count(value['handledCount']), + exceptionCount: count(value['exceptionCount']), + reasonCodes: reasonCodes(value['reasonCodes']), + undoState: oneOf(value['undoState'], [ + 'AVAILABLE', + 'REQUESTED', + 'COMPLETED', + 'CONFLICT', + 'EXPIRED', + 'NOT_ELIGIBLE', + ]), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseException(input: unknown): FolderAutopilotException { + const value = object(input); + only(value, [ + 'exceptionId', + 'assignmentId', + 'executionId', + 'severity', + 'reasonCode', + 'status', + 'createdAt', + ]); + const executionId = value['executionId']; + return Object.freeze({ + exceptionId: id(value['exceptionId']), + assignmentId: id(value['assignmentId']), + ...(executionId === undefined ? {} : { executionId: id(executionId) }), + severity: oneOf(value['severity'], ['INFO', 'WARNING', 'ERROR']), + reasonCode: token(value['reasonCode']), + status: oneOf(value['status'], ['OPEN', 'RESOLVED', 'IGNORED']), + createdAt: timestamp(value['createdAt']), + }); +} + +function parseHealth(input: unknown): FolderAutopilotHealth { + const value = object(input); + only(value, [ + 'assignmentId', + 'watcherState', + 'lastHeartbeatAt', + 'queueAgeSeconds', + 'queuedCount', + 'syncLagSeconds', + ]); + return Object.freeze({ + assignmentId: id(value['assignmentId']), + watcherState: oneOf(value['watcherState'], ['HEALTHY', 'PAUSED', 'OVERFLOWED', 'OFFLINE']), + lastHeartbeatAt: timestamp(value['lastHeartbeatAt']), + queueAgeSeconds: boundedSeconds(value['queueAgeSeconds'], 31_536_000), + queuedCount: count(value['queuedCount']), + syncLagSeconds: boundedSeconds(value['syncLagSeconds'], 31_536_000), + }); +} + +function parseDashboard(input: unknown): FolderAutopilotDashboard { + const value = object(input); + only(value, [ + 'schemaVersion', + 'profiles', + 'assignments', + 'previews', + 'approvals', + 'executions', + 'exceptions', + 'health', + ]); + if (value['schemaVersion'] !== 1) throw new Error(UUID_ERROR); + return Object.freeze({ + schemaVersion: 1, + profiles: Object.freeze(list(value['profiles']).map(parseProfile)), + assignments: Object.freeze(list(value['assignments']).map(parseAssignment)), + previews: Object.freeze(list(value['previews']).map(parsePreview)), + approvals: Object.freeze(list(value['approvals']).map(parseApproval)), + executions: Object.freeze(list(value['executions']).map(parseExecution)), + exceptions: Object.freeze(list(value['exceptions']).map(parseException)), + health: Object.freeze(list(value['health']).map(parseHealth)), + }); +} + +async function responsePayload(response: Response): Promise { + if (!response.ok) throw new Error('AUTOPILOT_REQUEST_FAILED'); + const payload: unknown = await response.json(); + const value = object(payload); + if (value['accepted'] === true && value['value'] !== undefined) return value['value']; + return payload; +} + +function idempotencyKey(prefix: string): string { + const random = globalThis.crypto?.randomUUID?.(); + if (random === undefined) throw new Error('AUTOPILOT_CRYPTO_UNAVAILABLE'); + return `${prefix}-${random}`; +} + +async function sha256Hex(value: string): Promise { + const subtle = globalThis.crypto?.subtle; + if (subtle === undefined) throw new Error('AUTOPILOT_CRYPTO_UNAVAILABLE'); + const digest = await subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +async function mutate( + path: string, + body: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${apiBaseUrl()}${path}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey('autopilot'), + }, + credentials: 'include', + body: JSON.stringify(body), + ...(signal === undefined ? {} : { signal }), + }); + return responsePayload(response); +} + +export async function getFolderAutopilotDashboard( + signal?: AbortSignal, +): Promise { + const response = await fetch(`${apiBaseUrl()}/v1/autopilot-dashboard`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + ...(signal === undefined ? {} : { signal }), + }); + return parseDashboard(await responsePayload(response)); +} + +export async function createFolderAutopilotProfile( + input: FolderAutopilotProfileInput, + signal?: AbortSignal, +): Promise { + const profileId = globalThis.crypto?.randomUUID?.(); + if (profileId === undefined) throw new Error('AUTOPILOT_CRYPTO_UNAVAILABLE'); + const stabilizationSeconds = boundedSeconds(input.stabilizationSeconds, 86_400); + const undoWindowHours = boundedSeconds(input.undoWindowHours, 168); + const payload = { + profileId, + version: 1, + stabilizationDelayMs: stabilizationSeconds * 1_000, + maxFilesPerScan: 10_000, + collisionPolicy: oneOf(input.collisionPolicy, ['REVIEW', 'SKIP', 'UNIQUE_NAME']), + undoWindowSeconds: undoWindowHours * 3_600, + outputLineageEnabled: true, + createdAt: new Date().toISOString(), + } as const; + const payloadHash = await sha256Hex(JSON.stringify(payload)); + return mutate('/v1/autopilot-profiles', { ...payload, payloadHash }, signal); +} + +export async function pauseFolderAutopilotAssignment( + assignmentId: string, + expectedRevision: number, + signal?: AbortSignal, +): Promise { + return mutate( + `/v1/autopilot-assignments/${encodeURIComponent(assignmentId)}/pause`, + { expectedRevision }, + signal, + ); +} + +export async function decideFolderAutopilotApproval( + approvalId: string, + subjectHash: string, + decision: Exclude, + planHash: string, + signal?: AbortSignal, +): Promise { + return mutate( + `/v1/autopilot-approvals/${encodeURIComponent(approvalId)}/decision`, + { + jraApprovalRequestId: approvalId, + subjectHash: hash(subjectHash), + planHash: hash(planHash), + decision: decision === 'APPROVED' ? 'APPROVE' : 'REJECT', + decisionReason: `Web ${decision.toLowerCase()} decision`, + }, + signal, + ); +} + +export async function requestFolderAutopilotUndo( + executionId: string, + planHash: string, + expectedRevision: number, + signal?: AbortSignal, +): Promise { + return mutate( + `/v1/autopilot-executions/${encodeURIComponent(executionId)}/undo`, + { expectedRevision, planHash: hash(planHash) }, + signal, + ); +} diff --git a/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx b/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx new file mode 100644 index 00000000..cba5b2f9 --- /dev/null +++ b/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx @@ -0,0 +1,586 @@ +import { Button, Status } from '@databreeze/ui/v1'; +import { useQuery } from '@tanstack/react-query'; +import { useState, type FormEvent } from 'react'; +import { appMessage } from '../../app/messages.ts'; +import { useLocale } from '../../app/locale-context.tsx'; +import { + createFolderAutopilotProfile, + decideFolderAutopilotApproval, + getFolderAutopilotDashboard, + pauseFolderAutopilotAssignment, + requestFolderAutopilotUndo, + type FolderAutopilotApproval, + type FolderAutopilotAssignment, + type FolderAutopilotDashboard, + type FolderAutopilotExecution, + type FolderAutopilotProfileInput, + type FolderAutopilotPreview, + type FolderAutopilotProfile, +} from './folder-autopilot-api.ts'; + +function dateLabel(locale: ReturnType, value: string): string { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format( + new Date(value), + ); +} + +function statusKind(value: string): 'danger' | 'info' | 'success' | 'warning' { + if (value === 'ACTIVE' || value === 'HEALTHY' || value === 'HANDLED' || value === 'APPROVED') + return 'success'; + if (value === 'INVALID' || value === 'EXCEPTION' || value === 'ERROR' || value === 'REJECTED') + return 'danger'; + if (value === 'RUNNING' || value === 'QUEUED') return 'info'; + return 'warning'; +} + +function reasonLabel(locale: ReturnType, value: string): string { + switch (value) { + case 'DESTINATION_COLLISION': + return appMessage(locale, 'autopilot.reason.collision'); + case 'DESTINATION_COLLISION_SKIPPED': + return appMessage(locale, 'autopilot.reason.collisionSkipped'); + case 'MOVE_REQUIRES_APPROVAL': + return appMessage(locale, 'autopilot.reason.moveApproval'); + default: + return value; + } +} + +function assignmentHealth( + dashboard: FolderAutopilotDashboard, + assignmentId: string, +): string | undefined { + return dashboard.health.find((item) => item.assignmentId === assignmentId)?.watcherState; +} + +function ProfileAuthoring({ + profiles, + onSaved, +}: { + readonly profiles: readonly FolderAutopilotProfile[]; + readonly onSaved: () => void; +}) { + const locale = useLocale(); + const [input, setInput] = useState({ + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + undoWindowHours: 24, + }); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setSaved(false); + setError(false); + try { + await createFolderAutopilotProfile(input); + setSaved(true); + onSaved(); + } catch { + setError(true); + } finally { + setSaving(false); + } + } + + return ( +
+
+

{appMessage(locale, 'autopilot.profile.heading')}

+ {appMessage(locale, 'autopilot.profile.facade')} +
+ {profiles.length === 0 ? ( +

{appMessage(locale, 'autopilot.reason.none')}

+ ) : ( +
+ {profiles.map((profile) => ( +
+
+
+

+ {appMessage(locale, 'autopilot.profile.version')} {profile.version} +

+ {profile.profileId} +
+ {profile.collisionPolicy} +
+
+
+
{appMessage(locale, 'autopilot.profile.collision')}
+
{profile.collisionPolicy}
+
+
+
{appMessage(locale, 'autopilot.profile.confidence')}
+
{profile.confidenceThreshold}
+
+
+
{appMessage(locale, 'autopilot.profile.approval')}
+
+ {profile.approvalRequired + ? appMessage(locale, 'autopilot.profile.required') + : appMessage(locale, 'autopilot.profile.optional')} +
+
+
+
+ ))} +
+ )} +
void submit(event)}> + + + + +
+ {saved ? ( +

+ {appMessage(locale, 'autopilot.profile.saved')} +

+ ) : null} + {error ? {appMessage(locale, 'autopilot.error')} : null} +
+ ); +} + +function AssignmentList({ + dashboard, + paused, + onPause, +}: { + readonly dashboard: FolderAutopilotDashboard; + readonly paused: Readonly>; + readonly onPause: (assignment: FolderAutopilotAssignment) => Promise; +}) { + const locale = useLocale(); + return ( +
+
+

+ {appMessage(locale, 'autopilot.assignment.heading')} +

+
+
+ + + + + + + + + + + + {dashboard.assignments.map((assignment) => { + const isPaused = paused[assignment.assignmentId] || assignment.state === 'PAUSED'; + const health = assignmentHealth(dashboard, assignment.assignmentId); + return ( + + + + + + + + ); + })} + +
{appMessage(locale, 'autopilot.assignment.name')}{appMessage(locale, 'autopilot.assignment.state')}{appMessage(locale, 'autopilot.assignment.revision')}{appMessage(locale, 'autopilot.assignment.health')} + {appMessage(locale, 'autopilot.actions')} +
+ {assignment.assignmentId} + + {assignment.assignmentId} + + + + {isPaused + ? appMessage(locale, 'autopilot.assignment.paused') + : appMessage(locale, 'autopilot.assignment.active')} + + {assignment.revision} + {health ?? 'OFFLINE'} + + +
+
+
+ ); +} + +function ApprovalQueue({ + previews, + approvals, + decisions, + onDecision, +}: { + readonly previews: readonly FolderAutopilotPreview[]; + readonly approvals: readonly FolderAutopilotApproval[]; + readonly decisions: Readonly>; + readonly onDecision: ( + approval: FolderAutopilotApproval, + decision: 'APPROVED' | 'REJECTED', + planHash: string, + ) => Promise; +}) { + const locale = useLocale(); + const pairs = approvals.flatMap((approval) => { + const preview = previews.find((candidate) => candidate.previewId === approval.previewId); + return preview === undefined ? [] : [{ approval, preview }]; + }); + return ( +
+
+

{appMessage(locale, 'autopilot.approval.heading')}

+
+ {pairs.length === 0 ?

{appMessage(locale, 'autopilot.reason.none')}

: null} +
+ {pairs.map(({ approval, preview }) => { + const decision = decisions[approval.approvalId] ?? approval.decision; + return ( +
+
+
+

+ {appMessage(locale, 'autopilot.approval.preview')}{' '} + {preview.previewId} +

+

+ {approval.approvalId} +

+
+ + {decision === 'PENDING' + ? appMessage(locale, 'autopilot.approval.pending') + : decision === 'APPROVED' + ? appMessage(locale, 'autopilot.approval.approved') + : appMessage(locale, 'autopilot.approval.rejected')} + +
+
+
+
{appMessage(locale, 'autopilot.approval.plan')}
+
+ {preview.planHash} +
+
+
+
{appMessage(locale, 'autopilot.approval.affected')}
+
{preview.affectedCount}
+
+
+
{appMessage(locale, 'autopilot.approval.blocked')}
+
{preview.blockedCount}
+
+
+
    + {preview.reasonCodes.map((reason) => ( +
  • {reasonLabel(locale, reason)}
  • + ))} +
+
+ + +
+
+ ); + })} +
+
+ ); +} + +function Exceptions({ dashboard }: { readonly dashboard: FolderAutopilotDashboard }) { + const locale = useLocale(); + return ( +
+
+

+ {appMessage(locale, 'autopilot.exceptions.heading')} +

+
+ {dashboard.exceptions.length === 0 ? ( +

{appMessage(locale, 'autopilot.reason.none')}

+ ) : ( +
+ + + + + + + + + + {dashboard.exceptions.map((item) => ( + + + + + + ))} + +
{appMessage(locale, 'autopilot.exceptions.reason')}{appMessage(locale, 'autopilot.exceptions.severity')}{appMessage(locale, 'autopilot.exceptions.status')}
+ {item.reasonCode} + + {item.severity} + + {item.status === 'OPEN' + ? appMessage(locale, 'autopilot.exceptions.open') + : item.status} +
+
+ )} +
+ ); +} + +function RecentOutcomes({ + executions, + requestedUndo, + onUndo, +}: { + readonly executions: readonly FolderAutopilotExecution[]; + readonly requestedUndo: Readonly>; + readonly onUndo: (execution: FolderAutopilotExecution) => Promise; +}) { + const locale = useLocale(); + return ( +
+
+

{appMessage(locale, 'autopilot.outcomes.heading')}

+
+
+ + + + + + + + + + + {executions.map((execution) => { + const undoAvailable = + execution.undoState === 'AVAILABLE' && !requestedUndo[execution.executionId]; + return ( + + + + + + + ); + })} + +
{appMessage(locale, 'autopilot.outcomes.outcome')}{appMessage(locale, 'autopilot.outcomes.affected')}{appMessage(locale, 'autopilot.outcomes.undo')} + {appMessage(locale, 'autopilot.actions')} +
+ + {execution.outcome === 'HANDLED' + ? appMessage(locale, 'autopilot.outcomes.handled') + : execution.outcome === 'EXCEPTION' + ? appMessage(locale, 'autopilot.outcomes.exception') + : execution.outcome} + + + {execution.executionId} + + {execution.affectedCount} + {requestedUndo[execution.executionId] + ? appMessage(locale, 'autopilot.outcomes.undoRequested') + : execution.undoState === 'AVAILABLE' + ? appMessage(locale, 'autopilot.outcomes.undoAvailable') + : execution.undoState} + + +
+
+
+ ); +} + +export function FolderAutopilotPage() { + const locale = useLocale(); + const query = useQuery({ + queryKey: ['folder-autopilot', 'dashboard'], + queryFn: ({ signal }) => getFolderAutopilotDashboard(signal), + retry: false, + }); + const [paused, setPaused] = useState>>({}); + const [decisions, setDecisions] = useState>>({}); + const [requestedUndo, setRequestedUndo] = useState>>({}); + const [mutationError, setMutationError] = useState(false); + + if (query.isPending) + return ( +
+

{appMessage(locale, 'autopilot.heading')}

+ {appMessage(locale, 'autopilot.loading')} +
+ ); + if (query.isError) + return ( +
+

{appMessage(locale, 'autopilot.heading')}

+ {appMessage(locale, 'autopilot.error')} + +
+ ); + + const dashboard = query.data; + async function pause(assignment: FolderAutopilotAssignment) { + setMutationError(false); + try { + await pauseFolderAutopilotAssignment(assignment.assignmentId, assignment.revision); + setPaused((current) => ({ ...current, [assignment.assignmentId]: true })); + } catch { + setMutationError(true); + } + } + async function decide( + approval: FolderAutopilotApproval, + decision: 'APPROVED' | 'REJECTED', + planHash: string, + ) { + setMutationError(false); + try { + await decideFolderAutopilotApproval( + approval.approvalId, + approval.subjectHash, + decision, + planHash, + ); + setDecisions((current) => ({ ...current, [approval.approvalId]: decision })); + } catch { + setMutationError(true); + } + } + async function undo(execution: FolderAutopilotExecution) { + setMutationError(false); + try { + await requestFolderAutopilotUndo( + execution.executionId, + execution.planHash, + execution.revision, + ); + setRequestedUndo((current) => ({ ...current, [execution.executionId]: true })); + } catch { + setMutationError(true); + } + } + + return ( +
+
+
+

{appMessage(locale, 'autopilot.heading')}

+

{appMessage(locale, 'autopilot.caption')}

+
+ {appMessage(locale, 'autopilot.dataMode.hybrid')} +
+ {mutationError ? ( + {appMessage(locale, 'autopilot.error')} + ) : null} +
+ void query.refetch()} profiles={dashboard.profiles} /> + + + + +
+

{appMessage(locale, 'access.clientHint')}

+ {dashboard.profiles[0] ? ( +

{dateLabel(locale, dashboard.profiles[0].updatedAt)}

+ ) : null} +
+ ); +} diff --git a/apps/web/src/pages/shell-states.tsx b/apps/web/src/pages/shell-states.tsx index 67ec90a3..3fbfc58b 100644 --- a/apps/web/src/pages/shell-states.tsx +++ b/apps/web/src/pages/shell-states.tsx @@ -12,6 +12,7 @@ function featureLabel(locale: ReturnType, key: NavigationKey): return formatMessageV1(locale, registration.messageKey); } if (key === 'usage') return appMessage(locale, 'nav.usage'); + if (key === 'autopilot') return appMessage(locale, 'autopilot.heading'); return appMessage(locale, 'nav.administration'); } diff --git a/apps/web/test/folder-autopilot-api.test.ts b/apps/web/test/folder-autopilot-api.test.ts new file mode 100644 index 00000000..1f9f979f --- /dev/null +++ b/apps/web/test/folder-autopilot-api.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createFolderAutopilotProfile, + decideFolderAutopilotApproval, + getFolderAutopilotDashboard, + pauseFolderAutopilotAssignment, + requestFolderAutopilotUndo, +} from '../src/features/folder-autopilot/folder-autopilot-api.ts'; + +const ids = { + profile: '00000000-0000-4000-8000-000000000001', + assignment: '00000000-0000-4000-8000-000000000002', + recipe: '00000000-0000-4000-8000-000000000003', + device: '00000000-0000-4000-8000-000000000004', + inputBinding: '00000000-0000-4000-8000-000000000005', + outputBinding: '00000000-0000-4000-8000-000000000006', + preview: '00000000-0000-4000-8000-000000000007', + artifact: '00000000-0000-4000-8000-000000000008', + approval: '00000000-0000-4000-8000-000000000009', + execution: '00000000-0000-4000-8000-00000000000a', + job: '00000000-0000-4000-8000-00000000000b', + manifest: '00000000-0000-4000-8000-00000000000c', + exception: '00000000-0000-4000-8000-00000000000d', +}; + +const dashboard = { + schemaVersion: 1, + profiles: [ + { + profileId: ids.profile, + version: 1, + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + confidenceThreshold: 0.9, + undoWindowHours: 24, + approvalRequired: true, + recipeHash: 'a'.repeat(64), + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + assignments: [ + { + assignmentId: ids.assignment, + profileId: ids.profile, + jraRecipeVersionId: ids.recipe, + deviceId: ids.device, + inputBindingId: ids.inputBinding, + outputBindingId: ids.outputBinding, + state: 'ACTIVE', + approvalRequired: true, + revision: 3, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + previews: [ + { + previewId: ids.preview, + assignmentId: ids.assignment, + jraRecipeVersionId: ids.recipe, + planHash: 'b'.repeat(64), + status: 'NEEDS_APPROVAL', + affectedCount: 2, + blockedCount: 1, + actions: [ + { + stepId: 'step-1', + actionType: 'MOVE', + sourceArtifactVersionId: ids.artifact, + destinationBindingId: ids.outputBinding, + collision: 'REVIEW', + requiresApproval: true, + }, + ], + reasonCodes: ['DESTINATION_COLLISION'], + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + approvals: [ + { + approvalId: ids.approval, + previewId: ids.preview, + subjectHash: 'c'.repeat(64), + planHash: 'b'.repeat(64), + decision: 'PENDING', + expiresAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + executions: [ + { + executionId: ids.execution, + assignmentId: ids.assignment, + jraJobId: ids.job, + resultManifestId: ids.manifest, + planHash: 'b'.repeat(64), + revision: 1, + outcome: 'UNDO_AVAILABLE', + affectedCount: 2, + handledCount: 2, + exceptionCount: 0, + reasonCodes: [], + undoState: 'AVAILABLE', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + exceptions: [ + { + exceptionId: ids.exception, + assignmentId: ids.assignment, + executionId: ids.execution, + severity: 'WARNING', + reasonCode: 'DESTINATION_COLLISION', + status: 'OPEN', + createdAt: '2026-08-04T00:00:00.000Z', + }, + ], + health: [ + { + assignmentId: ids.assignment, + watcherState: 'HEALTHY', + lastHeartbeatAt: '2026-08-04T00:00:00.000Z', + queueAgeSeconds: 2, + queuedCount: 1, + syncLagSeconds: 0, + }, + ], +}; + +describe('Folder Autopilot API boundary', () => { + it('parses content-free dashboard projections and rejects source fields', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(dashboard)))); + + const parsed = await getFolderAutopilotDashboard(); + expect(parsed.assignments[0]?.assignmentId).toBe(ids.assignment); + expect(parsed.previews[0]?.actions[0]?.sourceArtifactVersionId).toBe(ids.artifact); + expect(JSON.stringify(parsed)).not.toMatch(/sourcePath|rawBytes|localHandle/iu); + + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ...dashboard, sourcePath: 'C:\\private\\invoices' })), + ), + ); + await expect(getFolderAutopilotDashboard()).rejects.toThrow('AUTOPILOT_RESPONSE_INVALID'); + }); + + it('sends only bounded identifiers and policy values for mutations', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ accepted: true, value: dashboard.assignments[0] }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await createFolderAutopilotProfile({ + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + undoWindowHours: 24, + }); + await pauseFolderAutopilotAssignment(ids.assignment, 3); + await decideFolderAutopilotApproval(ids.approval, 'c'.repeat(64), 'APPROVED', 'b'.repeat(64)); + await requestFolderAutopilotUndo(ids.execution, 'b'.repeat(64), 1); + + const requests = fetchMock.mock.calls.map(([, request]) => { + const init = request as RequestInit; + const body = typeof init.body === 'string' ? init.body : ''; + expect(body).not.toMatch(/path|bytes|formula|sourceValue|localHandle/iu); + return JSON.parse(body) as Record; + }); + expect(requests[0]).toMatchObject({ + version: 1, + stabilizationDelayMs: 10_000, + undoWindowSeconds: 86_400, + collisionPolicy: 'REVIEW', + maxFilesPerScan: 10_000, + outputLineageEnabled: true, + }); + expect(requests[0]?.['payloadHash']).toMatch(/^[0-9a-f]{64}$/u); + expect(requests[2]).toMatchObject({ + jraApprovalRequestId: ids.approval, + subjectHash: 'c'.repeat(64), + planHash: 'b'.repeat(64), + decision: 'APPROVE', + }); + expect(requests[3]).toEqual({ expectedRevision: 1, planHash: 'b'.repeat(64) }); + }); +}); diff --git a/apps/web/test/folder-autopilot-page.test.tsx b/apps/web/test/folder-autopilot-page.test.tsx new file mode 100644 index 00000000..46dc1dc4 --- /dev/null +++ b/apps/web/test/folder-autopilot-page.test.tsx @@ -0,0 +1,182 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx'; + +const dashboard = { + schemaVersion: 1, + profiles: [ + { + profileId: '00000000-0000-4000-8000-000000000001', + version: 1, + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + confidenceThreshold: 0.9, + undoWindowHours: 24, + approvalRequired: true, + recipeHash: 'a'.repeat(64), + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + assignments: [ + { + assignmentId: '00000000-0000-4000-8000-000000000002', + profileId: '00000000-0000-4000-8000-000000000001', + jraRecipeVersionId: '00000000-0000-4000-8000-000000000003', + deviceId: '00000000-0000-4000-8000-000000000004', + inputBindingId: '00000000-0000-4000-8000-000000000005', + outputBindingId: '00000000-0000-4000-8000-000000000006', + state: 'ACTIVE', + approvalRequired: true, + revision: 3, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + previews: [ + { + previewId: '00000000-0000-4000-8000-000000000007', + assignmentId: '00000000-0000-4000-8000-000000000002', + jraRecipeVersionId: '00000000-0000-4000-8000-000000000003', + planHash: 'b'.repeat(64), + status: 'NEEDS_APPROVAL', + affectedCount: 2, + blockedCount: 1, + actions: [ + { + stepId: 'step-1', + actionType: 'MOVE', + sourceArtifactVersionId: '00000000-0000-4000-8000-000000000008', + destinationBindingId: '00000000-0000-4000-8000-000000000006', + collision: 'REVIEW', + requiresApproval: true, + }, + ], + reasonCodes: ['DESTINATION_COLLISION'], + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + approvals: [ + { + approvalId: '00000000-0000-4000-8000-000000000009', + previewId: '00000000-0000-4000-8000-000000000007', + subjectHash: 'c'.repeat(64), + planHash: 'b'.repeat(64), + decision: 'PENDING', + expiresAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + executions: [ + { + executionId: '00000000-0000-4000-8000-00000000000a', + assignmentId: '00000000-0000-4000-8000-000000000002', + jraJobId: '00000000-0000-4000-8000-00000000000b', + resultManifestId: '00000000-0000-4000-8000-00000000000c', + planHash: 'b'.repeat(64), + revision: 1, + outcome: 'UNDO_AVAILABLE', + affectedCount: 2, + handledCount: 2, + exceptionCount: 0, + reasonCodes: [], + undoState: 'AVAILABLE', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + exceptions: [ + { + exceptionId: '00000000-0000-4000-8000-00000000000d', + assignmentId: '00000000-0000-4000-8000-000000000002', + executionId: '00000000-0000-4000-8000-00000000000a', + severity: 'WARNING', + reasonCode: 'DESTINATION_COLLISION', + status: 'OPEN', + createdAt: '2026-08-04T00:00:00.000Z', + }, + ], + health: [ + { + assignmentId: '00000000-0000-4000-8000-000000000002', + watcherState: 'HEALTHY', + lastHeartbeatAt: '2026-08-04T00:00:00.000Z', + queueAgeSeconds: 2, + queuedCount: 1, + syncLagSeconds: 0, + }, + ], +}; + +describe('Folder Autopilot workspace surface', () => { + it('renders authoring, preview, approval, exception, and undo projections without paths', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response(JSON.stringify(dashboard), { status: 200 })), + ); + vi.stubGlobal('fetch', fetchMock); + const router = createAppRouter({ initialEntries: ['/en/autopilot'] }); + render(); + + expect(await screen.findByRole('heading', { name: 'Folder Autopilot' })).toBeTruthy(); + const asyncQueryOptions = { timeout: 5_000 }; + expect( + await screen.findByRole('heading', { name: 'Profiles' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Assignments' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Approval queue' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Exceptions' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Recent outcomes' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + (await screen.findAllByText('00000000-0000-4000-8000-000000000002', {}, asyncQueryOptions)) + .length, + ).toBeGreaterThan(0); + expect( + await screen.findByRole('heading', { name: 'Version 1' }, asyncQueryOptions), + ).toBeTruthy(); + expect(screen.queryByText(/sourceArtifactVersionId|sourcePath|localHandle/iu)).toBeNull(); + }); + + it('pauses an assignment and approves the exact preview plan through safe mutations', async () => { + const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('/v1/autopilot-dashboard')) + return Promise.resolve(new Response(JSON.stringify(dashboard), { status: 200 })); + return Promise.resolve( + new Response(JSON.stringify({ accepted: true, value: {} }), { status: 200 }), + ); + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + const router = createAppRouter({ initialEntries: ['/en/autopilot'] }); + render(); + + const asyncQueryOptions = { timeout: 5_000 }; + await user.click( + await screen.findByRole('button', { name: 'Pause assignment' }, asyncQueryOptions), + ); + expect(await screen.findByText('Paused', { selector: 'span' }, asyncQueryOptions)).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Approve preview' })); + expect( + await screen.findByText('Approved', { selector: 'span' }, asyncQueryOptions), + ).toBeTruthy(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + const mutationBodies = fetchMock.mock.calls + .slice(1) + .map(([, init]) => { + const body = (init as RequestInit).body; + return typeof body === 'string' ? body : ''; + }) + .join('\n'); + expect(mutationBodies).not.toMatch(/path|bytes|formula|sourceValue|localHandle/iu); + }, 20_000); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 63da4ebf..354ac202 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { environment: 'jsdom', include: ['test/**/*.test.{ts,tsx}'], + maxWorkers: 2, restoreMocks: true, setupFiles: ['./test/setup.ts'], }, diff --git a/docs/operations/coderabbit-pr-51-disposition.md b/docs/operations/coderabbit-pr-51-disposition.md new file mode 100644 index 00000000..a36981ba --- /dev/null +++ b/docs/operations/coderabbit-pr-51-disposition.md @@ -0,0 +1,50 @@ +# CodeRabbit disposition for promotion PR 51 + +Review invocation: one automatic full review was requested on PR #51 +(`4853416975`). After the final push, repository automation emitted a follow-up +status review for head `365c080` (run `57535106-acc4-4eed-a227-99fd2c53e2fe`); +no second review was manually invoked. Its four additional findings were +reproduced and addressed below. + +## Resolution + +All 35 findings from the requested review plus the four automated follow-up +findings were reproduced against the reviewed Folder Autopilot slice and +classified as valid. They are addressed in focused commits on +`feat/folder-autopilot-20260804`: + +- Desktop observation/action/journal limits, recovery state, copy undo policy, + runtime validation, partial receipts, JSON-safe timestamps, and path/collision + handling were hardened. +- Engine stable execution keys, generated-name bounds, and case-folded + destination collisions are validated before a plan is ready. +- API persistence reads now serialize with in-memory transactions, production + composition rejects an implicit in-memory adapter, assignment idempotency is + scope-safe, and state updates use compare-and-set revisions. +- API list failures preserve rejection codes and HTTP status, OpenAPI rejection + responses are documented, and profile decision reasons are bounded. +- Dashboard projections expose raw identifiers/version/mode/timestamps rather + than English display strings; assignment `updatedAt` is persisted and changed + on state transitions. +- Web mutations surface failures, refresh profile projections, enforce positive + revisions and bounded undo windows, require runtime UUID randomness, and keep + all displayed copy localized. +- Android approvals validate ISO expiry before queueing or deciding, disable + expired actions, and localize assignment, watcher, approval, outcome, undo, + and severity states in Vietnamese and English. +- Follow-up hardening normalizes invalid engine timestamps, preserves Desktop + receipts when a later operation fails, maps unavailable approval/undo facades + to HTTP 503, and proves owner-versus-sibling assignment reads in the service + test. + +No comments were rejected as invalid. The module remains a content-free, +testable slice; this disposition does not promote unimplemented FA P0/P1 +requirements to `verified`. + +## Evidence + +- TypeScript domain: 156 tests passed. +- API: 497 tests passed; Prisma validation/generation and OpenAPI checks passed. +- Web: 33 tests and typecheck passed. +- Engine: 143 tests, Ruff, and mypy passed. +- Android: `testDebugUnitTest` and `assembleDebug` passed with the local SDK. diff --git a/docs/release-evidence/fa-web-android-slice-2026-08-04.md b/docs/release-evidence/fa-web-android-slice-2026-08-04.md new file mode 100644 index 00000000..17b22c47 --- /dev/null +++ b/docs/release-evidence/fa-web-android-slice-2026-08-04.md @@ -0,0 +1,48 @@ +# Folder Autopilot Web and Android slice — 2026-08-04 + +This record covers the content-free Web and Android review boundary for the Folder Autopilot +feature. It is a client slice, not a claim that all FA P0/P1 requirements are complete. + +## Delivered + +- Web exposes a lazy-loaded `autopilot` route and navigation registration with Vietnamese and + English copy. +- Web dashboard parsing rejects unknown fields and source-bearing values; mutation requests carry + only opaque identifiers, revisions, policy values, decision, and immutable plan hashes. +- Web presents profile authoring, assignment pause, preview approval/rejection, exception, outcome, + and undo projections without rendering local paths, source bytes, formulas, or local handles. +- Android presents a compact assignment, approval, outcome, exception, and undo companion surface. +- Android state transitions fail closed on stale assignments, non-pending approvals, plan-hash + mismatch, and repeated undo requests. +- Android offline intent queue stores only bounded operation names, opaque IDs, revisions, and + SHA-256 payload hashes in the existing Room/InMemory queue; WorkManager scheduling remains + replaceable through `SyncScheduler`. + +## Commits + +- `459c095` — Web safe API boundary tests +- `436507f` — Web content-free API client +- `a0cad73` — Web authoring/review surface tests +- `95af11b` — Web workspace surfaces and lazy route +- `0529f70` — Android state-model tests +- `ed5a44f` — Android state model +- `c3fe924` — Android review companion and instrumentation coverage +- `23cf2c5` — Android offline queue tests +- `162f502` — Android offline action queue +- `1047fa8` — Android UI persists actions before local transitions +- `7aa7a3c` — bounded deterministic offline mutation identifiers +- `e46f8a8` — Web profile list and detail projection +- `175b0a4` — strict boundary lint hardening +- `465ceae` — Vietnamese/English profile status parity + +## Checks + +- `corepack pnpm --filter @databreeze/web typecheck` +- `corepack pnpm --filter @databreeze/web exec vitest run test/folder-autopilot-api.test.ts test/folder-autopilot-page.test.tsx` +- `apps/android/gradlew.bat :app:compileDebugKotlin --no-daemon --offline --console=plain` +- `apps/android/gradlew.bat :app:testDebugUnitTest --tests com.databreeze.android.folderautopilot.FolderAutopilotOfflineQueueTest --tests com.databreeze.android.folderautopilot.FolderAutopilotModelsTest --no-daemon --offline --console=plain` +- `apps/android/gradlew.bat :app:compileDebugAndroidTestKotlin --no-daemon --offline --console=plain` + +Instrumentation execution requires an attached Android emulator/device; compilation passed in this +worktree. The complete Folder Autopilot module remains gated by its backend, Desktop watcher, +engine, evidence, approval, recovery, and traceability plans. diff --git a/docs/release-evidence/folder-autopilot-slice-2026-08-04.md b/docs/release-evidence/folder-autopilot-slice-2026-08-04.md new file mode 100644 index 00000000..77cabe5c --- /dev/null +++ b/docs/release-evidence/folder-autopilot-slice-2026-08-04.md @@ -0,0 +1,76 @@ +# Folder Autopilot module release evidence + +Status: implementation in progress. This record is updated only when the +corresponding code, contract, and test evidence exists in the same branch. + +This release record intentionally does not mark the complete FA P0/P1 module +as released. The implemented boundary is a testable, content-free slice that +can be promoted independently while the remaining preview, execution, +approval, recovery, reconciliation, and export projections stay fail-closed. + +## Scope + +This direct-to-`main` feature slice covers the first independently testable +Folder Autopilot boundary: content-free folder bindings, typed profile and +assignment validation, safe local observation and action planning, review-safe +Web/Android surfaces, and deterministic failure behavior. It does not create a +second JRA recipe/job/approval authority or copy DSO grants, paths, or +revocation state. + +## Acceptance evidence + +- [ ] FA-001–FA-007: binding/profile/assignment contracts contain only opaque + DSO/JRA references and are immutable, tenant scoped, revision guarded, and + idempotent. +- [ ] FA-008–FA-009: bounded previews expose collision, permission, resource, + recursion, and approval outcomes without source paths or values. +- [ ] FA-010–FA-017: Desktop stabilization, fingerprinting, path containment, + typed allowlisted actions, collision policy, derivative-only conversion, and + recovery-folder semantics are covered by tests. +- [ ] FA-018–FA-027: plan-bound approval, pre-commit revalidation, staged + compensation, idempotent execution projections, pause, and DSO revocation + fail closed. +- [ ] FA-028–FA-034: module intake, reconciliation, authorized retry/undo, + constraint narrowing, output-lineage prevention, health projections, and + redacted ledger export are covered or explicitly tracked for the next slice. +- [ ] Cross-runtime contract generation and drift checks pass for TypeScript, + Kotlin, and Python. +- [ ] Root repository checks, builds, accessibility checks, tenant isolation, + path-escape tests, restart/replay tests, and `git diff --check` pass. + +## Privacy and rollback notes + +Folder Autopilot never persists a canonical path, local handle, source bytes, +independent DSO grant/status/revocation fields, or an independent JRA recipe, +job, or approval decision. A failed or rolled-back step leaves the original +artifact and immutable audit history intact. Reverting this feature branch +removes the module-owned projections and adapters without deleting IAE, DSO, +JRA, or Desktop-local records. + +## Implemented boundary evidence + +- API owns immutable, tenant-scoped profile, binding, and assignment records; + Prisma and in-memory adapters share the same repository port and transaction + semantics. +- API exposes a content-free dashboard projection and fail-closed pause, + approval, and undo facades. The generated OpenAPI document includes all + Folder Autopilot routes and passes Redocly validation. +- Engine evaluates bounded typed plans deterministically, including review, + skip, and unique-name collision outcomes, without filesystem side effects. +- Desktop stabilizes and fingerprints files, enforces authorization-path + containment, executes only exclusive allowlisted actions, and journals + reversible work with fingerprint-checked undo. +- Web and Android consume only opaque identifiers, hashes, revisions, policy + values, and redacted projections; no client boundary accepts paths, bytes, + formulas, source values, or local handles. + +The current branch evidence is covered by API controller/service/dashboard and +composition tests, Desktop Autopilot tests, 140 engine tests, 48 contract +tests, targeted Web tests, and Android compile/unit-test gates. These are +slice checks, not a claim that every FA requirement is verified. + +## Traceability + +The authoritative requirement records are `docs/plans/requirement-traceability.json`. +Statuses remain `planned` until each requirement has a concrete code path, test +path, and this evidence record is approved by the release gate. diff --git a/packages/contracts/compatibility/published.json b/packages/contracts/compatibility/published.json index fbdd0098..f3749fb3 100644 --- a/packages/contracts/compatibility/published.json +++ b/packages/contracts/compatibility/published.json @@ -4,7 +4,7 @@ { "contractVersion": 1, "baseline": "compatibility/v1/baseline.json", - "sha256": "cb1e4833e517b96eaa2ca6c0583838619bfa494e3b634b7d855b60eb2fb242ff" + "sha256": "313594a67ffb8f6fb41776874108dbcdadf951ff02f9ebfd3d597d4577fd626e" } ] } diff --git a/packages/contracts/compatibility/v1/baseline.json b/packages/contracts/compatibility/v1/baseline.json index 6d257209..c978b1f8 100644 --- a/packages/contracts/compatibility/v1/baseline.json +++ b/packages/contracts/compatibility/v1/baseline.json @@ -9,6 +9,12 @@ "path": "schemas/v1/actor-metadata.schema.json", "sha256": "fb9d12675478ae805bbe0163866c1cf8e4bb810dbe32ac780b1c1bf4975c856c" }, + { + "name": "autopilot-folder-binding", + "id": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "path": "schemas/v1/autopilot-folder-binding.schema.json", + "sha256": "4a65514c7ab3250d98cea3d227622f6c5461ba433042682e6b384bbe4cd765f4" + }, { "name": "command-envelope", "id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", @@ -33,6 +39,12 @@ "path": "schemas/v1/event-envelope.schema.json", "sha256": "54780e954b80a07de08d428a03c972cb9fcfe6682691521fe6d6c357b45753dd" }, + { + "name": "folder-autopilot-profile", + "id": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "path": "schemas/v1/folder-autopilot-profile.schema.json", + "sha256": "025fea468ae1a46630913acc5608bba6d4f3a49c53c40cc538b167bc956e762d" + }, { "name": "identifier", "id": "https://schemas.databreeze.dev/contracts/v1/identifier", @@ -45,6 +57,12 @@ "path": "schemas/v1/problem-details.schema.json", "sha256": "c1209d3d234e75b13a84e7cfbbd2bec9f6d9f1daa3602ec2443f682770892272" }, + { + "name": "recipe-assignment", + "id": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "path": "schemas/v1/recipe-assignment.schema.json", + "sha256": "108b3c18c704558787894fd12b0a2018d78a1cb26b5e0e2ede04e7f0df4cdcbf" + }, { "name": "revision", "id": "https://schemas.databreeze.dev/contracts/v1/revision", @@ -71,11 +89,11 @@ "generatedPublicOutputs": [ { "path": "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt", - "sha256": "cd60b56f750e382ada6a9bbaba1baefef3a43e8fdb41ee6a12c89e1b8e1f9487" + "sha256": "b2192fb6261d08744a04415e3478dd56f477edcd1e57245415a6c3eb7f77d0a0" }, { "path": "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt", - "sha256": "f4c98e5f568968160687ffaf9ddbba4db51e25266c5baf927cde8b24e6a2442a" + "sha256": "36de7f3b631b7ae1cf2a0c8af21d5acab208d034d7344ee746cdae4bfeee76ff" }, { "path": "generated/python/databreeze_contracts/__init__.py", @@ -87,7 +105,7 @@ }, { "path": "generated/python/databreeze_contracts/v1/__init__.py", - "sha256": "785ff1b0fde763730070345f43b494203f149b10879683364fcbec0fcc35cb7e" + "sha256": "13fe14e8acc4331a59a3a8a78b2245defefaf3ea6c0bb119e3a50e87caa87d5d" }, { "path": "generated/python/databreeze_contracts/v1/_validation.py", @@ -95,7 +113,7 @@ }, { "path": "generated/python/databreeze_contracts/v1/models.py", - "sha256": "435a3af5a513fd14fbf232c0f289b901f4b3722a12b9f3a559d59df404fee4ab" + "sha256": "88b2b669895974cf9ae8fadf11ce4cbb9693333ff05f147f26c9a611a91c9723" }, { "path": "generated/python/pyproject.toml", @@ -103,11 +121,11 @@ }, { "path": "generated/typescript/v1/index.ts", - "sha256": "59e3b40f806d91a6d82b81a59e8937e6e0716d4eab44dbd5d2740e8eeb14912b" + "sha256": "d6e259477eff513ad79d0ebfb515d5a6a3cc491d52a9b1d7cf5af9f6c14200ad" }, { "path": "generated/typescript/v1/validation.mjs", - "sha256": "0d48072b7dbc919e7ac1d9a3fbd8b864137594a98cf33e900d239564851fda7e" + "sha256": "dbb9e3201d1210ef0f638dbf65467001b5ebded40c1c02fa16c273f8fb73032b" } ], "publicPackageSurfaces": [ @@ -130,12 +148,15 @@ "import": "./generated/typescript/v1/validation.mjs" }, "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", + "./v1/autopilot-folder-binding": "./schemas/v1/autopilot-folder-binding.schema.json", "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", "./v1/cursor-page": "./schemas/v1/cursor-page.schema.json", "./v1/event-envelope": "./schemas/v1/event-envelope.schema.json", + "./v1/folder-autopilot-profile": "./schemas/v1/folder-autopilot-profile.schema.json", "./v1/identifier": "./schemas/v1/identifier.schema.json", "./v1/problem-details": "./schemas/v1/problem-details.schema.json", + "./v1/recipe-assignment": "./schemas/v1/recipe-assignment.schema.json", "./v1/revision": "./schemas/v1/revision.schema.json", "./v1/tenant-scope": "./schemas/v1/tenant-scope.schema.json", "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt index 295949ab..c665607a 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt @@ -19,6 +19,17 @@ public data class ActorMetadata( public val actorType: String, ) +public data class AutopilotFolderBinding( + public val bindingId: Identifier, + public val createdAt: UtcTimestamp, + public val deviceGrantId: Identifier, + public val expectedCapabilityDigest: String, + public val revision: Revision, + public val role: String, + public val schemaVersion: Long, + public val tenantScope: TenantScope, +) + public data class CommandEnvelope( public val actor: ActorMetadata, public val commandId: Identifier, @@ -69,6 +80,21 @@ public data class EventEnvelopeEntity( public val revision: Revision, ) +public data class FolderAutopilotProfile( + public val collisionPolicy: String, + public val createdAt: UtcTimestamp, + public val maxFilesPerScan: Long, + public val outputLineageEnabled: Boolean, + public val payloadHash: String, + public val profileId: Identifier, + public val revision: Revision, + public val schemaVersion: Long, + public val stabilizationDelayMs: Long, + public val tenantScope: TenantScope, + public val undoWindowSeconds: Long, + public val version: Long, +) + public data class OrganizationScope( public val organizationId: Identifier, ) : TenantScope { @@ -119,6 +145,26 @@ public data class ProjectScope( public override val scopeType: String = "project" } +public data class RecipeAssignment( + public val assignmentId: Identifier, + public val createdAt: UtcTimestamp, + public val dataModeConstraint: String? = null, + public val deviceId: Identifier, + public val effectiveDataModePolicyRef: Identifier? = null, + public val idempotencyKey: String, + public val inputBindingIds: List, + public val jraRecipeVersionHash: String, + public val jraRecipeVersionId: Identifier, + public val outputBindingIds: List, + public val profileHash: String, + public val profileId: Identifier, + public val profileVersion: Long, + public val revision: Revision, + public val schemaVersion: Long, + public val state: String, + public val tenantScope: TenantScope, +) + public data class WorkspaceScope( public val organizationId: Identifier, public val workspaceId: Identifier, diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt index 9422343e..f6e3a341 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt @@ -49,12 +49,15 @@ private fun decodeSchema(encoded: String): String = private val schemaSources: Map = mapOf( "https://schemas.databreeze.dev/contracts/v1/actor-metadata" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2FjdG9yLW1ldGFkYXRhIiwiJGNvbW1lbnQiOiJTaGFyZWQgYWN0b3IgaWRlbnRpdHkgbWV0YWRhdGEgdXNlZCBieSBjb21tYW5kcyBhbmQgZXZlbnRzOyBzdXBwb3J0cyBBVUQtMDA0LiIsInRpdGxlIjoiQWN0b3IgTWV0YWRhdGEiLCJkZXNjcmlwdGlvbiI6IlRoZSBzdGFibGUgdHlwZSBhbmQgaWRlbnRpZmllciBvZiB0aGUgcHJpbmNpcGFsIHJlc3BvbnNpYmxlIGZvciBhbiBhY3Rpb24uIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiYWN0b3JUeXBlIiwiYWN0b3JJZCJdLCJwcm9wZXJ0aWVzIjp7ImFjdG9yVHlwZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bYS16XVthLXowLTlfLV17MCw2Mn0kIn0sImFjdG9ySWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0="), + "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2F1dG9waWxvdC1mb2xkZXItYmluZGluZyIsIiRjb21tZW50IjoiRkEtMDAxLi5GQS0wMDM6IGFuIG9wYXF1ZSBEU08gRGV2aWNlR3JhbnQgcmVmZXJlbmNlOyBuZXZlciBhIHBhdGgsIGxvY2FsIGhhbmRsZSwgb3IgcmV2b2NhdGlvbiByZWNvcmQuIiwidGl0bGUiOiJBdXRvcGlsb3QgRm9sZGVyIEJpbmRpbmciLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwiYmluZGluZ0lkIiwidGVuYW50U2NvcGUiLCJkZXZpY2VHcmFudElkIiwicm9sZSIsImV4cGVjdGVkQ2FwYWJpbGl0eURpZ2VzdCIsImNyZWF0ZWRBdCIsInJldmlzaW9uIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6MX0sImJpbmRpbmdJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJkZXZpY2VHcmFudElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicm9sZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il4oSU5QVVR8T1VUUFVUKSQifSwiZXhwZWN0ZWRDYXBhYmlsaXR5RGlnZXN0Ijp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlswLTlhLWZdezY0fSQifSwiY3JlYXRlZEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifSwicmV2aXNpb24iOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvcmV2aXNpb24ifX19"), "https://schemas.databreeze.dev/contracts/v1/command-envelope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvbW1hbmQtZW52ZWxvcGUiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSU5ULTAwNCBhbmQgSUFNLTAxOS4iLCJ0aXRsZSI6IklkZW1wb3RlbnQgQ29tbWFuZCBFbnZlbG9wZSIsImRlc2NyaXB0aW9uIjoiVGhlIHNoYXJlZCBjbG9zZWQgZW52ZWxvcGUgZm9yIGFuIGlkZW1wb3RlbnQsIHRlbmFudC1zY29wZWQgY29tbWFuZC4iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJjb21tYW5kSWQiLCJjb21tYW5kVHlwZSIsInNjaGVtYVZlcnNpb24iLCJ0ZW5hbnRTY29wZSIsImFjdG9yIiwiY29ycmVsYXRpb24iLCJpc3N1ZWRBdCIsImlkZW1wb3RlbmN5S2V5IiwiZGF0YSJdLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmRJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImNvbW1hbmRUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXSooXFwuW2Etel1bYS16MC05Xy1dKikrJCJ9LCJzY2hlbWFWZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJhY3RvciI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9hY3Rvci1tZXRhZGF0YSJ9LCJjb3JyZWxhdGlvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9jb3JyZWxhdGlvbi1tZXRhZGF0YSJ9LCJpc3N1ZWRBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sImlkZW1wb3RlbmN5S2V5Ijp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjU1fSwiZGF0YSI6eyJ0eXBlIjoib2JqZWN0In19fQ=="), "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvcnJlbGF0aW9uLW1ldGFkYXRhIiwiJGNvbW1lbnQiOiJQYXJ0aWFsIGZvdW5kYXRpb24gY292ZXJhZ2UgZm9yIEFVRC0wMDQgYW5kIElOVC0wMjEuIiwidGl0bGUiOiJDb3JyZWxhdGlvbiBNZXRhZGF0YSIsImRlc2NyaXB0aW9uIjoiQ29udGVudC1zYWZlIGlkZW50aWZpZXJzIHVzZWQgdG8gam9pbiBhIHJlcXVlc3Qgb3IgZXZlbnQgY2hhaW4uIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiY29ycmVsYXRpb25JZCJdLCJwcm9wZXJ0aWVzIjp7ImNvcnJlbGF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJjYXVzYXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInJlcXVlc3RJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn19fQ=="), "https://schemas.databreeze.dev/contracts/v1/cursor-page" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2N1cnNvci1wYWdlIiwiJGNvbW1lbnQiOiJTaGFyZWQgcGFnaW5hdGlvbiBzaGFwZSBzdXBwb3J0aW5nIElOVC0wMDUuIiwidGl0bGUiOiJDdXJzb3IgUGFnZSBFbnZlbG9wZSIsImRlc2NyaXB0aW9uIjoiVGhlIGNhbm9uaWNhbCBjbG9zZWQgcGFnZSBlbnZlbG9wZSB3aXRoIGEgVVRDIHNuYXBzaG90IGFuZCBvcGFxdWUgY29udGludWF0aW9uIGN1cnNvci4iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJkYXRhIiwic25hcHNob3RBdCIsImhhc01vcmUiXSwicHJvcGVydGllcyI6eyJkYXRhIjp7InR5cGUiOiJhcnJheSIsIml0ZW1zIjp7fX0sIm5leHRDdXJzb3IiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjo0MDk2fSwic25hcHNob3RBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sImhhc01vcmUiOnsidHlwZSI6ImJvb2xlYW4ifX0sImFsbE9mIjpbeyJpZiI6eyJwcm9wZXJ0aWVzIjp7Imhhc01vcmUiOnsiY29uc3QiOnRydWV9fSwicmVxdWlyZWQiOlsiaGFzTW9yZSJdfSwidGhlbiI6eyJwcm9wZXJ0aWVzIjp7Im5leHRDdXJzb3IiOnRydWV9LCJyZXF1aXJlZCI6WyJuZXh0Q3Vyc29yIl19LCJlbHNlIjp7Im5vdCI6eyJwcm9wZXJ0aWVzIjp7Im5leHRDdXJzb3IiOnRydWV9LCJyZXF1aXJlZCI6WyJuZXh0Q3Vyc29yIl19fX1dfQ=="), "https://schemas.databreeze.dev/contracts/v1/event-envelope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2V2ZW50LWVudmVsb3BlIiwiJGNvbW1lbnQiOiJDYW5vbmljYWwgZXZlbnQgYmFzZSBzdXBwb3J0aW5nIEFVRC0wMDQsIEFVRC0wMDYsIElBTS0wMTksIGFuZCBJTlQtMDA4LiIsInRpdGxlIjoiQ2Fub25pY2FsIEV2ZW50IEVudmVsb3BlIiwiZGVzY3JpcHRpb24iOiJUaGUgc2hhcmVkIGNsb3NlZCBlbnZlbG9wZSBmb3IgYSB2ZXJzaW9uZWQsIHRlbmFudC1zY29wZWQgZG9tYWluIGV2ZW50LiIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImV2ZW50SWQiLCJldmVudFR5cGUiLCJzY2hlbWFWZXJzaW9uIiwidGVuYW50U2NvcGUiLCJlbnRpdHkiLCJhY3RvciIsImNvcnJlbGF0aW9uIiwic291cmNlQ29tcG9uZW50Iiwib2NjdXJyZWRBdCIsImRhdGEiXSwicHJvcGVydGllcyI6eyJldmVudElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwiZXZlbnRUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXSooXFwuW2Etel1bYS16MC05Xy1dKikrJCJ9LCJzY2hlbWFWZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJlbnRpdHkiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiZW50aXR5VHlwZSIsImVudGl0eUlkIiwicmV2aXNpb24iXSwicHJvcGVydGllcyI6eyJlbnRpdHlUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXXswLDYyfSQifSwiZW50aXR5SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJyZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9fX0sImFjdG9yIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2FjdG9yLW1ldGFkYXRhIn0sImNvcnJlbGF0aW9uIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvcnJlbGF0aW9uLW1ldGFkYXRhIn0sInNvdXJjZUNvbXBvbmVudCI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bYS16XVthLXowLTlfLV17MCw2Mn0kIn0sIm9jY3VycmVkQXQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvdXRjLXRpbWVzdGFtcCJ9LCJkYXRhIjp7InR5cGUiOiJvYmplY3QifX19"), + "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2ZvbGRlci1hdXRvcGlsb3QtcHJvZmlsZSIsIiRjb21tZW50IjoiRkEtMDAxLi5GQS0wMDc6IGltbXV0YWJsZSB0eXBlZCBwcm9maWxlIHBheWxvYWQ7IG5vIGxvY2FsIHBhdGggb3IgcmVjaXBlIGF1dGhvcml0eS4iLCJ0aXRsZSI6IkZvbGRlciBBdXRvcGlsb3QgUHJvZmlsZSIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjaGVtYVZlcnNpb24iLCJwcm9maWxlSWQiLCJ0ZW5hbnRTY29wZSIsInZlcnNpb24iLCJwYXlsb2FkSGFzaCIsInN0YWJpbGl6YXRpb25EZWxheU1zIiwibWF4RmlsZXNQZXJTY2FuIiwiY29sbGlzaW9uUG9saWN5IiwidW5kb1dpbmRvd1NlY29uZHMiLCJvdXRwdXRMaW5lYWdlRW5hYmxlZCIsImNyZWF0ZWRBdCIsInJldmlzaW9uIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6MX0sInByb2ZpbGVJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJ2ZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MSwibWF4aW11bSI6MTAwMDB9LCJwYXlsb2FkSGFzaCI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bMC05YS1mXXs2NH0kIn0sInN0YWJpbGl6YXRpb25EZWxheU1zIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MCwibWF4aW11bSI6ODY0MDAwMDB9LCJtYXhGaWxlc1BlclNjYW4iOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxLCJtYXhpbXVtIjoxMDAwMDB9LCJjb2xsaXNpb25Qb2xpY3kiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKFJFVklFV3xTS0lQfFVOSVFVRV9OQU1FKSQifSwidW5kb1dpbmRvd1NlY29uZHMiOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjowLCJtYXhpbXVtIjo2MDQ4MDB9LCJvdXRwdXRMaW5lYWdlRW5hYmxlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJjcmVhdGVkQXQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvdXRjLXRpbWVzdGFtcCJ9LCJyZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9fX0="), "https://schemas.databreeze.dev/contracts/v1/identifier" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSUFNLTAwMS4iLCJ0aXRsZSI6IlN0YWJsZSBVVUlEIElkZW50aWZpZXIiLCJkZXNjcmlwdGlvbiI6IkFuIG9wYXF1ZSBzdGFibGUgVVVJRCBpZGVudGlmaWVyLiIsInR5cGUiOiJzdHJpbmciLCJmb3JtYXQiOiJ1dWlkIn0="), "https://schemas.databreeze.dev/contracts/v1/problem-details" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3Byb2JsZW0tZGV0YWlscyIsIiRjb21tZW50IjoiUkZDIDc4MDctY29tcGF0aWJsZSBiYXNlIHdpdGggdGhlIHNhZmUgcHVibGljIGVycm9yIG1ldGFkYXRhIHJlcXVpcmVkIGJ5IElOVC0wMjEgYW5kIFdFQi0wMjEuIiwidGl0bGUiOiJQcm9ibGVtIERldGFpbHMiLCJkZXNjcmlwdGlvbiI6IkEgY2xvc2VkIFJGQyA3ODA3LWNvbXBhdGlibGUgcHJvYmxlbSBkb2N1bWVudCB3aXRoIERhdGFCcmVlemUgcHVibGljIGVycm9yIGV4dGVuc2lvbnMuIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsidHlwZSIsInN0YXR1cyIsImNvZGUiLCJjb3JyZWxhdGlvbklkIiwicmV0cnlhYmxlIl0sImFueU9mIjpbeyJwcm9wZXJ0aWVzIjp7InRpdGxlS2V5Ijp0cnVlfSwicmVxdWlyZWQiOlsidGl0bGVLZXkiXX0seyJwcm9wZXJ0aWVzIjp7Im1lc3NhZ2VLZXkiOnRydWV9LCJyZXF1aXJlZCI6WyJtZXNzYWdlS2V5Il19XSwicHJvcGVydGllcyI6eyJ0eXBlIjp7InR5cGUiOiJzdHJpbmciLCJmb3JtYXQiOiJ1cmktcmVmZXJlbmNlIn0sInRpdGxlIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjF9LCJ0aXRsZUtleSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sInN0YXR1cyI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjEwMCwibWF4aW11bSI6NTk5fSwiZGV0YWlsIjp7InR5cGUiOiJzdHJpbmcifSwiaW5zdGFuY2UiOnsidHlwZSI6InN0cmluZyIsImZvcm1hdCI6InVyaS1yZWZlcmVuY2UifSwiY29kZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bQS1aXVtBLVowLTlfXXswLDEyN30kIn0sImNvcnJlbGF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJyZXRyeWFibGUiOnsidHlwZSI6ImJvb2xlYW4ifSwibWVzc2FnZUtleSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sImZpZWxkRXJyb3JzIjp7InR5cGUiOiJhcnJheSIsIm1heEl0ZW1zIjoxMDAsIml0ZW1zIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImZpZWxkIiwiY29kZSJdLCJwcm9wZXJ0aWVzIjp7ImZpZWxkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjU1fSwiY29kZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bQS1aXVtBLVowLTlfXXswLDEyN30kIn19fX0sInJldHJ5QWZ0ZXJTZWNvbmRzIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MH0sImN1cnJlbnRSZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9LCJyZW1lZGlhdGlvbkFjdGlvbiI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sInJhdGVMaW1pdCI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZSIsInJlc2V0QXQiXSwicHJvcGVydGllcyI6eyJzY29wZSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sImxpbWl0Ijp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MH0sInJlbWFpbmluZyI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjB9LCJyZXNldEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifX19fX0="), + "https://schemas.databreeze.dev/contracts/v1/recipe-assignment" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3JlY2lwZS1hc3NpZ25tZW50IiwiJGNvbW1lbnQiOiJGQS0wMDUuLkZBLTAwNywgRkEtMDE0LCBGQS0wMTUsIEZBLTAzMTogSlJBIGFuZCBEU08gYXJlIHJlZmVyZW5jZWQgYnkgb3BhcXVlIElEcyBhbmQgaGFzaGVzLiIsInRpdGxlIjoiRm9sZGVyIEF1dG9waWxvdCBSZWNpcGUgQXNzaWdubWVudCIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjaGVtYVZlcnNpb24iLCJhc3NpZ25tZW50SWQiLCJ0ZW5hbnRTY29wZSIsInByb2ZpbGVJZCIsInByb2ZpbGVWZXJzaW9uIiwicHJvZmlsZUhhc2giLCJqcmFSZWNpcGVWZXJzaW9uSWQiLCJqcmFSZWNpcGVWZXJzaW9uSGFzaCIsImRldmljZUlkIiwiaW5wdXRCaW5kaW5nSWRzIiwib3V0cHV0QmluZGluZ0lkcyIsImlkZW1wb3RlbmN5S2V5Iiwic3RhdGUiLCJyZXZpc2lvbiIsImNyZWF0ZWRBdCJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjF9LCJhc3NpZ25tZW50SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJ0ZW5hbnRTY29wZSI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS90ZW5hbnQtc2NvcGUifSwicHJvZmlsZUlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicHJvZmlsZVZlcnNpb24iOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxLCJtYXhpbXVtIjoxMDAwMH0sInByb2ZpbGVIYXNoIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlswLTlhLWZdezY0fSQifSwianJhUmVjaXBlVmVyc2lvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwianJhUmVjaXBlVmVyc2lvbkhhc2giOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeWzAtOWEtZl17NjR9JCJ9LCJkZXZpY2VJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImlucHV0QmluZGluZ0lkcyI6eyJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIm1heEl0ZW1zIjozMn0sIm91dHB1dEJpbmRpbmdJZHMiOnsidHlwZSI6ImFycmF5IiwiaXRlbXMiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJtYXhJdGVtcyI6MzJ9LCJkYXRhTW9kZUNvbnN0cmFpbnQiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKExPQ0FMfEhZQlJJRHxDTE9VRCkkIn0sImVmZmVjdGl2ZURhdGFNb2RlUG9saWN5UmVmIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwiaWRlbXBvdGVuY3lLZXkiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoyMDB9LCJzdGF0ZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il4oRFJBRlR8QUNUSVZFfFBBVVNFRHxSRVRJUkVEKSQifSwicmV2aXNpb24iOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvcmV2aXNpb24ifSwiY3JlYXRlZEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifX19"), "https://schemas.databreeze.dev/contracts/v1/revision" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3JldmlzaW9uIiwiJGNvbW1lbnQiOiJTdXBwb3J0cyBvcHRpbWlzdGljLWNvbmN1cnJlbmN5IHJldmlzaW9ucyBkZXNjcmliZWQgYnkgdGhlIGRvbWFpbiBhbmQgZGF0YSBtb2RlbC4iLCJ0aXRsZSI6IkVudGl0eSBSZXZpc2lvbiIsImRlc2NyaXB0aW9uIjoiQSBwb3NpdGl2ZSwgbW9ub3RvbmljYWxseSBpbmNyZWFzaW5nIGVudGl0eSByZXZpc2lvbi4iLCJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9"), "https://schemas.databreeze.dev/contracts/v1/tenant-scope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSIsIiRjb21tZW50IjoiUGFydGlhbCBmb3VuZGF0aW9uIGNvdmVyYWdlIGZvciBJQU0tMDE5LiIsInRpdGxlIjoiVGVuYW50IFNjb3BlIiwiZGVzY3JpcHRpb24iOiJBIGRpc2NyaW1pbmF0ZWQgdGVuYW50IHNjb3BlIGNvbnRhaW5pbmcgdGhlIGNvbXBsZXRlIGFuY2VzdHJ5IHJlcXVpcmVkIGF0IGl0cyBsZXZlbC4iLCJvbmVPZiI6W3siJHJlZiI6IiMvJGRlZnMvb3JnYW5pemF0aW9uU2NvcGUifSx7IiRyZWYiOiIjLyRkZWZzL3dvcmtzcGFjZVNjb3BlIn0seyIkcmVmIjoiIy8kZGVmcy9wcm9qZWN0U2NvcGUifV0sIiRkZWZzIjp7Im9yZ2FuaXphdGlvblNjb3BlIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjb3BlVHlwZSIsIm9yZ2FuaXphdGlvbklkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoib3JnYW5pemF0aW9uIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19LCJ3b3Jrc3BhY2VTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoid29ya3NwYWNlIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwid29ya3NwYWNlSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0sInByb2plY3RTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIiwicHJvamVjdElkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0IjoicHJvamVjdCJ9LCJvcmdhbml6YXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIndvcmtzcGFjZUlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicHJvamVjdElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19fX0="), "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSUFNLTAwMSBhbmQgSU5ULTAwOC4iLCJ0aXRsZSI6IlVUQyBUaW1lc3RhbXAiLCJkZXNjcmlwdGlvbiI6IkFuIFJGQyAzMzM5IGRhdGUtdGltZSBub3JtYWxpemVkIHRvIFVUQyBhbmQgdGVybWluYXRlZCBieSB1cHBlcmNhc2UgWi4iLCJ0eXBlIjoic3RyaW5nIiwiZm9ybWF0IjoiZGF0ZS10aW1lIiwicGF0dGVybiI6IlokIn0="), @@ -67,6 +70,7 @@ private val schemaRegistry: SchemaRegistry = private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = when (schemaId) { "https://schemas.databreeze.dev/contracts/v1/actor-metadata" -> mapper.treeToValue(payload, ActorMetadata::class.java) + "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding" -> mapper.treeToValue(payload, AutopilotFolderBinding::class.java) "https://schemas.databreeze.dev/contracts/v1/command-envelope" -> mapper.convertValue( payload, object : TypeReference>() {}, @@ -80,8 +84,10 @@ private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = payload, object : TypeReference>() {}, ) + "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile" -> mapper.treeToValue(payload, FolderAutopilotProfile::class.java) "https://schemas.databreeze.dev/contracts/v1/identifier" -> mapper.treeToValue(payload, String::class.java) "https://schemas.databreeze.dev/contracts/v1/problem-details" -> mapper.treeToValue(payload, ProblemDetails::class.java) + "https://schemas.databreeze.dev/contracts/v1/recipe-assignment" -> mapper.treeToValue(payload, RecipeAssignment::class.java) "https://schemas.databreeze.dev/contracts/v1/revision" -> payload.longValue() "https://schemas.databreeze.dev/contracts/v1/tenant-scope" -> when (payload.required("scopeType").asText()) { "organization" -> mapper.treeToValue(payload, OrganizationScope::class.java) diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py b/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py index 4d609bc1..6f01ee66 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py @@ -2,17 +2,20 @@ from .models import ( ActorMetadata, + AutopilotFolderBinding, CommandEnvelope, CorrelationMetadata, CursorPage, EventEnvelope, EventEnvelopeEntity, + FolderAutopilotProfile, Identifier, OrganizationScope, ProblemDetails, ProblemDetailsFieldErrorsItem, ProblemDetailsRateLimit, ProjectScope, + RecipeAssignment, Revision, TenantScope, UtcTimestamp, @@ -21,17 +24,20 @@ __all__ = [ "ActorMetadata", + "AutopilotFolderBinding", "CommandEnvelope", "CorrelationMetadata", "CursorPage", "EventEnvelope", "EventEnvelopeEntity", + "FolderAutopilotProfile", "Identifier", "OrganizationScope", "ProblemDetails", "ProblemDetailsFieldErrorsItem", "ProblemDetailsRateLimit", "ProjectScope", + "RecipeAssignment", "Revision", "TenantScope", "UtcTimestamp", diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/models.py b/packages/contracts/generated/python/databreeze_contracts/v1/models.py index 00438505..b7e2bf4d 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/models.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/models.py @@ -52,6 +52,16 @@ class ActorMetadata(ClosedModel): actorId: Identifier actorType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] +class AutopilotFolderBinding(ClosedModel): + bindingId: Identifier + createdAt: UtcTimestamp + deviceGrantId: Identifier + expectedCapabilityDigest: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + revision: Revision + role: Annotated[StrictStr, StringConstraints(pattern=r"^(INPUT|OUTPUT)$")] + schemaVersion: Literal[1] + tenantScope: TenantScope + class CommandEnvelope(ClosedModel, Generic[TData]): actor: ActorMetadata commandId: Identifier @@ -99,6 +109,20 @@ class EventEnvelopeEntity(ClosedModel): entityType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] revision: Revision +class FolderAutopilotProfile(ClosedModel): + collisionPolicy: Annotated[StrictStr, StringConstraints(pattern=r"^(REVIEW|SKIP|UNIQUE_NAME)$")] + createdAt: UtcTimestamp + maxFilesPerScan: Annotated[int, Field(strict=True, ge=1, le=100000)] + outputLineageEnabled: StrictBool + payloadHash: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + profileId: Identifier + revision: Revision + schemaVersion: Literal[1] + stabilizationDelayMs: Annotated[int, Field(strict=True, ge=0, le=86400000)] + tenantScope: TenantScope + undoWindowSeconds: Annotated[int, Field(strict=True, ge=0, le=604800)] + version: Annotated[int, Field(strict=True, ge=1, le=10000)] + class OrganizationScope(ClosedModel): organizationId: Identifier scopeType: Literal["organization"] @@ -142,6 +166,25 @@ class ProjectScope(ClosedModel): scopeType: Literal["project"] workspaceId: Identifier +class RecipeAssignment(ClosedModel): + assignmentId: Identifier + createdAt: UtcTimestamp + dataModeConstraint: Annotated[StrictStr, StringConstraints(pattern=r"^(LOCAL|HYBRID|CLOUD)$")] | None = None + deviceId: Identifier + effectiveDataModePolicyRef: Identifier | None = None + idempotencyKey: Annotated[StrictStr, StringConstraints(min_length=1, max_length=200)] + inputBindingIds: Annotated[list[Identifier], Field(max_length=32)] + jraRecipeVersionHash: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + jraRecipeVersionId: Identifier + outputBindingIds: Annotated[list[Identifier], Field(max_length=32)] + profileHash: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + profileId: Identifier + profileVersion: Annotated[int, Field(strict=True, ge=1, le=10000)] + revision: Revision + schemaVersion: Literal[1] + state: Annotated[StrictStr, StringConstraints(pattern=r"^(DRAFT|ACTIVE|PAUSED|RETIRED)$")] + tenantScope: TenantScope + class WorkspaceScope(ClosedModel): organizationId: Identifier scopeType: Literal["workspace"] @@ -150,14 +193,17 @@ class WorkspaceScope(ClosedModel): TenantScope: TypeAlias = Annotated[OrganizationScope | WorkspaceScope | ProjectScope, Field(discriminator="scopeType")] ActorMetadata.model_rebuild() +AutopilotFolderBinding.model_rebuild() CommandEnvelope.model_rebuild() CorrelationMetadata.model_rebuild() CursorPage.model_rebuild() EventEnvelope.model_rebuild() EventEnvelopeEntity.model_rebuild() +FolderAutopilotProfile.model_rebuild() OrganizationScope.model_rebuild() ProblemDetails.model_rebuild() ProblemDetailsFieldErrorsItem.model_rebuild() ProblemDetailsRateLimit.model_rebuild() ProjectScope.model_rebuild() +RecipeAssignment.model_rebuild() WorkspaceScope.model_rebuild() diff --git a/packages/contracts/generated/typescript/v1/index.ts b/packages/contracts/generated/typescript/v1/index.ts index 17114417..a2272583 100644 --- a/packages/contracts/generated/typescript/v1/index.ts +++ b/packages/contracts/generated/typescript/v1/index.ts @@ -9,6 +9,17 @@ export interface ActorMetadata { readonly actorType: string; } +export interface AutopilotFolderBinding { + readonly bindingId: Identifier; + readonly createdAt: UtcTimestamp; + readonly deviceGrantId: Identifier; + readonly expectedCapabilityDigest: string; + readonly revision: Revision; + readonly role: string; + readonly schemaVersion: 1; + readonly tenantScope: TenantScope; +} + export interface CommandEnvelope { readonly actor: ActorMetadata; readonly commandId: Identifier; @@ -58,6 +69,21 @@ export interface EventEnvelopeEntity { readonly revision: Revision; } +export interface FolderAutopilotProfile { + readonly collisionPolicy: string; + readonly createdAt: UtcTimestamp; + readonly maxFilesPerScan: number; + readonly outputLineageEnabled: boolean; + readonly payloadHash: string; + readonly profileId: Identifier; + readonly revision: Revision; + readonly schemaVersion: 1; + readonly stabilizationDelayMs: number; + readonly tenantScope: TenantScope; + readonly undoWindowSeconds: number; + readonly version: number; +} + export type Identifier = string; export interface OrganizationScope { @@ -104,6 +130,26 @@ export interface ProjectScope { readonly workspaceId: Identifier; } +export interface RecipeAssignment { + readonly assignmentId: Identifier; + readonly createdAt: UtcTimestamp; + readonly dataModeConstraint?: string; + readonly deviceId: Identifier; + readonly effectiveDataModePolicyRef?: Identifier; + readonly idempotencyKey: string; + readonly inputBindingIds: readonly Identifier[]; + readonly jraRecipeVersionHash: string; + readonly jraRecipeVersionId: Identifier; + readonly outputBindingIds: readonly Identifier[]; + readonly profileHash: string; + readonly profileId: Identifier; + readonly profileVersion: number; + readonly revision: Revision; + readonly schemaVersion: 1; + readonly state: string; + readonly tenantScope: TenantScope; +} + export type Revision = number; export type TenantScope = OrganizationScope | WorkspaceScope | ProjectScope; @@ -116,7 +162,7 @@ export interface WorkspaceScope { readonly workspaceId: Identifier; } -export type ContractV1SchemaId = "https://schemas.databreeze.dev/contracts/v1/actor-metadata" | "https://schemas.databreeze.dev/contracts/v1/command-envelope" | "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" | "https://schemas.databreeze.dev/contracts/v1/cursor-page" | "https://schemas.databreeze.dev/contracts/v1/event-envelope" | "https://schemas.databreeze.dev/contracts/v1/identifier" | "https://schemas.databreeze.dev/contracts/v1/problem-details" | "https://schemas.databreeze.dev/contracts/v1/revision" | "https://schemas.databreeze.dev/contracts/v1/tenant-scope" | "https://schemas.databreeze.dev/contracts/v1/utc-timestamp"; +export type ContractV1SchemaId = "https://schemas.databreeze.dev/contracts/v1/actor-metadata" | "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding" | "https://schemas.databreeze.dev/contracts/v1/command-envelope" | "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" | "https://schemas.databreeze.dev/contracts/v1/cursor-page" | "https://schemas.databreeze.dev/contracts/v1/event-envelope" | "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile" | "https://schemas.databreeze.dev/contracts/v1/identifier" | "https://schemas.databreeze.dev/contracts/v1/problem-details" | "https://schemas.databreeze.dev/contracts/v1/recipe-assignment" | "https://schemas.databreeze.dev/contracts/v1/revision" | "https://schemas.databreeze.dev/contracts/v1/tenant-scope" | "https://schemas.databreeze.dev/contracts/v1/utc-timestamp"; export type ContractV1ParseResult = | { readonly accepted: true; readonly value: TValue } diff --git a/packages/contracts/generated/typescript/v1/validation.mjs b/packages/contracts/generated/typescript/v1/validation.mjs index b4cc1597..4445d2d6 100644 --- a/packages/contracts/generated/typescript/v1/validation.mjs +++ b/packages/contracts/generated/typescript/v1/validation.mjs @@ -5,12 +5,15 @@ import addFormats from 'ajv-formats'; const schemas = [ {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/actor-metadata","$comment":"Shared actor identity metadata used by commands and events; supports AUD-004.","title":"Actor Metadata","description":"The stable type and identifier of the principal responsible for an action.","type":"object","additionalProperties":false,"required":["actorType","actorId"],"properties":{"actorType":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"actorId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding","$comment":"FA-001..FA-003: an opaque DSO DeviceGrant reference; never a path, local handle, or revocation record.","title":"Autopilot Folder Binding","type":"object","additionalProperties":false,"required":["schemaVersion","bindingId","tenantScope","deviceGrantId","role","expectedCapabilityDigest","createdAt","revision"],"properties":{"schemaVersion":{"const":1},"bindingId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"deviceGrantId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"role":{"type":"string","pattern":"^(INPUT|OUTPUT)$"},"expectedCapabilityDigest":{"type":"string","pattern":"^[0-9a-f]{64}$"},"createdAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/command-envelope","$comment":"Partial foundation coverage for INT-004 and IAM-019.","title":"Idempotent Command Envelope","description":"The shared closed envelope for an idempotent, tenant-scoped command.","type":"object","additionalProperties":false,"required":["commandId","commandType","schemaVersion","tenantScope","actor","correlation","issuedAt","idempotencyKey","data"],"properties":{"commandId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"commandType":{"type":"string","pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"},"schemaVersion":{"type":"integer","minimum":1},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"actor":{"$ref":"https://schemas.databreeze.dev/contracts/v1/actor-metadata"},"correlation":{"$ref":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata"},"issuedAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"idempotencyKey":{"type":"string","minLength":1,"maxLength":255},"data":{"type":"object"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata","$comment":"Partial foundation coverage for AUD-004 and INT-021.","title":"Correlation Metadata","description":"Content-safe identifiers used to join a request or event chain.","type":"object","additionalProperties":false,"required":["correlationId"],"properties":{"correlationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"causationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"requestId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/cursor-page","$comment":"Shared pagination shape supporting INT-005.","title":"Cursor Page Envelope","description":"The canonical closed page envelope with a UTC snapshot and opaque continuation cursor.","type":"object","additionalProperties":false,"required":["data","snapshotAt","hasMore"],"properties":{"data":{"type":"array","items":{}},"nextCursor":{"type":"string","minLength":1,"maxLength":4096},"snapshotAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"hasMore":{"type":"boolean"}},"allOf":[{"if":{"properties":{"hasMore":{"const":true}},"required":["hasMore"]},"then":{"properties":{"nextCursor":true},"required":["nextCursor"]},"else":{"not":{"properties":{"nextCursor":true},"required":["nextCursor"]}}}]}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/event-envelope","$comment":"Canonical event base supporting AUD-004, AUD-006, IAM-019, and INT-008.","title":"Canonical Event Envelope","description":"The shared closed envelope for a versioned, tenant-scoped domain event.","type":"object","additionalProperties":false,"required":["eventId","eventType","schemaVersion","tenantScope","entity","actor","correlation","sourceComponent","occurredAt","data"],"properties":{"eventId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"eventType":{"type":"string","pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"},"schemaVersion":{"type":"integer","minimum":1},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"entity":{"type":"object","additionalProperties":false,"required":["entityType","entityId","revision"],"properties":{"entityType":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"entityId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}},"actor":{"$ref":"https://schemas.databreeze.dev/contracts/v1/actor-metadata"},"correlation":{"$ref":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata"},"sourceComponent":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"occurredAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"data":{"type":"object"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile","$comment":"FA-001..FA-007: immutable typed profile payload; no local path or recipe authority.","title":"Folder Autopilot Profile","type":"object","additionalProperties":false,"required":["schemaVersion","profileId","tenantScope","version","payloadHash","stabilizationDelayMs","maxFilesPerScan","collisionPolicy","undoWindowSeconds","outputLineageEnabled","createdAt","revision"],"properties":{"schemaVersion":{"const":1},"profileId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"version":{"type":"integer","minimum":1,"maximum":10000},"payloadHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"stabilizationDelayMs":{"type":"integer","minimum":0,"maximum":86400000},"maxFilesPerScan":{"type":"integer","minimum":1,"maximum":100000},"collisionPolicy":{"type":"string","pattern":"^(REVIEW|SKIP|UNIQUE_NAME)$"},"undoWindowSeconds":{"type":"integer","minimum":0,"maximum":604800},"outputLineageEnabled":{"type":"boolean"},"createdAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/identifier","$comment":"Partial foundation coverage for IAM-001.","title":"Stable UUID Identifier","description":"An opaque stable UUID identifier.","type":"string","format":"uuid"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/problem-details","$comment":"RFC 7807-compatible base with the safe public error metadata required by INT-021 and WEB-021.","title":"Problem Details","description":"A closed RFC 7807-compatible problem document with DataBreeze public error extensions.","type":"object","additionalProperties":false,"required":["type","status","code","correlationId","retryable"],"anyOf":[{"properties":{"titleKey":true},"required":["titleKey"]},{"properties":{"messageKey":true},"required":["messageKey"]}],"properties":{"type":{"type":"string","format":"uri-reference"},"title":{"type":"string","minLength":1},"titleKey":{"type":"string","minLength":1,"maxLength":255},"status":{"type":"integer","minimum":100,"maximum":599},"detail":{"type":"string"},"instance":{"type":"string","format":"uri-reference"},"code":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"},"correlationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"retryable":{"type":"boolean"},"messageKey":{"type":"string","minLength":1,"maxLength":255},"fieldErrors":{"type":"array","maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["field","code"],"properties":{"field":{"type":"string","minLength":1,"maxLength":255},"code":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}}}},"retryAfterSeconds":{"type":"integer","minimum":0},"currentRevision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"},"remediationAction":{"type":"string","minLength":1,"maxLength":255},"rateLimit":{"type":"object","additionalProperties":false,"required":["scope","resetAt"],"properties":{"scope":{"type":"string","minLength":1,"maxLength":255},"limit":{"type":"integer","minimum":0},"remaining":{"type":"integer","minimum":0},"resetAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"}}}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/recipe-assignment","$comment":"FA-005..FA-007, FA-014, FA-015, FA-031: JRA and DSO are referenced by opaque IDs and hashes.","title":"Folder Autopilot Recipe Assignment","type":"object","additionalProperties":false,"required":["schemaVersion","assignmentId","tenantScope","profileId","profileVersion","profileHash","jraRecipeVersionId","jraRecipeVersionHash","deviceId","inputBindingIds","outputBindingIds","idempotencyKey","state","revision","createdAt"],"properties":{"schemaVersion":{"const":1},"assignmentId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"profileId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"profileVersion":{"type":"integer","minimum":1,"maximum":10000},"profileHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"jraRecipeVersionId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"jraRecipeVersionHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"deviceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"inputBindingIds":{"type":"array","items":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"maxItems":32},"outputBindingIds":{"type":"array","items":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"maxItems":32},"dataModeConstraint":{"type":"string","pattern":"^(LOCAL|HYBRID|CLOUD)$"},"effectiveDataModePolicyRef":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"idempotencyKey":{"type":"string","minLength":1,"maxLength":200},"state":{"type":"string","pattern":"^(DRAFT|ACTIVE|PAUSED|RETIRED)$"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"},"createdAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/revision","$comment":"Supports optimistic-concurrency revisions described by the domain and data model.","title":"Entity Revision","description":"A positive, monotonically increasing entity revision.","type":"integer","minimum":1}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/tenant-scope","$comment":"Partial foundation coverage for IAM-019.","title":"Tenant Scope","description":"A discriminated tenant scope containing the complete ancestry required at its level.","oneOf":[{"$ref":"#/$defs/organizationScope"},{"$ref":"#/$defs/workspaceScope"},{"$ref":"#/$defs/projectScope"}],"$defs":{"organizationScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId"],"properties":{"scopeType":{"const":"organization"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"workspaceScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId"],"properties":{"scopeType":{"const":"workspace"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"projectScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId","projectId"],"properties":{"scopeType":{"const":"project"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"projectId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp","$comment":"Partial foundation coverage for IAM-001 and INT-008.","title":"UTC Timestamp","description":"An RFC 3339 date-time normalized to UTC and terminated by uppercase Z.","type":"string","format":"date-time","pattern":"Z$"}, diff --git a/packages/contracts/manifest.json b/packages/contracts/manifest.json index e76ffe63..e952ece8 100644 --- a/packages/contracts/manifest.json +++ b/packages/contracts/manifest.json @@ -7,6 +7,11 @@ "id": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", "path": "schemas/v1/actor-metadata.schema.json" }, + { + "name": "autopilot-folder-binding", + "id": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "path": "schemas/v1/autopilot-folder-binding.schema.json" + }, { "name": "command-envelope", "id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", @@ -27,6 +32,11 @@ "id": "https://schemas.databreeze.dev/contracts/v1/event-envelope", "path": "schemas/v1/event-envelope.schema.json" }, + { + "name": "folder-autopilot-profile", + "id": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "path": "schemas/v1/folder-autopilot-profile.schema.json" + }, { "name": "identifier", "id": "https://schemas.databreeze.dev/contracts/v1/identifier", @@ -37,6 +47,11 @@ "id": "https://schemas.databreeze.dev/contracts/v1/problem-details", "path": "schemas/v1/problem-details.schema.json" }, + { + "name": "recipe-assignment", + "id": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "path": "schemas/v1/recipe-assignment.schema.json" + }, { "name": "revision", "id": "https://schemas.databreeze.dev/contracts/v1/revision", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2abb1818..e276b4df 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,12 +10,15 @@ "import": "./generated/typescript/v1/validation.mjs" }, "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", + "./v1/autopilot-folder-binding": "./schemas/v1/autopilot-folder-binding.schema.json", "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", "./v1/cursor-page": "./schemas/v1/cursor-page.schema.json", "./v1/event-envelope": "./schemas/v1/event-envelope.schema.json", + "./v1/folder-autopilot-profile": "./schemas/v1/folder-autopilot-profile.schema.json", "./v1/identifier": "./schemas/v1/identifier.schema.json", "./v1/problem-details": "./schemas/v1/problem-details.schema.json", + "./v1/recipe-assignment": "./schemas/v1/recipe-assignment.schema.json", "./v1/revision": "./schemas/v1/revision.schema.json", "./v1/tenant-scope": "./schemas/v1/tenant-scope.schema.json", "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" diff --git a/packages/contracts/schemas/v1/autopilot-folder-binding.schema.json b/packages/contracts/schemas/v1/autopilot-folder-binding.schema.json new file mode 100644 index 00000000..0cfc1838 --- /dev/null +++ b/packages/contracts/schemas/v1/autopilot-folder-binding.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "$comment": "FA-001..FA-003: an opaque DSO DeviceGrant reference; never a path, local handle, or revocation record.", + "title": "Autopilot Folder Binding", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "bindingId", + "tenantScope", + "deviceGrantId", + "role", + "expectedCapabilityDigest", + "createdAt", + "revision" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "bindingId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "tenantScope": { "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" }, + "deviceGrantId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "role": { "type": "string", "pattern": "^(INPUT|OUTPUT)$" }, + "expectedCapabilityDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, + "revision": { "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" } + } +} diff --git a/packages/contracts/schemas/v1/folder-autopilot-profile.schema.json b/packages/contracts/schemas/v1/folder-autopilot-profile.schema.json new file mode 100644 index 00000000..99207b2c --- /dev/null +++ b/packages/contracts/schemas/v1/folder-autopilot-profile.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "$comment": "FA-001..FA-007: immutable typed profile payload; no local path or recipe authority.", + "title": "Folder Autopilot Profile", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "profileId", + "tenantScope", + "version", + "payloadHash", + "stabilizationDelayMs", + "maxFilesPerScan", + "collisionPolicy", + "undoWindowSeconds", + "outputLineageEnabled", + "createdAt", + "revision" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "profileId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "tenantScope": { "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" }, + "version": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "payloadHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "stabilizationDelayMs": { "type": "integer", "minimum": 0, "maximum": 86400000 }, + "maxFilesPerScan": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "collisionPolicy": { "type": "string", "pattern": "^(REVIEW|SKIP|UNIQUE_NAME)$" }, + "undoWindowSeconds": { "type": "integer", "minimum": 0, "maximum": 604800 }, + "outputLineageEnabled": { "type": "boolean" }, + "createdAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, + "revision": { "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" } + } +} diff --git a/packages/contracts/schemas/v1/recipe-assignment.schema.json b/packages/contracts/schemas/v1/recipe-assignment.schema.json new file mode 100644 index 00000000..9fd168e8 --- /dev/null +++ b/packages/contracts/schemas/v1/recipe-assignment.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "$comment": "FA-005..FA-007, FA-014, FA-015, FA-031: JRA and DSO are referenced by opaque IDs and hashes.", + "title": "Folder Autopilot Recipe Assignment", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "assignmentId", + "tenantScope", + "profileId", + "profileVersion", + "profileHash", + "jraRecipeVersionId", + "jraRecipeVersionHash", + "deviceId", + "inputBindingIds", + "outputBindingIds", + "idempotencyKey", + "state", + "revision", + "createdAt" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "assignmentId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "tenantScope": { "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" }, + "profileId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "profileVersion": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "profileHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "jraRecipeVersionId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "jraRecipeVersionHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "deviceId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "inputBindingIds": { + "type": "array", + "items": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "maxItems": 32 + }, + "outputBindingIds": { + "type": "array", + "items": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "maxItems": 32 + }, + "dataModeConstraint": { "type": "string", "pattern": "^(LOCAL|HYBRID|CLOUD)$" }, + "effectiveDataModePolicyRef": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 }, + "state": { "type": "string", "pattern": "^(DRAFT|ACTIVE|PAUSED|RETIRED)$" }, + "revision": { "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" }, + "createdAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" } + } +} diff --git a/packages/contracts/test/schemas.test.mjs b/packages/contracts/test/schemas.test.mjs index a3e6f633..b4e03f54 100644 --- a/packages/contracts/test/schemas.test.mjs +++ b/packages/contracts/test/schemas.test.mjs @@ -15,12 +15,15 @@ const schemaBase = 'https://schemas.databreeze.dev/contracts/v1'; const ids = { actorMetadata: `${schemaBase}/actor-metadata`, + autopilotFolderBinding: `${schemaBase}/autopilot-folder-binding`, commandEnvelope: `${schemaBase}/command-envelope`, correlationMetadata: `${schemaBase}/correlation-metadata`, cursorPage: `${schemaBase}/cursor-page`, eventEnvelope: `${schemaBase}/event-envelope`, + folderAutopilotProfile: `${schemaBase}/folder-autopilot-profile`, identifier: `${schemaBase}/identifier`, problemDetails: `${schemaBase}/problem-details`, + recipeAssignment: `${schemaBase}/recipe-assignment`, revision: `${schemaBase}/revision`, tenantScope: `${schemaBase}/tenant-scope`, utcTimestamp: `${schemaBase}/utc-timestamp`, @@ -62,12 +65,15 @@ test('publishes the complete deterministic v1 registry and compiles every real s const { ajv, manifest, schemas } = loadContracts(); const expectedNames = [ 'actor-metadata', + 'autopilot-folder-binding', 'command-envelope', 'correlation-metadata', 'cursor-page', 'event-envelope', + 'folder-autopilot-profile', 'identifier', 'problem-details', + 'recipe-assignment', 'revision', 'tenant-scope', 'utc-timestamp', @@ -100,12 +106,15 @@ test('exports only declared registry schema and generated TypeScript entry point '.', './v1', './v1/actor-metadata', + './v1/autopilot-folder-binding', './v1/command-envelope', './v1/correlation-metadata', './v1/cursor-page', './v1/event-envelope', + './v1/folder-autopilot-profile', './v1/identifier', './v1/problem-details', + './v1/recipe-assignment', './v1/revision', './v1/tenant-scope', './v1/utc-timestamp', @@ -158,6 +167,57 @@ test('rejects incomplete or discriminator-mismatched tenant ancestry', () => { assert.equal(validate({ scopeType: 'workspace', organizationId, workspaceId, projectId }), false); }); +test('[FA-001..FA-007, FA-014, FA-015, FA-031] compiles closed profile, binding, and assignment contracts', () => { + const profile = { + schemaVersion: 1, + profileId: organizationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 3600, + outputLineageEnabled: true, + createdAt: '2026-08-01T01:30:00.125Z', + revision: 1, + }; + const binding = { + schemaVersion: 1, + bindingId: actorId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + deviceGrantId: projectId, + role: 'INPUT', + expectedCapabilityDigest: 'b'.repeat(64), + createdAt: '2026-08-01T01:30:00.125Z', + revision: 1, + }; + const assignment = { + schemaVersion: 1, + assignmentId: correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + profileId: organizationId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: projectId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: actorId, + inputBindingIds: [actorId], + outputBindingIds: [projectId], + dataModeConstraint: 'LOCAL', + effectiveDataModePolicyRef: correlationId, + idempotencyKey: 'assignment-1', + state: 'DRAFT', + revision: 1, + createdAt: '2026-08-01T01:30:00.125Z', + }; + assert.equal(validatorFor(ids.folderAutopilotProfile)(profile), true); + assert.equal(validatorFor(ids.autopilotFolderBinding)(binding), true); + assert.equal(validatorFor(ids.recipeAssignment)(assignment), true); + assert.equal(validatorFor(ids.autopilotFolderBinding)({ ...binding, path: 'C:\\secret' }), false); + assert.equal(validatorFor(ids.recipeAssignment)({ ...assignment, localHandle: 'secret' }), false); +}); + test('accepts closed correlation metadata and rejects undeclared context', () => { const validate = validatorFor(ids.correlationMetadata); diff --git a/packages/domain/package.json b/packages/domain/package.json index 0c7930ab..403fa15e 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -64,6 +64,10 @@ "types": "./src/data-mode/v1.ts", "import": "./dist/data-mode/v1.js" }, + "./folder-autopilot/v1": { + "types": "./src/folder-autopilot/v1.ts", + "import": "./dist/folder-autopilot/v1.js" + }, "./pkce/v1": { "types": "./src/pkce/v1.ts", "import": "./dist/pkce/v1.js" diff --git a/packages/domain/src/folder-autopilot/v1.ts b/packages/domain/src/folder-autopilot/v1.ts new file mode 100644 index 00000000..a7bce429 --- /dev/null +++ b/packages/domain/src/folder-autopilot/v1.ts @@ -0,0 +1,334 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { DataModeV1 } from '../data-mode/v1.js'; + +/** FA-001..FA-007, FA-014, FA-015 and FA-031: content-free automation records. */ +export const FOLDER_AUTOPILOT_SCHEMA_VERSION_V1 = 1 as const; + +export type AutopilotFolderBindingRoleV1 = 'INPUT' | 'OUTPUT'; +export type FolderAutopilotCollisionPolicyV1 = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type RecipeAssignmentStateV1 = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'RETIRED'; + +export interface FolderAutopilotProfileV1 { + readonly schemaVersion: typeof FOLDER_AUTOPILOT_SCHEMA_VERSION_V1; + readonly profileId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly version: number; + /** SHA-256 of the canonical typed profile payload. */ + readonly payloadHash: string; + readonly stabilizationDelayMs: number; + readonly maxFilesPerScan: number; + readonly collisionPolicy: FolderAutopilotCollisionPolicyV1; + readonly undoWindowSeconds: number; + readonly outputLineageEnabled: boolean; + readonly createdAt: StrictUtcTimestampV1; + readonly revision: 1; +} + +/** A binding is deliberately only an opaque DSO reference plus a digest. */ +export interface AutopilotFolderBindingV1 { + readonly schemaVersion: typeof FOLDER_AUTOPILOT_SCHEMA_VERSION_V1; + readonly bindingId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly deviceGrantId: StableIdentifierV1; + readonly role: AutopilotFolderBindingRoleV1; + readonly expectedCapabilityDigest: string; + readonly createdAt: StrictUtcTimestampV1; + readonly revision: 1; +} + +/** + * Assignment state is a feature projection. JRA remains authoritative for the + * recipe/version and DSO remains authoritative for grant status and revocation. + */ +export interface RecipeAssignmentV1 { + readonly schemaVersion: typeof FOLDER_AUTOPILOT_SCHEMA_VERSION_V1; + readonly assignmentId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly profileId: StableIdentifierV1; + readonly profileVersion: number; + readonly profileHash: string; + readonly jraRecipeVersionId: StableIdentifierV1; + readonly jraRecipeVersionHash: string; + readonly deviceId: StableIdentifierV1; + readonly inputBindingIds: readonly StableIdentifierV1[]; + readonly outputBindingIds: readonly StableIdentifierV1[]; + readonly dataModeConstraint?: DataModeV1; + readonly effectiveDataModePolicyRef?: StableIdentifierV1; + readonly idempotencyKey: string; + readonly state: RecipeAssignmentStateV1; + readonly revision: number; + readonly createdAt: StrictUtcTimestampV1; + readonly updatedAt: StrictUtcTimestampV1; +} + +export type FolderAutopilotErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_HASH' + | 'INVALID_TIMESTAMP' + | 'INVALID_VERSION' + | 'INVALID_REVISION' + | 'INVALID_ROLE' + | 'INVALID_COLLISION_POLICY' + | 'INVALID_SETTINGS' + | 'INVALID_BINDINGS' + | 'INVALID_DATA_MODE' + | 'INVALID_POLICY_REFERENCE' + | 'INVALID_IDEMPOTENCY_KEY' + | 'INVALID_STATE'; + +export type FolderAutopilotResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: FolderAutopilotErrorCodeV1 }; + +function rejected(code: FolderAutopilotErrorCodeV1): FolderAutopilotResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) ? input : undefined; +} + +function boundedInteger(input: unknown, minimum: number, maximum: number): number | undefined { + return typeof input === 'number' && + Number.isSafeInteger(input) && + input >= minimum && + input <= maximum + ? input + : undefined; +} + +function text(input: unknown, maximum: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maximum) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maximum ? normalized : undefined; +} + +function identifiers(input: unknown): readonly StableIdentifierV1[] | undefined { + if (!Array.isArray(input) || input.length < 1 || input.length > 32) return undefined; + const values = Array.from(input, stable); + if (values.some((value) => value === undefined)) return undefined; + const result = values as StableIdentifierV1[]; + if (new Set(result).size !== result.length) return undefined; + return Object.freeze([...result]); +} + +function dataMode(input: unknown): DataModeV1 | undefined { + return input === 'LOCAL' || input === 'HYBRID' || input === 'CLOUD' + ? (input as DataModeV1) + : undefined; +} + +function revision(input: unknown, defaultValue = 1): number | undefined { + return input === undefined ? defaultValue : boundedInteger(input, 1, Number.MAX_SAFE_INTEGER); +} + +function freezeScope(value: TenantScopeV1): TenantScopeV1 { + return Object.freeze({ ...value }); +} + +export function createFolderAutopilotProfileV1(input: { + readonly profileId: unknown; + readonly tenantScope: unknown; + readonly version: unknown; + readonly payloadHash: unknown; + readonly stabilizationDelayMs: unknown; + readonly maxFilesPerScan: unknown; + readonly collisionPolicy: unknown; + readonly undoWindowSeconds: unknown; + readonly outputLineageEnabled: unknown; + readonly createdAt: unknown; +}): FolderAutopilotResultV1 { + const profileId = stable(input.profileId); + const tenantScope = scope(input.tenantScope); + const version = boundedInteger(input.version, 1, 10_000); + const payloadHash = hash(input.payloadHash); + const stabilizationDelayMs = boundedInteger(input.stabilizationDelayMs, 0, 86_400_000); + const maxFilesPerScan = boundedInteger(input.maxFilesPerScan, 1, 100_000); + const undoWindowSeconds = boundedInteger(input.undoWindowSeconds, 0, 604_800); + const createdAt = timestamp(input.createdAt); + if (!profileId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (version === undefined) return rejected('INVALID_VERSION'); + if (!payloadHash) return rejected('INVALID_HASH'); + if ( + stabilizationDelayMs === undefined || + maxFilesPerScan === undefined || + undoWindowSeconds === undefined || + typeof input.outputLineageEnabled !== 'boolean' + ) + return rejected('INVALID_SETTINGS'); + if ( + input.collisionPolicy !== 'REVIEW' && + input.collisionPolicy !== 'SKIP' && + input.collisionPolicy !== 'UNIQUE_NAME' + ) + return rejected('INVALID_COLLISION_POLICY'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, + profileId, + tenantScope: freezeScope(tenantScope), + version, + payloadHash, + stabilizationDelayMs, + maxFilesPerScan, + collisionPolicy: input.collisionPolicy as FolderAutopilotCollisionPolicyV1, + undoWindowSeconds, + outputLineageEnabled: input.outputLineageEnabled, + createdAt, + revision: 1 as const, + }), + }); +} + +export function createAutopilotFolderBindingV1(input: { + readonly bindingId: unknown; + readonly tenantScope: unknown; + readonly deviceGrantId: unknown; + readonly role: unknown; + readonly expectedCapabilityDigest: unknown; + readonly createdAt: unknown; +}): FolderAutopilotResultV1 { + const bindingId = stable(input.bindingId); + const tenantScope = scope(input.tenantScope); + const deviceGrantId = stable(input.deviceGrantId); + const expectedCapabilityDigest = hash(input.expectedCapabilityDigest); + const createdAt = timestamp(input.createdAt); + if (!bindingId || !deviceGrantId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (input.role !== 'INPUT' && input.role !== 'OUTPUT') return rejected('INVALID_ROLE'); + if (!expectedCapabilityDigest) return rejected('INVALID_HASH'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, + bindingId, + tenantScope: freezeScope(tenantScope), + deviceGrantId, + role: input.role as AutopilotFolderBindingRoleV1, + expectedCapabilityDigest, + createdAt, + revision: 1 as const, + }), + }); +} + +export function createRecipeAssignmentV1(input: { + readonly assignmentId: unknown; + readonly tenantScope: unknown; + readonly profileId: unknown; + readonly profileVersion: unknown; + readonly profileHash: unknown; + readonly jraRecipeVersionId: unknown; + readonly jraRecipeVersionHash: unknown; + readonly deviceId: unknown; + readonly inputBindingIds: unknown; + readonly outputBindingIds: unknown; + readonly dataModeConstraint?: unknown; + readonly effectiveDataModePolicyRef?: unknown; + readonly idempotencyKey: unknown; + readonly state?: unknown; + readonly revision?: unknown; + readonly createdAt: unknown; + readonly updatedAt?: unknown; +}): FolderAutopilotResultV1 { + const assignmentId = stable(input.assignmentId); + const tenantScope = scope(input.tenantScope); + const profileId = stable(input.profileId); + const profileVersion = boundedInteger(input.profileVersion, 1, 10_000); + const profileHash = hash(input.profileHash); + const jraRecipeVersionId = stable(input.jraRecipeVersionId); + const jraRecipeVersionHash = hash(input.jraRecipeVersionHash); + const deviceId = stable(input.deviceId); + const inputBindingIds = identifiers(input.inputBindingIds); + const outputBindingIds = identifiers(input.outputBindingIds); + const constraint = + input.dataModeConstraint === undefined ? undefined : dataMode(input.dataModeConstraint); + const effectiveDataModePolicyRef = + input.effectiveDataModePolicyRef === undefined + ? undefined + : stable(input.effectiveDataModePolicyRef); + const idempotencyKey = text(input.idempotencyKey, 200); + const assignmentRevision = revision(input.revision); + const createdAt = timestamp(input.createdAt); + const updatedAt = input.updatedAt === undefined ? createdAt : timestamp(input.updatedAt); + if (!assignmentId || !profileId || !jraRecipeVersionId || !deviceId) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (profileVersion === undefined) return rejected('INVALID_VERSION'); + if (!profileHash || !jraRecipeVersionHash) return rejected('INVALID_HASH'); + if (!inputBindingIds || !outputBindingIds) return rejected('INVALID_BINDINGS'); + if (inputBindingIds.some((id) => outputBindingIds.includes(id))) + return rejected('INVALID_BINDINGS'); + if (input.dataModeConstraint !== undefined && !constraint) return rejected('INVALID_DATA_MODE'); + if (input.effectiveDataModePolicyRef !== undefined && !effectiveDataModePolicyRef) + return rejected('INVALID_POLICY_REFERENCE'); + if (constraint === undefined && effectiveDataModePolicyRef !== undefined) + return rejected('INVALID_POLICY_REFERENCE'); + if (!idempotencyKey) return rejected('INVALID_IDEMPOTENCY_KEY'); + if (assignmentRevision === undefined) return rejected('INVALID_REVISION'); + if (!createdAt || !updatedAt || Date.parse(updatedAt) < Date.parse(createdAt)) + return rejected('INVALID_TIMESTAMP'); + const state = input.state ?? 'DRAFT'; + if (state !== 'DRAFT' && state !== 'ACTIVE' && state !== 'PAUSED' && state !== 'RETIRED') + return rejected('INVALID_STATE'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, + assignmentId, + tenantScope: freezeScope(tenantScope), + profileId, + profileVersion, + profileHash, + jraRecipeVersionId, + jraRecipeVersionHash, + deviceId, + inputBindingIds, + outputBindingIds, + ...(constraint === undefined ? {} : { dataModeConstraint: constraint }), + ...(effectiveDataModePolicyRef === undefined ? {} : { effectiveDataModePolicyRef }), + idempotencyKey, + state: state as RecipeAssignmentStateV1, + revision: assignmentRevision, + createdAt, + updatedAt, + }), + }); +} + +/** Returns true only when a requested assignment mode is no broader than DSO's maximum. */ +export function isFolderAutopilotDataModeNarrowingV1( + maximum: DataModeV1, + requested: DataModeV1, +): boolean { + const rank = (mode: DataModeV1): number => (mode === 'LOCAL' ? 0 : mode === 'HYBRID' ? 1 : 2); + return rank(requested) <= rank(maximum); +} diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index c3d755f8..8f701d2e 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -34,6 +34,7 @@ export * from './device-authorization/v1.js'; export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; export * from './data-mode/v1.js'; +export * from './folder-autopilot/v1.js'; export * from './pkce/v1.js'; export * from './csrf/v1.js'; export * from './permissions/v1.js'; diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 007640de..f214a35f 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -20,6 +20,7 @@ const [ datasetExport, spreadsheetAudit, dataMode, + folderAutopilot, jobs, approval, executionAttempt, @@ -52,6 +53,7 @@ const [ import('@databreeze/domain/dataset-export/v1'), import('@databreeze/domain/spreadsheet-audit/v1'), import('@databreeze/domain/data-mode/v1'), + import('@databreeze/domain/folder-autopilot/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), import('@databreeze/domain/execution-attempt/v1'), @@ -87,6 +89,7 @@ assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(datasetExport.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(spreadsheetAudit.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); +assert.equal(folderAutopilot.FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); assert.equal(executionAttempt.EXECUTION_ATTEMPT_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/folder-autopilot-v1.test.mjs b/packages/domain/test/folder-autopilot-v1.test.mjs new file mode 100644 index 00000000..241dc850 --- /dev/null +++ b/packages/domain/test/folder-autopilot-v1.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAutopilotFolderBindingV1, + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, + isFolderAutopilotDataModeNarrowingV1, +} from '../dist/folder-autopilot/v1.js'; + +const ids = { + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', + profileId: '33333333-3333-4333-8333-333333333333', + inputBindingId: '44444444-4444-4444-8444-444444444444', + outputBindingId: '55555555-5555-4555-8555-555555555555', + deviceGrantId: '66666666-6666-4666-8666-666666666666', + deviceId: '77777777-7777-4777-8777-777777777777', + recipeId: '88888888-8888-4888-8888-888888888888', + policyVersionId: '99999999-9999-4999-8999-999999999999', +}; + +const scope = { + scopeType: 'workspace', + organizationId: ids.organizationId, + workspaceId: ids.workspaceId, +}; + +const base = { + tenantScope: scope, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +test('[FA-001..FA-007] profile and binding contracts contain no local path or DSO authority', () => { + const profile = createFolderAutopilotProfileV1({ + ...base, + profileId: ids.profileId, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + }); + assert.equal(profile.accepted, true); + if (!profile.accepted) return; + assert.equal(profile.value.version, 1); + assert.equal(Object.isFrozen(profile.value), true); + assert.equal('path' in profile.value, false); + assert.equal('status' in profile.value, false); + + const binding = createAutopilotFolderBindingV1({ + ...base, + bindingId: ids.inputBindingId, + deviceGrantId: ids.deviceGrantId, + role: 'INPUT', + expectedCapabilityDigest: 'b'.repeat(64), + }); + assert.equal(binding.accepted, true); + if (!binding.accepted) return; + assert.deepEqual(Object.keys(binding.value).sort(), [ + 'bindingId', + 'createdAt', + 'deviceGrantId', + 'expectedCapabilityDigest', + 'revision', + 'role', + 'schemaVersion', + 'tenantScope', + ]); + assert.equal('path' in binding.value, false); + assert.equal('revokedAt' in binding.value, false); +}); + +test('[FA-014..FA-015] assignment validates bindings, collision-safe settings, and immutable hashes', () => { + const assignment = createRecipeAssignmentV1({ + ...base, + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: [ids.outputBindingId], + dataModeConstraint: 'LOCAL', + effectiveDataModePolicyRef: ids.policyVersionId, + idempotencyKey: 'assignment-create-1', + }); + assert.equal(assignment.accepted, true); + if (!assignment.accepted) return; + assert.equal(assignment.value.state, 'DRAFT'); + assert.equal(assignment.value.revision, 1); + assert.equal(Object.isFrozen(assignment.value.inputBindingIds), true); + + const invalidCollision = createFolderAutopilotProfileV1({ + ...base, + profileId: ids.profileId, + version: 2, + payloadHash: 'd'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'OVERWRITE', + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + }); + assert.deepEqual(invalidCollision, { accepted: false, code: 'INVALID_COLLISION_POLICY' }); +}); + +test('[FA-031] assignment data mode constraints can only narrow the DSO maximum', () => { + assert.equal(isFolderAutopilotDataModeNarrowingV1('CLOUD', 'HYBRID'), true); + assert.equal(isFolderAutopilotDataModeNarrowingV1('HYBRID', 'LOCAL'), true); + assert.equal(isFolderAutopilotDataModeNarrowingV1('LOCAL', 'HYBRID'), false); +}); + +test('[FA-014..FA-015] sparse binding identifier arrays are rejected', () => { + const sparseInput = createRecipeAssignmentV1({ + ...base, + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: new Array(1), + outputBindingIds: [ids.outputBindingId], + idempotencyKey: 'sparse-input', + }); + assert.deepEqual(sparseInput, { accepted: false, code: 'INVALID_BINDINGS' }); + + const sparseOutput = createRecipeAssignmentV1({ + ...base, + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: new Array(1), + idempotencyKey: 'sparse-output', + }); + assert.deepEqual(sparseOutput, { accepted: false, code: 'INVALID_BINDINGS' }); +}); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 1417773d..6f507d09 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -24,6 +24,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-sync/v1', './device-capability/v1', './data-mode/v1', + './folder-autopilot/v1', './pkce/v1', './csrf/v1', './artifact/v1', @@ -80,6 +81,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); diff --git a/packages/test-fixtures/contracts/v1/manifest.json b/packages/test-fixtures/contracts/v1/manifest.json index 8cc90bd2..75f66cf6 100644 --- a/packages/test-fixtures/contracts/v1/manifest.json +++ b/packages/test-fixtures/contracts/v1/manifest.json @@ -19,6 +19,48 @@ "source": "payloads/actor-metadata/missing-identity.json", "covers": ["actor.identity"] }, + { + "id": "v1.autopilot-folder-binding.valid-input", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "expectedAcceptance": true, + "source": "payloads/autopilot-folder-binding/valid-input.json", + "covers": ["folder-autopilot.binding"] + }, + { + "id": "v1.autopilot-folder-binding.invalid-role", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "expectedAcceptance": false, + "source": "payloads/autopilot-folder-binding/invalid-role.json", + "covers": ["folder-autopilot.binding"] + }, + { + "id": "v1.folder-autopilot-profile.valid-review", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "expectedAcceptance": true, + "source": "payloads/folder-autopilot-profile/valid-review.json", + "covers": ["folder-autopilot.profile"] + }, + { + "id": "v1.folder-autopilot-profile.invalid-hash", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "expectedAcceptance": false, + "source": "payloads/folder-autopilot-profile/invalid-hash.json", + "covers": ["folder-autopilot.profile"] + }, + { + "id": "v1.recipe-assignment.valid-active", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "expectedAcceptance": true, + "source": "payloads/recipe-assignment/valid-active.json", + "covers": ["folder-autopilot.assignment"] + }, + { + "id": "v1.recipe-assignment.invalid-state", + "schemaId": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "expectedAcceptance": false, + "source": "payloads/recipe-assignment/invalid-state.json", + "covers": ["folder-autopilot.assignment"] + }, { "id": "v1.command-envelope.valid-idempotent", "schemaId": "https://schemas.databreeze.dev/contracts/v1/command-envelope", diff --git a/packages/test-fixtures/contracts/v1/payloads/autopilot-folder-binding/invalid-role.json b/packages/test-fixtures/contracts/v1/payloads/autopilot-folder-binding/invalid-role.json new file mode 100644 index 00000000..47f6ed54 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/autopilot-folder-binding/invalid-role.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "bindingId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc11", + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "deviceGrantId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc12", + "role": "SOURCE", + "expectedCapabilityDigest": "0000000000000000000000000000000000000000000000000000000000000000", + "createdAt": "2026-08-04T01:30:00.000Z", + "revision": 1 +} diff --git a/packages/test-fixtures/contracts/v1/payloads/autopilot-folder-binding/valid-input.json b/packages/test-fixtures/contracts/v1/payloads/autopilot-folder-binding/valid-input.json new file mode 100644 index 00000000..a3eabffa --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/autopilot-folder-binding/valid-input.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "bindingId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc11", + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "deviceGrantId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc12", + "role": "INPUT", + "expectedCapabilityDigest": "0000000000000000000000000000000000000000000000000000000000000000", + "createdAt": "2026-08-04T01:30:00.000Z", + "revision": 1 +} diff --git a/packages/test-fixtures/contracts/v1/payloads/folder-autopilot-profile/invalid-hash.json b/packages/test-fixtures/contracts/v1/payloads/folder-autopilot-profile/invalid-hash.json new file mode 100644 index 00000000..adf17c4c --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/folder-autopilot-profile/invalid-hash.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "profileId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc21", + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "version": 1, + "payloadHash": "not-a-sha256-digest", + "stabilizationDelayMs": 1500, + "maxFilesPerScan": 500, + "collisionPolicy": "REVIEW", + "undoWindowSeconds": 3600, + "outputLineageEnabled": true, + "createdAt": "2026-08-04T01:30:00.000Z", + "revision": 1 +} diff --git a/packages/test-fixtures/contracts/v1/payloads/folder-autopilot-profile/valid-review.json b/packages/test-fixtures/contracts/v1/payloads/folder-autopilot-profile/valid-review.json new file mode 100644 index 00000000..d9e7270a --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/folder-autopilot-profile/valid-review.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "profileId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc21", + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "version": 1, + "payloadHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "stabilizationDelayMs": 1500, + "maxFilesPerScan": 500, + "collisionPolicy": "REVIEW", + "undoWindowSeconds": 3600, + "outputLineageEnabled": true, + "createdAt": "2026-08-04T01:30:00.000Z", + "revision": 1 +} diff --git a/packages/test-fixtures/contracts/v1/payloads/recipe-assignment/invalid-state.json b/packages/test-fixtures/contracts/v1/payloads/recipe-assignment/invalid-state.json new file mode 100644 index 00000000..4a0b0d96 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/recipe-assignment/invalid-state.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "assignmentId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc41", + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "profileId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc21", + "profileVersion": 1, + "profileHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "jraRecipeVersionId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc31", + "jraRecipeVersionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "deviceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc12", + "inputBindingIds": ["018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc11"], + "outputBindingIds": ["018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc13"], + "idempotencyKey": "fa-assignment-2026-08-04-0001", + "state": "RUNNING", + "revision": 1, + "createdAt": "2026-08-04T01:30:00.000Z" +} diff --git a/packages/test-fixtures/contracts/v1/payloads/recipe-assignment/valid-active.json b/packages/test-fixtures/contracts/v1/payloads/recipe-assignment/valid-active.json new file mode 100644 index 00000000..efd76057 --- /dev/null +++ b/packages/test-fixtures/contracts/v1/payloads/recipe-assignment/valid-active.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "assignmentId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc41", + "tenantScope": { + "scopeType": "workspace", + "organizationId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc01", + "workspaceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc02" + }, + "profileId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc21", + "profileVersion": 1, + "profileHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "jraRecipeVersionId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc31", + "jraRecipeVersionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "deviceId": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc12", + "inputBindingIds": ["018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc11"], + "outputBindingIds": ["018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc13"], + "dataModeConstraint": "HYBRID", + "effectiveDataModePolicyRef": "018f47f2-5ee1-7d8d-a4c2-8f0e19e4cc51", + "idempotencyKey": "fa-assignment-2026-08-04-0001", + "state": "ACTIVE", + "revision": 1, + "createdAt": "2026-08-04T01:30:00.000Z" +} diff --git a/packages/test-fixtures/test/contracts-v1.test.mjs b/packages/test-fixtures/test/contracts-v1.test.mjs index b83e1081..8b8ed689 100644 --- a/packages/test-fixtures/test/contracts-v1.test.mjs +++ b/packages/test-fixtures/test/contracts-v1.test.mjs @@ -16,7 +16,7 @@ test('publishes a deterministic synthetic v1 contract fixture registry', () => { assert.equal(fixtureManifest.fixtureVersion, 1); assert.equal(fixtureManifest.contractVersion, 1); assert.equal(fixtureManifest.synthetic, true); - assert.equal(fixtureManifest.cases.length, 28); + assert.equal(fixtureManifest.cases.length, 34); const caseIds = fixtureManifest.cases.map((fixtureCase) => fixtureCase.id); assert.equal(new Set(caseIds).size, caseIds.length, 'fixture case IDs must be unique'); diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 5f2912db..2165622f 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -10338,6 +10338,2112 @@ "summary": "Read a tenant-scoped Spreadsheet Auditor run handle", "tags": ["spreadsheet-audit-runs"] } + }, + "/v1/autopilot-dashboard": { + "get": { + "operationId": "FolderAutopilotController.dashboard", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read content-free Folder Autopilot dashboard projections", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-profiles": { + "post": { + "operationId": "FolderAutopilotController.createProfile", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateFolderAutopilotProfileDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Register an immutable, content-free Folder Autopilot profile", + "tags": ["folder-autopilot"] + }, + "get": { + "operationId": "FolderAutopilotController.listProfiles", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List Folder Autopilot profile versions visible to the tenant", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-profiles/{profileId}": { + "get": { + "operationId": "FolderAutopilotController.findProfile", + "parameters": [ + { "name": "profileId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "version", + "required": false, + "in": "query", + "schema": { "minimum": 1, "maximum": 10000, "type": "integer" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read an exact immutable Folder Autopilot profile version", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-folder-bindings": { + "post": { + "operationId": "FolderAutopilotController.createBinding", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateAutopilotFolderBindingDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Register an opaque DSO-backed Folder Autopilot binding", + "tags": ["folder-autopilot"] + }, + "get": { + "operationId": "FolderAutopilotController.listBindings", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List opaque Folder Autopilot bindings visible to the tenant", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-folder-bindings/{bindingId}": { + "get": { + "operationId": "FolderAutopilotController.findBinding", + "parameters": [ + { "name": "bindingId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read an opaque Folder Autopilot binding", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-assignments": { + "post": { + "operationId": "FolderAutopilotController.createAssignment", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateRecipeAssignmentDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Create a tenant-scoped Folder Autopilot assignment projection", + "tags": ["folder-autopilot"] + }, + "get": { + "operationId": "FolderAutopilotController.listAssignments", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "List Folder Autopilot assignments visible to the tenant", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-assignments/{assignmentId}": { + "get": { + "operationId": "FolderAutopilotController.findAssignment", + "parameters": [ + { + "name": "assignmentId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Read a tenant-scoped Folder Autopilot assignment", + "tags": ["folder-autopilot"] + }, + "patch": { + "operationId": "FolderAutopilotController.updateAssignment", + "parameters": [ + { + "name": "assignmentId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateRecipeAssignmentDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Advance an assignment projection with optimistic concurrency", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-assignments/{assignmentId}/pause": { + "post": { + "operationId": "FolderAutopilotController.pauseAssignment", + "parameters": [ + { + "name": "assignmentId", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PauseRecipeAssignmentDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Pause an assignment projection with optimistic concurrency", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-approvals/{approvalId}/decision": { + "post": { + "operationId": "FolderAutopilotController.decideApproval", + "parameters": [ + { "name": "approvalId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotApprovalDecisionDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Submit a decision through the JRA-owned approval facade", + "tags": ["folder-autopilot"] + } + }, + "/v1/autopilot-executions/{executionId}/undo": { + "post": { + "operationId": "FolderAutopilotController.requestUndo", + "parameters": [ + { "name": "executionId", "required": true, "in": "path", "schema": { "type": "string" } }, + { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "description": "Optional single bounded UUID; invalid or repeated values fail closed.", + "schema": { "format": "uuid", "maxLength": 128, "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotUndoRequestDto" } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "410": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "500": { + "description": "An unexpected failure was safely mapped.", + "content": { + "application/problem+json": { + "schema": { "$ref": "#/components/schemas/ProblemDetails" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderAutopilotRejectedResponseDto" } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + } + }, + "security": [{ "bearer": [] }], + "summary": "Request undo through the JRA/desktop effect facade", + "tags": ["folder-autopilot"] + } } }, "info": { @@ -11914,6 +14020,181 @@ }, "required": ["artifactVersionId", "processorVersion"] }, + "FolderAutopilotRejectedResponseDto": { + "type": "object", + "properties": { + "accepted": { "type": "boolean", "enum": [false], "example": false }, + "code": { + "type": "string", + "enum": [ + "INVALID_IDENTIFIER", + "INVALID_SCOPE", + "INVALID_HASH", + "INVALID_TIMESTAMP", + "INVALID_VERSION", + "INVALID_REVISION", + "INVALID_ROLE", + "INVALID_COLLISION_POLICY", + "INVALID_SETTINGS", + "INVALID_BINDINGS", + "INVALID_DATA_MODE", + "INVALID_POLICY_REFERENCE", + "INVALID_IDEMPOTENCY_KEY", + "INVALID_STATE", + "FA_PROFILE_NOT_FOUND", + "FA_BINDING_NOT_FOUND", + "FA_ASSIGNMENT_NOT_FOUND", + "FA_SCOPE_NARROWING_REQUIRED", + "FA_IMMUTABLE_PROFILE", + "FA_IMMUTABLE_BINDING", + "FA_IMMUTABLE_ASSIGNMENT", + "FA_PROFILE_HASH_MISMATCH", + "FA_BINDING_ROLE_MISMATCH", + "FA_ASSIGNMENT_REVISION_CONFLICT", + "FA_PERSISTENCE_UNAVAILABLE", + "DATA_MODE_BROADENS_WORKSPACE", + "DATA_MODE_POLICY_UNAVAILABLE" + ] + } + }, + "required": ["accepted", "code"] + }, + "CreateFolderAutopilotProfileDto": { + "type": "object", + "properties": { + "profileId": { "type": "string", "format": "uuid" }, + "version": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "payloadHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "stabilizationDelayMs": { "type": "integer", "minimum": 0, "maximum": 86400000 }, + "maxFilesPerScan": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "collisionPolicy": { "type": "string", "enum": ["REVIEW", "SKIP", "UNIQUE_NAME"] }, + "undoWindowSeconds": { "type": "integer", "minimum": 0, "maximum": 604800 }, + "outputLineageEnabled": { "type": "boolean" }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } + }, + "required": [ + "profileId", + "version", + "payloadHash", + "stabilizationDelayMs", + "maxFilesPerScan", + "collisionPolicy", + "undoWindowSeconds", + "outputLineageEnabled", + "createdAt" + ] + }, + "CreateAutopilotFolderBindingDto": { + "type": "object", + "properties": { + "bindingId": { "type": "string", "format": "uuid" }, + "deviceGrantId": { + "type": "string", + "format": "uuid", + "description": "Opaque DSO DeviceGrant identifier." + }, + "role": { "type": "string", "enum": ["INPUT", "OUTPUT"] }, + "expectedCapabilityDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } + }, + "required": ["bindingId", "deviceGrantId", "role", "expectedCapabilityDigest", "createdAt"] + }, + "CreateRecipeAssignmentDto": { + "type": "object", + "properties": { + "assignmentId": { "type": "string", "format": "uuid" }, + "profileId": { "type": "string", "format": "uuid" }, + "profileVersion": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "profileHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "jraRecipeVersionId": { + "type": "string", + "format": "uuid", + "description": "Opaque JRA RecipeVersion identifier." + }, + "jraRecipeVersionHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "deviceId": { "type": "string", "format": "uuid" }, + "inputBindingIds": { + "minItems": 1, + "maxItems": 32, + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "outputBindingIds": { + "minItems": 1, + "maxItems": 32, + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "dataModeConstraint": { "type": "string", "enum": ["LOCAL", "HYBRID", "CLOUD"] }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + } + }, + "required": [ + "assignmentId", + "profileId", + "profileVersion", + "profileHash", + "jraRecipeVersionId", + "jraRecipeVersionHash", + "deviceId", + "inputBindingIds", + "outputBindingIds", + "createdAt" + ] + }, + "UpdateRecipeAssignmentDto": { + "type": "object", + "properties": { + "expectedRevision": { "type": "integer", "minimum": 1 }, + "state": { "type": "string", "enum": ["DRAFT", "ACTIVE", "PAUSED", "RETIRED"] } + }, + "required": ["expectedRevision", "state"] + }, + "PauseRecipeAssignmentDto": { + "type": "object", + "properties": { "expectedRevision": { "type": "integer", "minimum": 1 } }, + "required": ["expectedRevision"] + }, + "FolderAutopilotApprovalDecisionDto": { + "type": "object", + "properties": { + "jraApprovalRequestId": { + "type": "string", + "format": "uuid", + "description": "JRA-owned approval request identifier." + }, + "subjectHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "planHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "decision": { "type": "string", "enum": ["APPROVE", "REJECT"] }, + "decisionReason": { "type": "string", "minLength": 1, "maxLength": 500 } + }, + "required": [ + "jraApprovalRequestId", + "subjectHash", + "planHash", + "decision", + "decisionReason" + ] + }, + "FolderAutopilotUndoRequestDto": { + "type": "object", + "properties": { + "expectedRevision": { "type": "integer", "minimum": 1 }, + "planHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "required": ["expectedRevision", "planHash"] + }, "Identifier": { "title": "Stable UUID Identifier", "description": "An opaque stable UUID identifier.", diff --git a/services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql b/services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql new file mode 100644 index 00000000..aaa77d2a --- /dev/null +++ b/services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql @@ -0,0 +1,73 @@ +-- FA-001..FA-007: Folder Autopilot stores typed settings and opaque DSO/JRA references only. +CREATE SCHEMA IF NOT EXISTS "fa"; + +INSERT INTO "platform"."schema_registry" ("schema_name", "owner_module") +VALUES ('fa', 'folder-autopilot') +ON CONFLICT ("schema_name") DO NOTHING; + +CREATE TABLE "fa"."folder_autopilot_profiles" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "version" INTEGER NOT NULL, + "payload_hash" CHAR(64) NOT NULL, + "stabilization_delay_ms" INTEGER NOT NULL, + "max_files_per_scan" INTEGER NOT NULL, + "collision_policy" VARCHAR(16) NOT NULL, + "undo_window_seconds" INTEGER NOT NULL, + "output_lineage_enabled" BOOLEAN NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "folder_autopilot_profiles_pkey" PRIMARY KEY ("id", "version") +); +CREATE INDEX "folder_autopilot_profiles_scope_idx" + ON "fa"."folder_autopilot_profiles" ("organization_id", "workspace_id", "project_id", "id", "version"); + +CREATE TABLE "fa"."autopilot_folder_bindings" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "device_grant_id" UUID NOT NULL, + "role" VARCHAR(8) NOT NULL, + "expected_capability_digest" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "autopilot_folder_bindings_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "autopilot_folder_bindings_scope_role_idx" + ON "fa"."autopilot_folder_bindings" ("organization_id", "workspace_id", "project_id", "role"); +CREATE INDEX "autopilot_folder_bindings_device_grant_idx" + ON "fa"."autopilot_folder_bindings" ("device_grant_id"); + +CREATE TABLE "fa"."recipe_assignments" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "profile_id" UUID NOT NULL, + "profile_version" INTEGER NOT NULL, + "profile_hash" CHAR(64) NOT NULL, + "jra_recipe_version_id" UUID NOT NULL, + "jra_recipe_version_hash" CHAR(64) NOT NULL, + "device_id" UUID NOT NULL, + "input_binding_ids" JSONB NOT NULL, + "output_binding_ids" JSONB NOT NULL, + "data_mode_constraint" VARCHAR(16), + "effective_data_mode_policy_ref" UUID, + "idempotency_key" VARCHAR(200) NOT NULL, + "state" VARCHAR(16) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "recipe_assignments_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "recipe_assignments_scope_idempotency_key" + ON "fa"."recipe_assignments" ("organization_id", "workspace_id", "project_id", "idempotency_key"); +CREATE INDEX "recipe_assignments_scope_state_idx" + ON "fa"."recipe_assignments" ("organization_id", "workspace_id", "project_id", "state"); +CREATE INDEX "recipe_assignments_device_state_idx" + ON "fa"."recipe_assignments" ("device_id", "state"); diff --git a/services/api/prisma/migrations/20260804120000_fa_assignment_scope_key/migration.sql b/services/api/prisma/migrations/20260804120000_fa_assignment_scope_key/migration.sql new file mode 100644 index 00000000..93b9bbbc --- /dev/null +++ b/services/api/prisma/migrations/20260804120000_fa_assignment_scope_key/migration.sql @@ -0,0 +1,30 @@ +-- FA-015: scope idempotency must remain unique when nullable ancestry columns are NULL. +ALTER TABLE "fa"."recipe_assignments" + ADD COLUMN "scope_key" VARCHAR(200); + +UPDATE "fa"."recipe_assignments" +SET "scope_key" = CASE + WHEN "scope_type" = 'organization' + THEN concat('organization:', "organization_id"::text) + WHEN "scope_type" = 'workspace' + THEN concat('workspace:', "organization_id"::text, ':', "workspace_id"::text) + WHEN "scope_type" = 'project' + THEN concat('project:', "organization_id"::text, ':', "workspace_id"::text, ':', "project_id"::text) + ELSE NULL +END; + +ALTER TABLE "fa"."recipe_assignments" + ALTER COLUMN "scope_key" SET NOT NULL; + +ALTER TABLE "fa"."recipe_assignments" + ADD COLUMN "updated_at" TIMESTAMPTZ(6); + +UPDATE "fa"."recipe_assignments" +SET "updated_at" = "created_at"; + +ALTER TABLE "fa"."recipe_assignments" + ALTER COLUMN "updated_at" SET NOT NULL; + +DROP INDEX "fa"."recipe_assignments_scope_idempotency_key"; +CREATE UNIQUE INDEX "recipe_assignments_scope_idempotency_key" + ON "fa"."recipe_assignments" ("scope_key", "idempotency_key"); diff --git a/services/api/prisma/schema/fa.prisma b/services/api/prisma/schema/fa.prisma new file mode 100644 index 00000000..5a8306c3 --- /dev/null +++ b/services/api/prisma/schema/fa.prisma @@ -0,0 +1,72 @@ +/// FA-001..FA-007: Folder Autopilot owns only typed profiles and opaque references. +model FolderAutopilotProfileRecord { + id String @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + version Int + payloadHash String @map("payload_hash") @db.Char(64) + stabilizationDelayMs Int @map("stabilization_delay_ms") + maxFilesPerScan Int @map("max_files_per_scan") + collisionPolicy String @map("collision_policy") @db.VarChar(16) + undoWindowSeconds Int @map("undo_window_seconds") + outputLineageEnabled Boolean @map("output_lineage_enabled") + createdAt DateTime @map("created_at") @db.Timestamptz(6) + revision Int @default(1) + + @@id([id, version], map: "folder_autopilot_profiles_pkey") + @@index([organizationId, workspaceId, projectId, id, version], map: "folder_autopilot_profiles_scope_idx") + @@map("folder_autopilot_profiles") + @@schema("fa") +} + +/// FA-001..FA-003: only DSO grant identifiers and expected digests are persisted. +model AutopilotFolderBindingRecord { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + deviceGrantId String @map("device_grant_id") @db.Uuid + role String @db.VarChar(8) + expectedCapabilityDigest String @map("expected_capability_digest") @db.Char(64) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([organizationId, workspaceId, projectId, role], map: "autopilot_folder_bindings_scope_role_idx") + @@index([deviceGrantId], map: "autopilot_folder_bindings_device_grant_idx") + @@map("autopilot_folder_bindings") + @@schema("fa") +} + +/// FA-005..FA-007, FA-014, FA-015, FA-031: references JRA/DSO authority by ID/hash. +model RecipeAssignmentRecord { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + scopeKey String @map("scope_key") @db.VarChar(200) + profileId String @map("profile_id") @db.Uuid + profileVersion Int @map("profile_version") + profileHash String @map("profile_hash") @db.Char(64) + jraRecipeVersionId String @map("jra_recipe_version_id") @db.Uuid + jraRecipeVersionHash String @map("jra_recipe_version_hash") @db.Char(64) + deviceId String @map("device_id") @db.Uuid + inputBindingIds Json @map("input_binding_ids") + outputBindingIds Json @map("output_binding_ids") + dataModeConstraint String? @map("data_mode_constraint") @db.VarChar(16) + effectiveDataModePolicyRef String? @map("effective_data_mode_policy_ref") @db.Uuid + idempotencyKey String @map("idempotency_key") @db.VarChar(200) + state String @db.VarChar(16) + revision Int @default(1) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @map("updated_at") @db.Timestamptz(6) + + @@unique([scopeKey, idempotencyKey], map: "recipe_assignments_scope_idempotency_key") + @@index([organizationId, workspaceId, projectId, state], map: "recipe_assignments_scope_state_idx") + @@index([deviceId, state], map: "recipe_assignments_device_state_idx") + @@map("recipe_assignments") + @@schema("fa") +} diff --git a/services/api/prisma/schema/platform.prisma b/services/api/prisma/schema/platform.prisma index c4f3d74f..8fdfb4de 100644 --- a/services/api/prisma/schema/platform.prisma +++ b/services/api/prisma/schema/platform.prisma @@ -7,7 +7,7 @@ generator client { datasource db { provider = "postgresql" - schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso", "sa"] + schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso", "sa", "fa"] } /// Platform-owned registry documenting database-schema ownership boundaries. diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index b36c5134..05c12445 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -8,6 +8,7 @@ import { DsoModule, type DsoModuleOptions } from './features/dso/dso.module.js'; import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; import { SaModule, type SaModuleOptions } from './features/sa/sa.module.js'; +import { FaModule, type FaModuleOptions } from './features/fa/fa.module.js'; import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js'; @@ -18,7 +19,8 @@ export type AppModuleOptions = SystemModuleOptions & DsoModuleOptions & AudModuleOptions & BuaModuleOptions & - SaModuleOptions; + SaModuleOptions & + FaModuleOptions; @Module({}) export class AppModule { @@ -57,6 +59,7 @@ export class AppModule { AudModule.register(composedOptions), BuaModule.register(composedOptions), SaModule.register(composedOptions), + FaModule.register(composedOptions), ], }; } diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 630aa3fd..9fa650eb 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -12,6 +12,7 @@ import type { DsoModuleOptions } from './features/dso/dso.module.js'; import type { AudModuleOptions } from './features/aud/aud.module.js'; import type { BuaModuleOptions } from './features/bua/bua.module.js'; import type { SaModuleOptions } from './features/sa/sa.module.js'; +import type { FaModuleOptions } from './features/fa/fa.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; @@ -34,7 +35,8 @@ export interface ApiApplicationOptions DsoModuleOptions, AudModuleOptions, BuaModuleOptions, - SaModuleOptions { + SaModuleOptions, + FaModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; readonly requestContext?: RequestContextOptions; diff --git a/services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts b/services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts new file mode 100644 index 00000000..325800c8 --- /dev/null +++ b/services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts @@ -0,0 +1,300 @@ +import { + parseStrictUtcTimestampV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { + AutopilotFolderBindingV1, + FolderAutopilotProfileV1, + RecipeAssignmentStateV1, + RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + FolderAutopilotRepositoryPortV1, + FolderAutopilotTransactionPortV1, +} from '../application/folder-autopilot-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function cloneProfile(profile: FolderAutopilotProfileV1): FolderAutopilotProfileV1 { + return Object.freeze({ + ...profile, + tenantScope: Object.freeze({ ...profile.tenantScope }), + }); +} + +function cloneBinding(binding: AutopilotFolderBindingV1): AutopilotFolderBindingV1 { + return Object.freeze({ + ...binding, + tenantScope: Object.freeze({ ...binding.tenantScope }), + }); +} + +function cloneAssignment(assignment: RecipeAssignmentV1): RecipeAssignmentV1 { + return Object.freeze({ + ...assignment, + tenantScope: Object.freeze({ ...assignment.tenantScope }), + inputBindingIds: Object.freeze([...assignment.inputBindingIds]), + outputBindingIds: Object.freeze([...assignment.outputBindingIds]), + }); +} + +function profileKey(profile: Pick): string { + return `${profile.profileId}:${profile.version}`; +} + +function nowTimestamp() { + const parsed = parseStrictUtcTimestampV1(new Date().toISOString()); + if (!parsed.accepted) throw new Error('FA_PERSISTENCE_UNAVAILABLE'); + return parsed.value; +} + +/** Test/local adapter. Durable deployments use the Prisma adapter with the same port. */ +export class InMemoryFolderAutopilotRepositoryAdapter implements FolderAutopilotRepositoryPortV1 { + private profiles = new Map(); + private bindings = new Map(); + private assignments = new Map(); + private transactionTail: Promise = Promise.resolve(); + + private async saveProfileUnlocked( + context: IamTenantContextV1, + profile: FolderAutopilotProfileV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const key = profileKey(profile); + const existing = this.profiles.get(key); + if (existing && JSON.stringify(existing) !== JSON.stringify(profile)) + throw new Error('FA_IMMUTABLE_PROFILE'); + this.profiles.set(key, cloneProfile(profile)); + } + + private async findProfileUnlocked( + context: IamTenantContextV1, + profileId: StableIdentifierV1, + version?: number, + ): Promise { + await Promise.resolve(); + const values = [...this.profiles.values()].filter( + (profile) => + profile.profileId === profileId && + (version === undefined || profile.version === version) && + visible(context.tenantScope, profile.tenantScope), + ); + values.sort((left, right) => right.version - left.version); + return values[0] ? cloneProfile(values[0]) : undefined; + } + + private async listProfilesUnlocked( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.profiles.values()] + .filter((profile) => visible(context.tenantScope, profile.tenantScope)) + .sort((left, right) => + `${left.profileId}:${left.version}`.localeCompare(`${right.profileId}:${right.version}`), + ) + .map(cloneProfile); + } + + private async saveBindingUnlocked( + context: IamTenantContextV1, + binding: AutopilotFolderBindingV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, binding.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = this.bindings.get(binding.bindingId); + if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) + throw new Error('FA_IMMUTABLE_BINDING'); + this.bindings.set(binding.bindingId, cloneBinding(binding)); + } + + private async findBindingUnlocked( + context: IamTenantContextV1, + bindingId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const binding = this.bindings.get(bindingId); + return binding && visible(context.tenantScope, binding.tenantScope) + ? cloneBinding(binding) + : undefined; + } + + private async listBindingsUnlocked( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.bindings.values()] + .filter((binding) => visible(context.tenantScope, binding.tenantScope)) + .sort((left, right) => left.bindingId.localeCompare(right.bindingId)) + .map(cloneBinding); + } + + private async saveAssignmentUnlocked( + context: IamTenantContextV1, + assignment: RecipeAssignmentV1, + ): Promise { + await Promise.resolve(); + if (!tenantScopeContainsV1(context.tenantScope, assignment.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = this.assignments.get(assignment.assignmentId); + if (existing && JSON.stringify(existing) !== JSON.stringify(assignment)) + throw new Error('FA_IMMUTABLE_ASSIGNMENT'); + this.assignments.set(assignment.assignmentId, cloneAssignment(assignment)); + } + + private async findAssignmentUnlocked( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + ): Promise { + await Promise.resolve(); + const assignment = this.assignments.get(assignmentId); + return assignment && visible(context.tenantScope, assignment.tenantScope) + ? cloneAssignment(assignment) + : undefined; + } + + private async listAssignmentsUnlocked( + context: IamTenantContextV1, + ): Promise { + await Promise.resolve(); + return [...this.assignments.values()] + .filter((assignment) => visible(context.tenantScope, assignment.tenantScope)) + .sort((left, right) => left.assignmentId.localeCompare(right.assignmentId)) + .map(cloneAssignment); + } + + private async updateAssignmentStateUnlocked( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ): Promise { + await Promise.resolve(); + const existing = this.assignments.get(assignmentId); + if (!existing || !visible(context.tenantScope, existing.tenantScope)) + throw new Error('FA_ASSIGNMENT_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, existing.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + if (existing.revision !== expectedRevision) throw new Error('FA_ASSIGNMENT_REVISION_CONFLICT'); + const next = cloneAssignment({ + ...existing, + state, + revision: existing.revision + 1, + updatedAt: nowTimestamp(), + }); + this.assignments.set(assignmentId, next); + return cloneAssignment(next); + } + + public async saveProfile( + context: IamTenantContextV1, + profile: FolderAutopilotProfileV1, + ): Promise { + await this.withTransaction(context, (transaction) => transaction.saveProfile(context, profile)); + } + + public findProfile(context: IamTenantContextV1, profileId: StableIdentifierV1, version?: number) { + return this.withTransaction(context, (transaction) => + transaction.findProfile(context, profileId, version), + ); + } + + public listProfiles(context: IamTenantContextV1) { + return this.withTransaction(context, (transaction) => transaction.listProfiles(context)); + } + + public async saveBinding( + context: IamTenantContextV1, + binding: AutopilotFolderBindingV1, + ): Promise { + await this.withTransaction(context, (transaction) => transaction.saveBinding(context, binding)); + } + + public findBinding(context: IamTenantContextV1, bindingId: StableIdentifierV1) { + return this.withTransaction(context, (transaction) => + transaction.findBinding(context, bindingId), + ); + } + + public listBindings(context: IamTenantContextV1) { + return this.withTransaction(context, (transaction) => transaction.listBindings(context)); + } + + public async saveAssignment( + context: IamTenantContextV1, + assignment: RecipeAssignmentV1, + ): Promise { + await this.withTransaction(context, (transaction) => + transaction.saveAssignment(context, assignment), + ); + } + + public findAssignment(context: IamTenantContextV1, assignmentId: StableIdentifierV1) { + return this.withTransaction(context, (transaction) => + transaction.findAssignment(context, assignmentId), + ); + } + + public listAssignments(context: IamTenantContextV1) { + return this.withTransaction(context, (transaction) => transaction.listAssignments(context)); + } + + public updateAssignmentState( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ) { + return this.withTransaction(context, (transaction) => + transaction.updateAssignmentState(context, assignmentId, expectedRevision, state), + ); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: FolderAutopilotTransactionPortV1) => Promise, + ): Promise { + // This queue is intentionally non-reentrant. Transaction callbacks must + // use the supplied transaction methods rather than public repository reads. + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = { + profiles: new Map(this.profiles), + bindings: new Map(this.bindings), + assignments: new Map(this.assignments), + }; + try { + return await work({ + saveProfile: this.saveProfileUnlocked.bind(this), + findProfile: this.findProfileUnlocked.bind(this), + listProfiles: this.listProfilesUnlocked.bind(this), + saveBinding: this.saveBindingUnlocked.bind(this), + findBinding: this.findBindingUnlocked.bind(this), + listBindings: this.listBindingsUnlocked.bind(this), + saveAssignment: this.saveAssignmentUnlocked.bind(this), + findAssignment: this.findAssignmentUnlocked.bind(this), + listAssignments: this.listAssignmentsUnlocked.bind(this), + updateAssignmentState: this.updateAssignmentStateUnlocked.bind(this), + }); + } catch (error) { + this.profiles = before.profiles; + this.bindings = before.bindings; + this.assignments = before.assignments; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts b/services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts new file mode 100644 index 00000000..d1ada585 --- /dev/null +++ b/services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts @@ -0,0 +1,535 @@ +import { + createAutopilotFolderBindingV1, + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, + type AutopilotFolderBindingV1, + type FolderAutopilotProfileV1, + type RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + tenantScopeKeyV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + FolderAutopilotRepositoryPortV1, + FolderAutopilotTransactionPortV1, +} from '../application/folder-autopilot-repository.port.js'; + +export interface FolderAutopilotProfileDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly version: number; + readonly payloadHash: string; + readonly stabilizationDelayMs: number; + readonly maxFilesPerScan: number; + readonly collisionPolicy: string; + readonly undoWindowSeconds: number; + readonly outputLineageEnabled: boolean; + readonly createdAt: Date; + readonly revision: number; +} + +export interface FolderAutopilotBindingDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly deviceGrantId: string; + readonly role: string; + readonly expectedCapabilityDigest: string; + readonly createdAt: Date; + readonly revision: number; +} + +export interface FolderAutopilotAssignmentDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly scopeKey: string; + readonly profileId: string; + readonly profileVersion: number; + readonly profileHash: string; + readonly jraRecipeVersionId: string; + readonly jraRecipeVersionHash: string; + readonly deviceId: string; + readonly inputBindingIds: unknown; + readonly outputBindingIds: unknown; + readonly dataModeConstraint: string | null; + readonly effectiveDataModePolicyRef: string | null; + readonly idempotencyKey: string; + readonly state: string; + readonly revision: number; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface FolderAutopilotDatabaseClientV1 { + readonly folderAutopilotProfileRecord: { + create(input: { + readonly data: FolderAutopilotProfileDatabaseRowV1; + }): Promise; + createMany(input: { + readonly data: FolderAutopilotProfileDatabaseRowV1; + readonly skipDuplicates?: boolean; + }): Promise<{ readonly count: number }>; + findFirst(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }): Promise; + }; + readonly autopilotFolderBindingRecord: { + create(input: { + readonly data: FolderAutopilotBindingDatabaseRowV1; + }): Promise; + createMany(input: { + readonly data: FolderAutopilotBindingDatabaseRowV1; + readonly skipDuplicates?: boolean; + }): Promise<{ readonly count: number }>; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }): Promise; + }; + readonly recipeAssignmentRecord: { + create(input: { + readonly data: FolderAutopilotAssignmentDatabaseRowV1; + }): Promise; + createMany(input: { + readonly data: FolderAutopilotAssignmentDatabaseRowV1; + readonly skipDuplicates?: boolean; + }): Promise<{ readonly count: number }>; + findUnique(input: { + readonly where: { readonly id: string }; + }): Promise; + findFirst(input: { + readonly where: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Readonly>; + }): Promise; + updateMany(input: { + readonly where: Readonly>; + readonly data: Readonly>; + }): Promise<{ readonly count: number }>; + }; + $transaction( + work: (transaction: FolderAutopilotDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function scopeKey(scope: TenantScopeV1): string { + return tenantScopeKeyV1(scope); +} + +function rowScope(row: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +}): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function profileFromRow(row: FolderAutopilotProfileDatabaseRowV1): FolderAutopilotProfileV1 { + const parsed = createFolderAutopilotProfileV1({ + profileId: row.id, + tenantScope: rowScope(row), + version: row.version, + payloadHash: row.payloadHash, + stabilizationDelayMs: row.stabilizationDelayMs, + maxFilesPerScan: row.maxFilesPerScan, + collisionPolicy: row.collisionPolicy, + undoWindowSeconds: row.undoWindowSeconds, + outputLineageEnabled: row.outputLineageEnabled, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_PROFILE_INVALID'); + return parsed.value; +} + +function bindingFromRow(row: FolderAutopilotBindingDatabaseRowV1): AutopilotFolderBindingV1 { + const parsed = createAutopilotFolderBindingV1({ + bindingId: row.id, + tenantScope: rowScope(row), + deviceGrantId: row.deviceGrantId, + role: row.role, + expectedCapabilityDigest: row.expectedCapabilityDigest, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_BINDING_INVALID'); + return parsed.value; +} + +function assignmentFromRow(row: FolderAutopilotAssignmentDatabaseRowV1): RecipeAssignmentV1 { + const parsed = createRecipeAssignmentV1({ + assignmentId: row.id, + tenantScope: rowScope(row), + profileId: row.profileId, + profileVersion: row.profileVersion, + profileHash: row.profileHash, + jraRecipeVersionId: row.jraRecipeVersionId, + jraRecipeVersionHash: row.jraRecipeVersionHash, + deviceId: row.deviceId, + inputBindingIds: row.inputBindingIds, + outputBindingIds: row.outputBindingIds, + ...(row.dataModeConstraint === null ? {} : { dataModeConstraint: row.dataModeConstraint }), + ...(row.effectiveDataModePolicyRef === null + ? {} + : { effectiveDataModePolicyRef: row.effectiveDataModePolicyRef }), + idempotencyKey: row.idempotencyKey, + state: row.state, + revision: row.revision, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_ASSIGNMENT_INVALID'); + return parsed.value; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaFolderAutopilotTransactionAdapter implements FolderAutopilotTransactionPortV1 { + public constructor(private readonly client: FolderAutopilotDatabaseClientV1) {} + + public async saveProfile( + context: IamTenantContextV1, + profile: FolderAutopilotProfileV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.folderAutopilotProfileRecord.findFirst({ + where: { id: profile.profileId, version: profile.version }, + }); + if (existing) { + if (JSON.stringify(profileFromRow(existing)) !== JSON.stringify(profile)) + throw new Error('FA_IMMUTABLE_PROFILE'); + return; + } + const data = { + ...databaseScope(profile.tenantScope), + id: profile.profileId, + version: profile.version, + payloadHash: profile.payloadHash, + stabilizationDelayMs: profile.stabilizationDelayMs, + maxFilesPerScan: profile.maxFilesPerScan, + collisionPolicy: profile.collisionPolicy, + undoWindowSeconds: profile.undoWindowSeconds, + outputLineageEnabled: profile.outputLineageEnabled, + createdAt: new Date(profile.createdAt), + revision: profile.revision, + }; + const inserted = await this.client.folderAutopilotProfileRecord.createMany({ + data, + skipDuplicates: true, + }); + if (inserted.count === 1) return; + const raced = await this.client.folderAutopilotProfileRecord.findFirst({ + where: { id: profile.profileId, version: profile.version }, + }); + if (raced && JSON.stringify(profileFromRow(raced)) === JSON.stringify(profile)) return; + if (raced) throw new Error('FA_IMMUTABLE_PROFILE'); + throw new Error('FA_PERSISTENCE_UNAVAILABLE'); + } + + public async findProfile( + context: IamTenantContextV1, + profileId: FolderAutopilotProfileV1['profileId'], + version?: number, + ) { + const row = await this.client.folderAutopilotProfileRecord.findFirst({ + where: { id: profileId, ...(version === undefined ? {} : { version }) }, + orderBy: { version: 'desc' }, + }); + return row !== null && visible(context.tenantScope, rowScope(row)) + ? profileFromRow(row) + : undefined; + } + + public async listProfiles(context: IamTenantContextV1) { + const rows = await this.client.folderAutopilotProfileRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, rowScope(row))).map(profileFromRow); + } + + public async saveBinding( + context: IamTenantContextV1, + binding: AutopilotFolderBindingV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, binding.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.autopilotFolderBindingRecord.findUnique({ + where: { id: binding.bindingId }, + }); + if (existing) { + if (JSON.stringify(bindingFromRow(existing)) !== JSON.stringify(binding)) + throw new Error('FA_IMMUTABLE_BINDING'); + return; + } + const data = { + ...databaseScope(binding.tenantScope), + id: binding.bindingId, + deviceGrantId: binding.deviceGrantId, + role: binding.role, + expectedCapabilityDigest: binding.expectedCapabilityDigest, + createdAt: new Date(binding.createdAt), + revision: binding.revision, + }; + const inserted = await this.client.autopilotFolderBindingRecord.createMany({ + data, + skipDuplicates: true, + }); + if (inserted.count === 1) return; + const raced = await this.client.autopilotFolderBindingRecord.findUnique({ + where: { id: binding.bindingId }, + }); + if (raced && JSON.stringify(bindingFromRow(raced)) === JSON.stringify(binding)) return; + if (raced) throw new Error('FA_IMMUTABLE_BINDING'); + throw new Error('FA_PERSISTENCE_UNAVAILABLE'); + } + + public async findBinding( + context: IamTenantContextV1, + bindingId: AutopilotFolderBindingV1['bindingId'], + ) { + const row = await this.client.autopilotFolderBindingRecord.findUnique({ + where: { id: bindingId }, + }); + return row !== null && visible(context.tenantScope, rowScope(row)) + ? bindingFromRow(row) + : undefined; + } + + public async listBindings(context: IamTenantContextV1) { + const rows = await this.client.autopilotFolderBindingRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, rowScope(row))).map(bindingFromRow); + } + + public async saveAssignment( + context: IamTenantContextV1, + assignment: RecipeAssignmentV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, assignment.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.recipeAssignmentRecord.findUnique({ + where: { id: assignment.assignmentId }, + }); + if (existing) { + if (JSON.stringify(assignmentFromRow(existing)) !== JSON.stringify(assignment)) + throw new Error('FA_IMMUTABLE_ASSIGNMENT'); + return; + } + const data = { + ...databaseScope(assignment.tenantScope), + scopeKey: scopeKey(assignment.tenantScope), + id: assignment.assignmentId, + profileId: assignment.profileId, + profileVersion: assignment.profileVersion, + profileHash: assignment.profileHash, + jraRecipeVersionId: assignment.jraRecipeVersionId, + jraRecipeVersionHash: assignment.jraRecipeVersionHash, + deviceId: assignment.deviceId, + inputBindingIds: assignment.inputBindingIds, + outputBindingIds: assignment.outputBindingIds, + dataModeConstraint: assignment.dataModeConstraint ?? null, + effectiveDataModePolicyRef: assignment.effectiveDataModePolicyRef ?? null, + idempotencyKey: assignment.idempotencyKey, + state: assignment.state, + revision: assignment.revision, + createdAt: new Date(assignment.createdAt), + updatedAt: new Date(assignment.updatedAt), + }; + const inserted = await this.client.recipeAssignmentRecord.createMany({ + data, + skipDuplicates: true, + }); + if (inserted.count === 1) return; + const raced = await this.client.recipeAssignmentRecord.findUnique({ + where: { id: assignment.assignmentId }, + }); + if (raced && JSON.stringify(assignmentFromRow(raced)) === JSON.stringify(assignment)) return; + const idempotent = await this.client.recipeAssignmentRecord.findFirst({ + where: { + scopeKey: scopeKey(assignment.tenantScope), + idempotencyKey: assignment.idempotencyKey, + }, + }); + if (idempotent) throw new Error('FA_IMMUTABLE_ASSIGNMENT'); + if (raced) throw new Error('FA_IMMUTABLE_ASSIGNMENT'); + throw new Error('FA_PERSISTENCE_UNAVAILABLE'); + } + + public async findAssignment( + context: IamTenantContextV1, + assignmentId: RecipeAssignmentV1['assignmentId'], + ) { + const row = await this.client.recipeAssignmentRecord.findUnique({ + where: { id: assignmentId }, + }); + return row !== null && visible(context.tenantScope, rowScope(row)) + ? assignmentFromRow(row) + : undefined; + } + + public async listAssignments(context: IamTenantContextV1) { + const rows = await this.client.recipeAssignmentRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, rowScope(row))).map(assignmentFromRow); + } + + public async updateAssignmentState( + context: IamTenantContextV1, + assignmentId: RecipeAssignmentV1['assignmentId'], + expectedRevision: number, + state: RecipeAssignmentV1['state'], + ): Promise { + const existing = await this.client.recipeAssignmentRecord.findUnique({ + where: { id: assignmentId }, + }); + if (!existing || !visible(context.tenantScope, rowScope(existing))) + throw new Error('FA_ASSIGNMENT_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, rowScope(existing))) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + if (existing.revision !== expectedRevision) throw new Error('FA_ASSIGNMENT_REVISION_CONFLICT'); + const outcome = await this.client.recipeAssignmentRecord.updateMany({ + where: { id: assignmentId, revision: expectedRevision }, + data: { state, revision: expectedRevision + 1, updatedAt: new Date() }, + }); + if (outcome.count === 0) throw new Error('FA_ASSIGNMENT_REVISION_CONFLICT'); + if (outcome.count !== 1) throw new Error('FA_PERSISTENCE_UNAVAILABLE'); + const updated = await this.client.recipeAssignmentRecord.findUnique({ + where: { id: assignmentId }, + }); + if (updated === null) throw new Error('FA_PERSISTENCE_UNAVAILABLE'); + return assignmentFromRow(updated); + } +} + +export class PrismaFolderAutopilotRepositoryAdapter implements FolderAutopilotRepositoryPortV1 { + public constructor(private readonly client: FolderAutopilotDatabaseClientV1) {} + + public withTransaction( + _context: IamTenantContextV1, + work: (transaction: FolderAutopilotTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaFolderAutopilotTransactionAdapter(transaction)), + ); + } + + public saveProfile(context: IamTenantContextV1, profile: FolderAutopilotProfileV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).saveProfile(context, profile); + } + + public findProfile( + context: IamTenantContextV1, + profileId: FolderAutopilotProfileV1['profileId'], + version?: number, + ) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).findProfile( + context, + profileId, + version, + ); + } + + public listProfiles(context: IamTenantContextV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).listProfiles(context); + } + + public saveBinding(context: IamTenantContextV1, binding: AutopilotFolderBindingV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).saveBinding(context, binding); + } + + public findBinding( + context: IamTenantContextV1, + bindingId: AutopilotFolderBindingV1['bindingId'], + ) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).findBinding(context, bindingId); + } + + public listBindings(context: IamTenantContextV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).listBindings(context); + } + + public saveAssignment(context: IamTenantContextV1, assignment: RecipeAssignmentV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).saveAssignment( + context, + assignment, + ); + } + + public findAssignment( + context: IamTenantContextV1, + assignmentId: RecipeAssignmentV1['assignmentId'], + ) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).findAssignment( + context, + assignmentId, + ); + } + + public listAssignments(context: IamTenantContextV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).listAssignments(context); + } + + public updateAssignmentState( + context: IamTenantContextV1, + assignmentId: RecipeAssignmentV1['assignmentId'], + expectedRevision: number, + state: RecipeAssignmentV1['state'], + ) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).updateAssignmentState( + context, + assignmentId, + expectedRevision, + state, + ); + } +} diff --git a/services/api/src/features/fa/api/folder-autopilot-dashboard.ts b/services/api/src/features/fa/api/folder-autopilot-dashboard.ts new file mode 100644 index 00000000..7c2aa6d2 --- /dev/null +++ b/services/api/src/features/fa/api/folder-autopilot-dashboard.ts @@ -0,0 +1,103 @@ +import type { + FolderAutopilotProfileV1, + RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; + +/** + * The dashboard is a read-only projection. It deliberately contains no local + * paths, handles, bytes, or independent JRA/DSO authority. + */ +export interface FolderAutopilotDashboardProjectionV1 { + readonly schemaVersion: 1; + readonly profiles: readonly FolderAutopilotDashboardProfileV1[]; + readonly assignments: readonly FolderAutopilotDashboardAssignmentV1[]; + readonly previews: readonly []; + readonly approvals: readonly []; + readonly executions: readonly []; + readonly exceptions: readonly []; + readonly health: readonly []; +} + +export interface FolderAutopilotDashboardProfileV1 { + readonly profileId: string; + readonly version: number; + readonly stabilizationSeconds: number; + readonly collisionPolicy: FolderAutopilotProfileV1['collisionPolicy']; + readonly confidenceThreshold: 1; + readonly undoWindowHours: number; + readonly approvalRequired: true; + readonly dataModeConstraint: 'Hybrid'; + readonly recipeHash: string; + readonly updatedAt: string; +} + +export interface FolderAutopilotDashboardAssignmentV1 { + readonly assignmentId: string; + readonly profileId: string; + readonly jraRecipeVersionId: string; + readonly deviceId: string; + readonly inputBindingId: string; + readonly outputBindingId: string; + readonly dataModeConstraint?: RecipeAssignmentV1['dataModeConstraint']; + readonly state: RecipeAssignmentV1['state']; + readonly approvalRequired: true; + readonly revision: number; + readonly updatedAt: string; +} + +function profileProjection(profile: FolderAutopilotProfileV1): FolderAutopilotDashboardProfileV1 { + return Object.freeze({ + profileId: profile.profileId, + version: profile.version, + stabilizationSeconds: Math.floor(profile.stabilizationDelayMs / 1_000), + collisionPolicy: profile.collisionPolicy, + confidenceThreshold: 1 as const, + undoWindowHours: Math.floor(profile.undoWindowSeconds / 3_600), + approvalRequired: true as const, + dataModeConstraint: 'Hybrid' as const, + recipeHash: profile.payloadHash, + updatedAt: profile.createdAt, + }); +} + +function assignmentProjection( + assignment: RecipeAssignmentV1, +): FolderAutopilotDashboardAssignmentV1 { + const inputBindingId = assignment.inputBindingIds[0]; + const outputBindingId = assignment.outputBindingIds[0]; + if (inputBindingId === undefined || outputBindingId === undefined) { + throw new Error('FA_ASSIGNMENT_BINDINGS_INVALID'); + } + return Object.freeze({ + assignmentId: assignment.assignmentId, + profileId: assignment.profileId, + jraRecipeVersionId: assignment.jraRecipeVersionId, + deviceId: assignment.deviceId, + inputBindingId, + outputBindingId, + ...(assignment.dataModeConstraint === undefined + ? {} + : { dataModeConstraint: assignment.dataModeConstraint }), + state: assignment.state, + approvalRequired: true as const, + revision: assignment.revision, + updatedAt: assignment.updatedAt, + }); +} + +export function buildFolderAutopilotDashboardProjection( + profiles: readonly FolderAutopilotProfileV1[], + assignments: readonly RecipeAssignmentV1[], +): FolderAutopilotDashboardProjectionV1 { + const empty: readonly [] = Object.freeze([]); + return Object.freeze({ + schemaVersion: 1 as const, + profiles: Object.freeze(profiles.map(profileProjection)), + assignments: Object.freeze(assignments.map(assignmentProjection)), + previews: empty, + approvals: empty, + executions: empty, + exceptions: empty, + health: empty, + }); +} diff --git a/services/api/src/features/fa/api/folder-autopilot.controller.ts b/services/api/src/features/fa/api/folder-autopilot.controller.ts new file mode 100644 index 00000000..830542e3 --- /dev/null +++ b/services/api/src/features/fa/api/folder-autopilot.controller.ts @@ -0,0 +1,341 @@ +import { + applyDecorators, + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + Patch, + Post, + Query, + Req, + Res, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiConflictResponse, + ApiForbiddenResponse, + ApiGoneResponse, + ApiNotFoundResponse, + ApiOperation, + ApiQuery, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; + +import { + FOLDER_AUTOPILOT_SERVICE, + FOLDER_AUTOPILOT_JRA_FACADE_PORT, + FolderAutopilotService, + type FolderAutopilotJraFacadePortV1, +} from '../application/folder-autopilot.service.js'; +import { + CreateAutopilotFolderBindingDto, + CreateFolderAutopilotProfileDto, + CreateRecipeAssignmentDto, + FolderAutopilotApprovalDecisionDto, + FolderAutopilotUndoRequestDto, + PauseRecipeAssignmentDto, + FolderAutopilotRejectedResponseDto, + UpdateRecipeAssignmentDto, +} from './folder-autopilot.dto.js'; +import { buildFolderAutopilotDashboardProjection } from './folder-autopilot-dashboard.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +function folderAutopilotStatus(result: unknown): number { + if (typeof result !== 'object' || result === null || !('accepted' in result)) + return HttpStatus.SERVICE_UNAVAILABLE; + const candidate = result as { readonly accepted?: unknown; readonly code?: unknown }; + if (candidate.accepted === true) return HttpStatus.OK; + switch (candidate.code) { + case 'FA_SCOPE_NARROWING_REQUIRED': + case 'DATA_MODE_BROADENS_WORKSPACE': + return HttpStatus.FORBIDDEN; + case 'FA_PROFILE_NOT_FOUND': + case 'FA_BINDING_NOT_FOUND': + case 'FA_ASSIGNMENT_NOT_FOUND': + return HttpStatus.NOT_FOUND; + case 'FA_IMMUTABLE_PROFILE': + case 'FA_IMMUTABLE_BINDING': + case 'FA_IMMUTABLE_ASSIGNMENT': + case 'FA_ASSIGNMENT_REVISION_CONFLICT': + return HttpStatus.CONFLICT; + case 'FA_PERSISTENCE_UNAVAILABLE': + case 'DATA_MODE_POLICY_UNAVAILABLE': + case 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE': + case 'FA_JRA_UNDO_FACADE_UNAVAILABLE': + return HttpStatus.SERVICE_UNAVAILABLE; + default: + return HttpStatus.BAD_REQUEST; + } +} + +function preserveFolderAutopilotStatus(result: TValue, reply?: FastifyReply): TValue { + if ( + typeof result === 'object' && + result !== null && + 'accepted' in result && + (result as { readonly accepted?: unknown }).accepted !== true + ) { + reply?.code(folderAutopilotStatus(result)); + } + return result; +} + +function applyFolderAutopilotOutcomeResponses(): MethodDecorator { + return applyDecorators( + ApiBadRequestResponse({ type: FolderAutopilotRejectedResponseDto }), + ApiForbiddenResponse({ type: FolderAutopilotRejectedResponseDto }), + ApiNotFoundResponse({ type: FolderAutopilotRejectedResponseDto }), + ApiConflictResponse({ type: FolderAutopilotRejectedResponseDto }), + ApiGoneResponse({ type: FolderAutopilotRejectedResponseDto }), + ApiServiceUnavailableResponse({ type: FolderAutopilotRejectedResponseDto }), + ); +} + +@ApiTags('folder-autopilot') +@ApiBearerAuth() +@Controller('v1') +export class FolderAutopilotController { + public constructor( + @Inject(FOLDER_AUTOPILOT_SERVICE) private readonly service: FolderAutopilotService, + @Inject(FOLDER_AUTOPILOT_JRA_FACADE_PORT) + private readonly jraFacade: FolderAutopilotJraFacadePortV1, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Get('autopilot-dashboard') + @ApiOperation({ summary: 'Read content-free Folder Autopilot dashboard projections' }) + @applyFolderAutopilotOutcomeResponses() + public async dashboard( + @Req() request: unknown, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const [profiles, assignments] = await Promise.all([ + this.service.listProfiles(context), + this.service.listAssignments(context), + ]); + if (!profiles.accepted) return preserveFolderAutopilotStatus(profiles, reply); + if (!assignments.accepted) return preserveFolderAutopilotStatus(assignments, reply); + return preserveFolderAutopilotStatus( + { + accepted: true, + value: buildFolderAutopilotDashboardProjection(profiles.value, assignments.value), + }, + reply, + ); + } + + @Post('autopilot-profiles') + @ApiOperation({ summary: 'Register an immutable, content-free Folder Autopilot profile' }) + @ApiBody({ type: CreateFolderAutopilotProfileDto }) + @applyFolderAutopilotOutcomeResponses() + public async createProfile( + @Req() request: unknown, + @Body() input: CreateFolderAutopilotProfileDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus(await this.service.createProfile(context, input), reply); + } + + @Get('autopilot-profiles') + @ApiOperation({ summary: 'List Folder Autopilot profile versions visible to the tenant' }) + @applyFolderAutopilotOutcomeResponses() + public async listProfiles( + @Req() request: unknown, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus(await this.service.listProfiles(context), reply); + } + + @Get('autopilot-profiles/:profileId') + @ApiOperation({ summary: 'Read an exact immutable Folder Autopilot profile version' }) + @ApiQuery({ name: 'version', required: false, type: 'integer', minimum: 1, maximum: 10_000 }) + @applyFolderAutopilotOutcomeResponses() + public async findProfile( + @Req() request: unknown, + @Param('profileId') profileId: string, + @Query('version') version?: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + const parsedVersion = version === undefined ? undefined : Number(version); + return preserveFolderAutopilotStatus( + await this.service.findProfile(context, profileId, parsedVersion), + reply, + ); + } + + @Post('autopilot-folder-bindings') + @ApiOperation({ summary: 'Register an opaque DSO-backed Folder Autopilot binding' }) + @ApiBody({ type: CreateAutopilotFolderBindingDto }) + @applyFolderAutopilotOutcomeResponses() + public async createBinding( + @Req() request: unknown, + @Body() input: CreateAutopilotFolderBindingDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus(await this.service.createBinding(context, input), reply); + } + + @Get('autopilot-folder-bindings') + @ApiOperation({ summary: 'List opaque Folder Autopilot bindings visible to the tenant' }) + @applyFolderAutopilotOutcomeResponses() + public async listBindings( + @Req() request: unknown, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus(await this.service.listBindings(context), reply); + } + + @Get('autopilot-folder-bindings/:bindingId') + @ApiOperation({ summary: 'Read an opaque Folder Autopilot binding' }) + @applyFolderAutopilotOutcomeResponses() + public async findBinding( + @Req() request: unknown, + @Param('bindingId') bindingId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus(await this.service.findBinding(context, bindingId), reply); + } + + @Post('autopilot-assignments') + @ApiOperation({ summary: 'Create a tenant-scoped Folder Autopilot assignment projection' }) + @ApiBody({ type: CreateRecipeAssignmentDto }) + @applyFolderAutopilotOutcomeResponses() + public async createAssignment( + @Req() request: unknown, + @Body() input: CreateRecipeAssignmentDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus( + await this.service.createAssignment(context, { + ...input, + idempotencyKey: context.idempotencyKey, + }), + reply, + ); + } + + @Get('autopilot-assignments') + @ApiOperation({ summary: 'List Folder Autopilot assignments visible to the tenant' }) + @applyFolderAutopilotOutcomeResponses() + public async listAssignments( + @Req() request: unknown, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus(await this.service.listAssignments(context), reply); + } + + @Get('autopilot-assignments/:assignmentId') + @ApiOperation({ summary: 'Read a tenant-scoped Folder Autopilot assignment' }) + @applyFolderAutopilotOutcomeResponses() + public async findAssignment( + @Req() request: unknown, + @Param('assignmentId') assignmentId: string, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus( + await this.service.findAssignment(context, assignmentId), + reply, + ); + } + + @Patch('autopilot-assignments/:assignmentId') + @ApiOperation({ summary: 'Advance an assignment projection with optimistic concurrency' }) + @ApiBody({ type: UpdateRecipeAssignmentDto }) + @applyFolderAutopilotOutcomeResponses() + public async updateAssignment( + @Req() request: unknown, + @Param('assignmentId') assignmentId: string, + @Body() input: UpdateRecipeAssignmentDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus( + await this.service.updateAssignmentState( + context, + assignmentId, + input.expectedRevision, + input.state, + ), + reply, + ); + } + + @Post('autopilot-assignments/:assignmentId/pause') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Pause an assignment projection with optimistic concurrency' }) + @ApiBody({ type: PauseRecipeAssignmentDto }) + @applyFolderAutopilotOutcomeResponses() + public async pauseAssignment( + @Req() request: unknown, + @Param('assignmentId') assignmentId: string, + @Body() input: PauseRecipeAssignmentDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus( + await this.service.updateAssignmentState( + context, + assignmentId, + input.expectedRevision, + 'PAUSED', + ), + reply, + ); + } + + @Post('autopilot-approvals/:approvalId/decision') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Submit a decision through the JRA-owned approval facade' }) + @ApiBody({ type: FolderAutopilotApprovalDecisionDto }) + @applyFolderAutopilotOutcomeResponses() + public async decideApproval( + @Req() request: unknown, + @Param('approvalId') approvalId: string, + @Body() input: FolderAutopilotApprovalDecisionDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus( + await this.service.decideApproval(context, approvalId, { ...input }, this.jraFacade), + reply, + ); + } + + @Post('autopilot-executions/:executionId/undo') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request undo through the JRA/desktop effect facade' }) + @ApiBody({ type: FolderAutopilotUndoRequestDto }) + @applyFolderAutopilotOutcomeResponses() + public async requestUndo( + @Req() request: unknown, + @Param('executionId') executionId: string, + @Body() input: FolderAutopilotUndoRequestDto, + @Res({ passthrough: true }) reply?: FastifyReply, + ): Promise { + const context = await this.requestContext.resolve(request); + return preserveFolderAutopilotStatus( + await this.service.requestUndo(context, executionId, { ...input }, this.jraFacade), + reply, + ); + } +} diff --git a/services/api/src/features/fa/api/folder-autopilot.dto.ts b/services/api/src/features/fa/api/folder-autopilot.dto.ts new file mode 100644 index 00000000..af43c89a --- /dev/null +++ b/services/api/src/features/fa/api/folder-autopilot.dto.ts @@ -0,0 +1,248 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + MinLength, + Min, +} from 'class-validator'; + +const sha256Pattern = '^[0-9a-f]{64}$'; +const strictUtcTimestampPattern = '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'; + +export class CreateFolderAutopilotProfileDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + profileId!: string; + + @ApiProperty({ type: 'integer', minimum: 1, maximum: 10000 }) + @IsInt() + @Min(1) + @Max(10_000) + version!: number; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + payloadHash!: string; + + @ApiProperty({ type: 'integer', minimum: 0, maximum: 86400000 }) + @IsInt() + @Min(0) + @Max(86_400_000) + stabilizationDelayMs!: number; + + @ApiProperty({ type: 'integer', minimum: 1, maximum: 100000 }) + @IsInt() + @Min(1) + @Max(100_000) + maxFilesPerScan!: number; + + @ApiProperty({ enum: ['REVIEW', 'SKIP', 'UNIQUE_NAME'] }) + @IsIn(['REVIEW', 'SKIP', 'UNIQUE_NAME']) + collisionPolicy!: 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; + + @ApiProperty({ type: 'integer', minimum: 0, maximum: 604800 }) + @IsInt() + @Min(0) + @Max(604_800) + undoWindowSeconds!: number; + + @ApiProperty() + @IsBoolean() + outputLineageEnabled!: boolean; + + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} + +export class CreateAutopilotFolderBindingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bindingId!: string; + + @ApiProperty({ format: 'uuid', description: 'Opaque DSO DeviceGrant identifier.' }) + @IsUUID() + deviceGrantId!: string; + + @ApiProperty({ enum: ['INPUT', 'OUTPUT'] }) + @IsIn(['INPUT', 'OUTPUT']) + role!: 'INPUT' | 'OUTPUT'; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + expectedCapabilityDigest!: string; + + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} + +export class CreateRecipeAssignmentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + assignmentId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + profileId!: string; + + @ApiProperty({ type: 'integer', minimum: 1, maximum: 10000 }) + @IsInt() + @Min(1) + @Max(10_000) + profileVersion!: number; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + profileHash!: string; + + @ApiProperty({ format: 'uuid', description: 'Opaque JRA RecipeVersion identifier.' }) + @IsUUID() + jraRecipeVersionId!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + jraRecipeVersionHash!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + deviceId!: string; + + @ApiProperty({ type: [String], minItems: 1, maxItems: 32, format: 'uuid' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(32) + @ArrayUnique() + @IsUUID(undefined, { each: true }) + inputBindingIds!: string[]; + + @ApiProperty({ type: [String], minItems: 1, maxItems: 32, format: 'uuid' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(32) + @ArrayUnique() + @IsUUID(undefined, { each: true }) + outputBindingIds!: string[]; + + @ApiPropertyOptional({ enum: ['LOCAL', 'HYBRID', 'CLOUD'] }) + @IsOptional() + @IsIn(['LOCAL', 'HYBRID', 'CLOUD']) + dataModeConstraint?: 'LOCAL' | 'HYBRID' | 'CLOUD'; + + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} + +export class UpdateRecipeAssignmentDto { + @ApiProperty({ type: 'integer', minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; + + @ApiProperty({ enum: ['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED'] }) + @IsIn(['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED']) + state!: 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'RETIRED'; +} + +export class PauseRecipeAssignmentDto { + @ApiProperty({ type: 'integer', minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} + +export class FolderAutopilotApprovalDecisionDto { + @ApiProperty({ format: 'uuid', description: 'JRA-owned approval request identifier.' }) + @IsUUID() + jraApprovalRequestId!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + subjectHash!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + planHash!: string; + + @ApiProperty({ enum: ['APPROVE', 'REJECT'] }) + @IsIn(['APPROVE', 'REJECT']) + decision!: 'APPROVE' | 'REJECT'; + + @ApiProperty({ minLength: 1, maxLength: 500 }) + @IsString() + @MinLength(1) + @MaxLength(500) + decisionReason!: string; +} + +export class FolderAutopilotUndoRequestDto { + @ApiProperty({ type: 'integer', minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + planHash!: string; +} + +export class FolderAutopilotRejectedResponseDto { + @ApiProperty({ enum: [false], example: false }) + accepted!: false; + + @ApiProperty({ + enum: [ + 'INVALID_IDENTIFIER', + 'INVALID_SCOPE', + 'INVALID_HASH', + 'INVALID_TIMESTAMP', + 'INVALID_VERSION', + 'INVALID_REVISION', + 'INVALID_ROLE', + 'INVALID_COLLISION_POLICY', + 'INVALID_SETTINGS', + 'INVALID_BINDINGS', + 'INVALID_DATA_MODE', + 'INVALID_POLICY_REFERENCE', + 'INVALID_IDEMPOTENCY_KEY', + 'INVALID_STATE', + 'FA_PROFILE_NOT_FOUND', + 'FA_BINDING_NOT_FOUND', + 'FA_ASSIGNMENT_NOT_FOUND', + 'FA_SCOPE_NARROWING_REQUIRED', + 'FA_IMMUTABLE_PROFILE', + 'FA_IMMUTABLE_BINDING', + 'FA_IMMUTABLE_ASSIGNMENT', + 'FA_PROFILE_HASH_MISMATCH', + 'FA_BINDING_ROLE_MISMATCH', + 'FA_ASSIGNMENT_REVISION_CONFLICT', + 'FA_PERSISTENCE_UNAVAILABLE', + 'DATA_MODE_BROADENS_WORKSPACE', + 'DATA_MODE_POLICY_UNAVAILABLE', + ], + }) + code!: string; +} diff --git a/services/api/src/features/fa/application/folder-autopilot-repository.port.ts b/services/api/src/features/fa/application/folder-autopilot-repository.port.ts new file mode 100644 index 00000000..bc6e2c0a --- /dev/null +++ b/services/api/src/features/fa/application/folder-autopilot-repository.port.ts @@ -0,0 +1,46 @@ +import type { + AutopilotFolderBindingV1, + FolderAutopilotProfileV1, + RecipeAssignmentStateV1, + RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const FOLDER_AUTOPILOT_REPOSITORY_PORT = Symbol('FOLDER_AUTOPILOT_REPOSITORY_PORT'); + +export interface FolderAutopilotTransactionPortV1 { + saveProfile(context: IamTenantContextV1, profile: FolderAutopilotProfileV1): Promise; + findProfile( + context: IamTenantContextV1, + profileId: StableIdentifierV1, + version?: number, + ): Promise; + listProfiles(context: IamTenantContextV1): Promise; + saveBinding(context: IamTenantContextV1, binding: AutopilotFolderBindingV1): Promise; + findBinding( + context: IamTenantContextV1, + bindingId: StableIdentifierV1, + ): Promise; + listBindings(context: IamTenantContextV1): Promise; + saveAssignment(context: IamTenantContextV1, assignment: RecipeAssignmentV1): Promise; + findAssignment( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + ): Promise; + listAssignments(context: IamTenantContextV1): Promise; + updateAssignmentState( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ): Promise; +} + +export interface FolderAutopilotRepositoryPortV1 extends FolderAutopilotTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: FolderAutopilotTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/fa/application/folder-autopilot.service.ts b/services/api/src/features/fa/application/folder-autopilot.service.ts new file mode 100644 index 00000000..4a3b85da --- /dev/null +++ b/services/api/src/features/fa/application/folder-autopilot.service.ts @@ -0,0 +1,393 @@ +import { + createAutopilotFolderBindingV1, + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, + type AutopilotFolderBindingV1, + type FolderAutopilotErrorCodeV1, + type FolderAutopilotProfileV1, + type RecipeAssignmentStateV1, + type RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { DataModeV1 } from '@databreeze/domain/data-mode/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { FolderAutopilotRepositoryPortV1 } from './folder-autopilot-repository.port.js'; + +export const FOLDER_AUTOPILOT_SERVICE = Symbol('FOLDER_AUTOPILOT_SERVICE'); +export const FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT = Symbol( + 'FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT', +); +export const FOLDER_AUTOPILOT_JRA_FACADE_PORT = Symbol('FOLDER_AUTOPILOT_JRA_FACADE_PORT'); + +export type FolderAutopilotDataModePolicyResultV1 = + | { readonly accepted: true; readonly value: { readonly effectiveDataModePolicyRef: string } } + | { + readonly accepted: false; + readonly code: 'DATA_MODE_BROADENS_WORKSPACE' | 'DATA_MODE_POLICY_UNAVAILABLE'; + }; + +/** DSO owns policy records; FA only calls this narrow integration facade. */ +export interface FolderAutopilotDataModePolicyPortV1 { + resolveNarrowed( + context: IamTenantContextV1, + requested: DataModeV1, + ): Promise; +} + +export class UnavailableFolderAutopilotDataModePolicyAdapter + implements FolderAutopilotDataModePolicyPortV1 +{ + public resolveNarrowed( + context: IamTenantContextV1, + requested: DataModeV1, + ): Promise { + void context; + void requested; + return Promise.resolve({ accepted: false, code: 'DATA_MODE_POLICY_UNAVAILABLE' as const }); + } +} + +export interface FolderAutopilotJraFacadePortV1 { + decideApproval( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + ): Promise; + requestUndo( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + ): Promise; +} + +export type FolderAutopilotFacadeResultV1 = + | { readonly accepted: true; readonly value: Readonly> } + | { + readonly accepted: false; + readonly code: 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE' | 'FA_JRA_UNDO_FACADE_UNAVAILABLE'; + }; + +/** JRA remains the sole approval/effect authority; this adapter fails closed until composed. */ +export class UnavailableFolderAutopilotJraFacadeAdapter implements FolderAutopilotJraFacadePortV1 { + public decideApproval( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + ): Promise { + void context; + void executionId; + void input; + return Promise.resolve({ + accepted: false, + code: 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE' as const, + }); + } + + public requestUndo( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + ): Promise { + void context; + void executionId; + void input; + return Promise.resolve({ accepted: false, code: 'FA_JRA_UNDO_FACADE_UNAVAILABLE' as const }); + } +} + +export type FolderAutopilotServiceErrorV1 = + | FolderAutopilotErrorCodeV1 + | 'FA_PROFILE_NOT_FOUND' + | 'FA_BINDING_NOT_FOUND' + | 'FA_ASSIGNMENT_NOT_FOUND' + | 'FA_SCOPE_NARROWING_REQUIRED' + | 'FA_IMMUTABLE_PROFILE' + | 'FA_IMMUTABLE_BINDING' + | 'FA_IMMUTABLE_ASSIGNMENT' + | 'FA_PROFILE_HASH_MISMATCH' + | 'FA_BINDING_ROLE_MISMATCH' + | 'FA_ASSIGNMENT_REVISION_CONFLICT' + | 'FA_PERSISTENCE_UNAVAILABLE' + | 'DATA_MODE_BROADENS_WORKSPACE' + | 'DATA_MODE_POLICY_UNAVAILABLE'; + +export type FolderAutopilotServiceResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: FolderAutopilotServiceErrorV1 }; + +type ProfileInputV1 = Omit[0], 'tenantScope'>; +type BindingInputV1 = Omit[0], 'tenantScope'>; +type AssignmentInputV1 = Omit[0], 'tenantScope'>; + +function rejected( + code: FolderAutopilotServiceErrorV1, +): FolderAutopilotServiceResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function mapPersistenceError(error: unknown): FolderAutopilotServiceErrorV1 { + const code = error instanceof Error ? error.message : ''; + if ( + code === 'FA_SCOPE_NARROWING_REQUIRED' || + code === 'FA_IMMUTABLE_PROFILE' || + code === 'FA_IMMUTABLE_BINDING' || + code === 'FA_IMMUTABLE_ASSIGNMENT' || + code === 'FA_PROFILE_NOT_FOUND' || + code === 'FA_BINDING_NOT_FOUND' || + code === 'FA_ASSIGNMENT_NOT_FOUND' || + code === 'FA_ASSIGNMENT_REVISION_CONFLICT' + ) + return code; + return 'FA_PERSISTENCE_UNAVAILABLE' as FolderAutopilotServiceErrorV1; +} + +function parseId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +/** Coordinates FA-owned records without copying JRA recipe or DSO grant authority. */ +export class FolderAutopilotService { + public constructor( + private readonly repository: FolderAutopilotRepositoryPortV1, + private readonly dataModePolicy: FolderAutopilotDataModePolicyPortV1 = new UnavailableFolderAutopilotDataModePolicyAdapter(), + ) {} + + public async createProfile( + context: IamTenantContextV1, + input: ProfileInputV1, + ): Promise> { + const created = createFolderAutopilotProfileV1({ + ...input, + tenantScope: context.tenantScope, + }); + if (!created.accepted) return created; + return this.repository + .withTransaction( + context, + async (transaction): Promise> => { + const existing = await transaction.findProfile( + context, + created.value.profileId, + created.value.version, + ); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + return rejected('FA_IMMUTABLE_PROFILE'); + } + await transaction.saveProfile(context, created.value); + return created; + }, + ) + .catch((error: unknown) => rejected(mapPersistenceError(error))); + } + + public async createBinding( + context: IamTenantContextV1, + input: BindingInputV1, + ): Promise> { + const created = createAutopilotFolderBindingV1({ + ...input, + tenantScope: context.tenantScope, + }); + if (!created.accepted) return created; + return this.repository + .withTransaction( + context, + async (transaction): Promise> => { + const existing = await transaction.findBinding(context, created.value.bindingId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + return rejected('FA_IMMUTABLE_BINDING'); + } + await transaction.saveBinding(context, created.value); + return created; + }, + ) + .catch((error: unknown) => rejected(mapPersistenceError(error))); + } + + public async createAssignment( + context: IamTenantContextV1, + input: AssignmentInputV1, + ): Promise> { + let effectiveDataModePolicyRef: string | undefined; + if (input.dataModeConstraint !== undefined) { + const requested = input.dataModeConstraint; + const resolution = await this.dataModePolicy.resolveNarrowed( + context, + requested as DataModeV1, + ); + if (!resolution.accepted) return rejected(resolution.code); + effectiveDataModePolicyRef = resolution.value.effectiveDataModePolicyRef; + } + const created = createRecipeAssignmentV1({ + ...input, + tenantScope: context.tenantScope, + ...(effectiveDataModePolicyRef === undefined ? {} : { effectiveDataModePolicyRef }), + }); + if (!created.accepted) return created; + return this.repository + .withTransaction( + context, + async (transaction): Promise> => { + const profile = await transaction.findProfile( + context, + created.value.profileId, + created.value.profileVersion, + ); + if (!profile) return rejected('FA_PROFILE_NOT_FOUND'); + if (profile.payloadHash !== created.value.profileHash) + return rejected('FA_PROFILE_HASH_MISMATCH'); + for (const bindingId of created.value.inputBindingIds) { + const binding = await transaction.findBinding(context, bindingId); + if (!binding) return rejected('FA_BINDING_NOT_FOUND'); + if (binding.role !== 'INPUT') return rejected('FA_BINDING_ROLE_MISMATCH'); + } + for (const bindingId of created.value.outputBindingIds) { + const binding = await transaction.findBinding(context, bindingId); + if (!binding) return rejected('FA_BINDING_NOT_FOUND'); + if (binding.role !== 'OUTPUT') return rejected('FA_BINDING_ROLE_MISMATCH'); + } + const existing = await transaction.findAssignment(context, created.value.assignmentId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + return rejected('FA_IMMUTABLE_ASSIGNMENT'); + } + await transaction.saveAssignment(context, created.value); + return created; + }, + ) + .catch((error: unknown) => rejected(mapPersistenceError(error))); + } + + public async updateAssignmentState( + context: IamTenantContextV1, + assignmentIdInput: unknown, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ): Promise> { + const assignmentId = parseId(assignmentIdInput); + if (!assignmentId) return rejected('INVALID_IDENTIFIER'); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) + return rejected('INVALID_REVISION'); + if (!['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED'].includes(state)) return rejected('INVALID_STATE'); + try { + const value = await this.repository.updateAssignmentState( + context, + assignmentId, + expectedRevision, + state, + ); + return Object.freeze({ accepted: true as const, value }); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async findProfile( + context: IamTenantContextV1, + profileIdInput: unknown, + version?: number, + ): Promise> { + const profileId = parseId(profileIdInput); + if (!profileId) return rejected('INVALID_IDENTIFIER'); + if ( + version !== undefined && + (!Number.isSafeInteger(version) || version < 1 || version > 10_000) + ) + return rejected('INVALID_VERSION'); + try { + const value = await this.repository.findProfile(context, profileId, version); + return value ? Object.freeze({ accepted: true, value }) : rejected('FA_PROFILE_NOT_FOUND'); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async findBinding( + context: IamTenantContextV1, + bindingIdInput: unknown, + ): Promise> { + const bindingId = parseId(bindingIdInput); + if (!bindingId) return rejected('INVALID_IDENTIFIER'); + try { + const value = await this.repository.findBinding(context, bindingId); + return value ? Object.freeze({ accepted: true, value }) : rejected('FA_BINDING_NOT_FOUND'); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async findAssignment( + context: IamTenantContextV1, + assignmentIdInput: unknown, + ): Promise> { + const assignmentId = parseId(assignmentIdInput); + if (!assignmentId) return rejected('INVALID_IDENTIFIER'); + try { + const value = await this.repository.findAssignment(context, assignmentId); + return value ? Object.freeze({ accepted: true, value }) : rejected('FA_ASSIGNMENT_NOT_FOUND'); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async listProfiles( + context: IamTenantContextV1, + ): Promise> { + try { + return Object.freeze({ accepted: true, value: await this.repository.listProfiles(context) }); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async listBindings( + context: IamTenantContextV1, + ): Promise> { + try { + return Object.freeze({ accepted: true, value: await this.repository.listBindings(context) }); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async listAssignments( + context: IamTenantContextV1, + ): Promise> { + try { + return Object.freeze({ + accepted: true, + value: await this.repository.listAssignments(context), + }); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public decideApproval( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + facade: FolderAutopilotJraFacadePortV1, + ): Promise { + return facade.decideApproval(context, executionId, input); + } + + public requestUndo( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + facade: FolderAutopilotJraFacadePortV1, + ): Promise { + return facade.requestUndo(context, executionId, input); + } +} diff --git a/services/api/src/features/fa/fa.module.ts b/services/api/src/features/fa/fa.module.ts new file mode 100644 index 00000000..21ceb6ea --- /dev/null +++ b/services/api/src/features/fa/fa.module.ts @@ -0,0 +1,86 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { FolderAutopilotController } from './api/folder-autopilot.controller.js'; +import { InMemoryFolderAutopilotRepositoryAdapter } from './adapter/in-memory-folder-autopilot-repository.adapter.js'; +import { + PrismaFolderAutopilotRepositoryAdapter, + type FolderAutopilotDatabaseClientV1, +} from './adapter/prisma-folder-autopilot-repository.adapter.js'; +import { + FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT, + FOLDER_AUTOPILOT_JRA_FACADE_PORT, + FOLDER_AUTOPILOT_SERVICE, + FolderAutopilotService, + type FolderAutopilotDataModePolicyPortV1, + type FolderAutopilotJraFacadePortV1, + UnavailableFolderAutopilotDataModePolicyAdapter, + UnavailableFolderAutopilotJraFacadeAdapter, +} from './application/folder-autopilot.service.js'; +import { + FOLDER_AUTOPILOT_REPOSITORY_PORT, + type FolderAutopilotRepositoryPortV1, +} from './application/folder-autopilot-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export interface FaModuleOptions { + /** Production composition passes the generated Prisma client; tests may use in-memory state. */ + readonly folderAutopilotDatabase?: FolderAutopilotDatabaseClientV1; + readonly folderAutopilotRepository?: FolderAutopilotRepositoryPortV1; + /** DSO owns policy authority; FA receives only this narrow facade. */ + readonly folderAutopilotDataModePolicy?: FolderAutopilotDataModePolicyPortV1; + /** JRA owns ApprovalRequest/Decision and effects; FA receives only this facade. */ + readonly folderAutopilotJraFacade?: FolderAutopilotJraFacadePortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class FaModule { + public static register(options: FaModuleOptions = {}): DynamicModule { + if ( + process.env['NODE_ENV'] === 'production' && + options.folderAutopilotRepository === undefined && + options.folderAutopilotDatabase === undefined + ) { + throw new Error('FA_PERSISTENCE_REQUIRED'); + } + const repository = + options.folderAutopilotRepository ?? + (options.folderAutopilotDatabase === undefined + ? new InMemoryFolderAutopilotRepositoryAdapter() + : new PrismaFolderAutopilotRepositoryAdapter(options.folderAutopilotDatabase)); + const dataModePolicy = + options.folderAutopilotDataModePolicy ?? + new UnavailableFolderAutopilotDataModePolicyAdapter(); + const jraFacade = + options.folderAutopilotJraFacade ?? new UnavailableFolderAutopilotJraFacadeAdapter(); + return { + module: FaModule, + controllers: [FolderAutopilotController], + providers: [ + { provide: FOLDER_AUTOPILOT_REPOSITORY_PORT, useValue: repository }, + { + provide: FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT, + useValue: dataModePolicy, + }, + { provide: FOLDER_AUTOPILOT_JRA_FACADE_PORT, useValue: jraFacade }, + { + provide: FOLDER_AUTOPILOT_SERVICE, + useFactory: ( + folderRepository: FolderAutopilotRepositoryPortV1, + policy: FolderAutopilotDataModePolicyPortV1, + ): FolderAutopilotService => new FolderAutopilotService(folderRepository, policy), + inject: [FOLDER_AUTOPILOT_REPOSITORY_PORT, FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT], + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [FOLDER_AUTOPILOT_REPOSITORY_PORT, FOLDER_AUTOPILOT_SERVICE], + }; + } +} diff --git a/services/api/test/features/fa/folder-autopilot-dashboard.test.ts b/services/api/test/features/fa/folder-autopilot-dashboard.test.ts new file mode 100644 index 00000000..72da3808 --- /dev/null +++ b/services/api/test/features/fa/folder-autopilot-dashboard.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildFolderAutopilotDashboardProjection } from '../../../src/features/fa/api/folder-autopilot-dashboard.js'; +import { + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; + +const scope = { + scopeType: 'workspace' as const, + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', +}; + +const createdAt = '2026-08-04T00:00:00.000Z'; + +function profile() { + const result = createFolderAutopilotProfileV1({ + profileId: '33333333-3333-4333-8333-333333333333', + tenantScope: scope, + version: 2, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 10_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 86_400, + outputLineageEnabled: true, + createdAt, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid profile fixture'); + return result.value; +} + +function assignment() { + const result = createRecipeAssignmentV1({ + assignmentId: '44444444-4444-4444-8444-444444444444', + tenantScope: scope, + profileId: '33333333-3333-4333-8333-333333333333', + profileVersion: 2, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: '55555555-5555-4555-8555-555555555555', + jraRecipeVersionHash: 'b'.repeat(64), + deviceId: '66666666-6666-4666-8666-666666666666', + inputBindingIds: ['77777777-7777-4777-8777-777777777777'], + outputBindingIds: ['88888888-8888-4888-8888-888888888888'], + dataModeConstraint: 'LOCAL', + idempotencyKey: 'dashboard-fixture', + createdAt, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid assignment fixture'); + return result.value; +} + +void test('[FA-033] dashboard maps immutable records to a content-free projection', () => { + const dashboard = buildFolderAutopilotDashboardProjection([profile()], [assignment()]); + assert.equal(dashboard.schemaVersion, 1); + assert.equal(dashboard.profiles[0]?.stabilizationSeconds, 10); + assert.equal(dashboard.profiles[0]?.undoWindowHours, 24); + assert.equal(dashboard.profiles[0]?.version, 2); + assert.equal(dashboard.assignments[0]?.assignmentId, '44444444-4444-4444-8444-444444444444'); + assert.equal(dashboard.assignments[0]?.dataModeConstraint, 'LOCAL'); + assert.equal(dashboard.assignments[0]?.updatedAt, createdAt); + assert.equal('tenantScope' in dashboard.profiles[0], false); + assert.equal('deviceGrantId' in dashboard.assignments[0], false); + assert.doesNotMatch(JSON.stringify(dashboard), /path|handle|bytes|localHandle/iu); +}); diff --git a/services/api/test/features/fa/folder-autopilot.controller.test.ts b/services/api/test/features/fa/folder-autopilot.controller.test.ts new file mode 100644 index 00000000..e290babe --- /dev/null +++ b/services/api/test/features/fa/folder-autopilot.controller.test.ts @@ -0,0 +1,207 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryFolderAutopilotRepositoryAdapter } from '../../../src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.js'; +import type { FolderAutopilotDataModePolicyPortV1 } from '../../../src/features/fa/application/folder-autopilot.service.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const ids = { + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', + profileId: '33333333-3333-4333-8333-333333333333', + inputBindingId: '44444444-4444-4444-8444-444444444444', + outputBindingId: '55555555-5555-4555-8555-555555555555', + deviceGrantId: '66666666-6666-4666-8666-666666666666', + deviceId: '77777777-7777-4777-8777-777777777777', + recipeId: '88888888-8888-4888-8888-888888888888', + policyVersionId: '99999999-9999-4999-8999-999999999999', +}; + +function context(workspaceId = ids.workspaceId, idempotencyKey = 'fa-http') { + const result = createIamTenantContextV1({ + actorId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + correlationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + tenantScope: { scopeType: 'workspace', organizationId: ids.organizationId, workspaceId }, + authorizationEpoch: 1, + idempotencyKey, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context fixture'); + return result.value; +} + +const profile = { + profileId: ids.profileId, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +const policy: FolderAutopilotDataModePolicyPortV1 = { + resolveNarrowed: (_context, requested) => + Promise.resolve( + requested === 'LOCAL' + ? { accepted: true, value: { effectiveDataModePolicyRef: ids.policyVersionId } } + : { accepted: false, code: 'DATA_MODE_BROADENS_WORKSPACE' }, + ), +}; + +function jsonObject(response: { json(): unknown }): Record { + const value = response.json(); + assert.equal(typeof value, 'object'); + assert.notEqual(value, null); + return value as Record; +} + +void test('[FA-001..FA-007, FA-014, FA-015, FA-031] HTTP is tenant-scoped and content-free', async () => { + let current = context(); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(current), + }; + const repository = new InMemoryFolderAutopilotRepositoryAdapter(); + const { app } = await createApiApplication({ + requestTenantContext, + folderAutopilotRepository: repository, + folderAutopilotDataModePolicy: policy, + }); + try { + const rejectedUnknown = await app.inject({ + method: 'POST', + url: '/v1/autopilot-profiles', + payload: { ...profile, tenantScope: current.tenantScope, path: 'C:\\secret' }, + }); + assert.equal(rejectedUnknown.statusCode, 400); + + const createdProfile = await app.inject({ + method: 'POST', + url: '/v1/autopilot-profiles', + payload: profile, + }); + assert.equal(createdProfile.statusCode, 201); + assert.equal(jsonObject(createdProfile)['accepted'], true); + + for (const [bindingId, role] of [ + [ids.inputBindingId, 'INPUT'], + [ids.outputBindingId, 'OUTPUT'], + ] as const) { + const createdBinding = await app.inject({ + method: 'POST', + url: '/v1/autopilot-folder-bindings', + payload: { + bindingId, + deviceGrantId: ids.deviceGrantId, + role, + expectedCapabilityDigest: 'b'.repeat(64), + createdAt: profile.createdAt, + }, + }); + assert.equal(createdBinding.statusCode, 201); + assert.equal(jsonObject(createdBinding)['accepted'], true); + } + + const createdAssignment = await app.inject({ + method: 'POST', + url: '/v1/autopilot-assignments', + payload: { + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: profile.payloadHash, + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: [ids.outputBindingId], + dataModeConstraint: 'LOCAL', + createdAt: profile.createdAt, + }, + }); + assert.equal(createdAssignment.statusCode, 201); + const assignmentBody = jsonObject(createdAssignment); + const assignmentValue = assignmentBody['value']; + assert.equal(typeof assignmentValue, 'object'); + assert.notEqual(assignmentValue, null); + assert.equal( + (assignmentValue as Record)['effectiveDataModePolicyRef'], + ids.policyVersionId, + ); + + const patched = await app.inject({ + method: 'PATCH', + url: `/v1/autopilot-assignments/${ids.recipeId}`, + payload: { expectedRevision: 1, state: 'ACTIVE' }, + }); + assert.equal(patched.statusCode, 200); + const patchedBody = jsonObject(patched); + const patchedValue = patchedBody['value']; + assert.equal(typeof patchedValue, 'object'); + assert.notEqual(patchedValue, null); + assert.equal((patchedValue as Record)['revision'], 2); + + const dashboard = await app.inject({ method: 'GET', url: '/v1/autopilot-dashboard' }); + assert.equal(dashboard.statusCode, 200); + const dashboardBody = jsonObject(dashboard); + assert.equal(dashboardBody['accepted'], true); + const dashboardValue = dashboardBody['value']; + assert.equal(typeof dashboardValue, 'object'); + assert.notEqual(dashboardValue, null); + assert.equal(Array.isArray((dashboardValue as Record)['assignments']), true); + + const pause = await app.inject({ + method: 'POST', + url: `/v1/autopilot-assignments/${ids.recipeId}/pause`, + payload: { expectedRevision: 2 }, + }); + assert.equal(pause.statusCode, 200); + const pauseBody = jsonObject(pause); + const pauseValue = pauseBody['value']; + assert.equal(typeof pauseValue, 'object'); + assert.notEqual(pauseValue, null); + assert.equal((pauseValue as Record)['state'], 'PAUSED'); + + const approvalUnavailable = await app.inject({ + method: 'POST', + url: `/v1/autopilot-approvals/${ids.recipeId}/decision`, + payload: { + jraApprovalRequestId: ids.recipeId, + subjectHash: 'd'.repeat(64), + planHash: 'e'.repeat(64), + decision: 'APPROVE', + decisionReason: 'Ready for the JRA approval service.', + }, + }); + assert.equal(approvalUnavailable.statusCode, 503); + assert.deepEqual(approvalUnavailable.json(), { + accepted: false, + code: 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE', + }); + + const undoUnavailable = await app.inject({ + method: 'POST', + url: `/v1/autopilot-executions/${ids.recipeId}/undo`, + payload: { expectedRevision: 1, planHash: 'e'.repeat(64) }, + }); + assert.equal(undoUnavailable.statusCode, 503); + assert.deepEqual(undoUnavailable.json(), { + accepted: false, + code: 'FA_JRA_UNDO_FACADE_UNAVAILABLE', + }); + + current = context('ffffffff-ffff-4fff-8fff-ffffffffffff', 'fa-sibling'); + const siblingRead = await app.inject({ + method: 'GET', + url: `/v1/autopilot-profiles/${ids.profileId}`, + }); + assert.equal(siblingRead.statusCode, 404); + assert.deepEqual(siblingRead.json(), { accepted: false, code: 'FA_PROFILE_NOT_FOUND' }); + } finally { + await app.close(); + } +}); diff --git a/services/api/test/features/fa/folder-autopilot.service.test.ts b/services/api/test/features/fa/folder-autopilot.service.test.ts new file mode 100644 index 00000000..166a7f82 --- /dev/null +++ b/services/api/test/features/fa/folder-autopilot.service.test.ts @@ -0,0 +1,162 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryFolderAutopilotRepositoryAdapter } from '../../../src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.js'; +import { + FolderAutopilotService, + type FolderAutopilotDataModePolicyPortV1, +} from '../../../src/features/fa/application/folder-autopilot.service.js'; + +const ids = { + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', + profileId: '33333333-3333-4333-8333-333333333333', + inputBindingId: '44444444-4444-4444-8444-444444444444', + outputBindingId: '55555555-5555-4555-8555-555555555555', + deviceGrantId: '66666666-6666-4666-8666-666666666666', + deviceId: '77777777-7777-4777-8777-777777777777', + recipeId: '88888888-8888-4888-8888-888888888888', + policyVersionId: '99999999-9999-4999-8999-999999999999', +}; + +function context( + scope = { + scopeType: 'workspace' as const, + organizationId: ids.organizationId, + workspaceId: ids.workspaceId, + }, + idempotencyKey = 'fa-service', +) { + const result = createIamTenantContextV1({ + actorId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + correlationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + tenantScope: scope, + authorizationEpoch: 1, + idempotencyKey, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context fixture'); + return result.value; +} + +const policy: FolderAutopilotDataModePolicyPortV1 = { + resolveNarrowed: (_context, requested) => + Promise.resolve( + requested === 'LOCAL' + ? { accepted: true, value: { effectiveDataModePolicyRef: ids.policyVersionId } } + : { accepted: false, code: 'DATA_MODE_BROADENS_WORKSPACE' }, + ), +}; + +const profileInput = { + profileId: ids.profileId, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW' as const, + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +const bindingInput = (bindingId: string, role: 'INPUT' | 'OUTPUT') => ({ + bindingId, + deviceGrantId: ids.deviceGrantId, + role, + expectedCapabilityDigest: 'b'.repeat(64), + createdAt: '2026-08-04T00:00:00.000Z', +}); + +const assignmentInput = { + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: [ids.outputBindingId], + dataModeConstraint: 'LOCAL' as const, + idempotencyKey: 'assignment-create-1', + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[FA-001..FA-007] service stores profile and binding idempotently without local path data', async () => { + const service = new FolderAutopilotService( + new InMemoryFolderAutopilotRepositoryAdapter(), + policy, + ); + const tenant = context(); + const profile = await service.createProfile(tenant, profileInput); + assert.equal(profile.accepted, true); + const duplicate = await service.createProfile(tenant, profileInput); + assert.deepEqual(duplicate, profile); + const binding = await service.createBinding(tenant, bindingInput(ids.inputBindingId, 'INPUT')); + assert.equal(binding.accepted, true); + if (binding.accepted) { + assert.equal('path' in binding.value, false); + assert.equal('status' in binding.value, false); + } +}); + +void test('[FA-014, FA-015, FA-031] assignment validates owned references and rejects a broader mode', async () => { + const service = new FolderAutopilotService( + new InMemoryFolderAutopilotRepositoryAdapter(), + policy, + ); + const tenant = context(); + await service.createProfile(tenant, profileInput); + await service.createBinding(tenant, bindingInput(ids.inputBindingId, 'INPUT')); + await service.createBinding(tenant, bindingInput(ids.outputBindingId, 'OUTPUT')); + const assignment = await service.createAssignment(tenant, assignmentInput); + assert.equal(assignment.accepted, true); + if (assignment.accepted) + assert.equal(assignment.value.effectiveDataModePolicyRef, ids.policyVersionId); + + const broader = await service.createAssignment(tenant, { + ...assignmentInput, + assignmentId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + dataModeConstraint: 'HYBRID', + }); + assert.deepEqual(broader, { accepted: false, code: 'DATA_MODE_BROADENS_WORKSPACE' }); +}); + +void test('[IAM-019, FA-003] sibling tenant cannot read a profile or assignment', async () => { + const repository = new InMemoryFolderAutopilotRepositoryAdapter(); + const service = new FolderAutopilotService(repository, policy); + const tenant = context(); + await service.createProfile(tenant, profileInput); + const inputBinding = await service.createBinding( + tenant, + bindingInput(ids.inputBindingId, 'INPUT'), + ); + assert.equal(inputBinding.accepted, true); + const outputBinding = await service.createBinding( + tenant, + bindingInput(ids.outputBindingId, 'OUTPUT'), + ); + assert.equal(outputBinding.accepted, true); + const assignment = await service.createAssignment(tenant, assignmentInput); + assert.equal(assignment.accepted, true); + const ownerRead = await service.findAssignment(tenant, ids.recipeId); + assert.equal(ownerRead.accepted, true); + const sibling = context( + { + scopeType: 'workspace', + organizationId: ids.organizationId, + workspaceId: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + }, + 'fa-sibling', + ); + assert.deepEqual(await service.findProfile(sibling, ids.profileId), { + accepted: false, + code: 'FA_PROFILE_NOT_FOUND', + }); + assert.deepEqual(await service.findAssignment(sibling, ids.recipeId), { + accepted: false, + code: 'FA_ASSIGNMENT_NOT_FOUND', + }); +}); diff --git a/services/api/test/features/foundation-module-composition.test.ts b/services/api/test/features/foundation-module-composition.test.ts index 14103c82..cb87d96f 100644 --- a/services/api/test/features/foundation-module-composition.test.ts +++ b/services/api/test/features/foundation-module-composition.test.ts @@ -44,6 +44,9 @@ import { SessionRequestTenantContextAdapter } from '../../src/platform/http/sess import { SaModule } from '../../src/features/sa/sa.module.js'; import { SPREADSHEET_AUDIT_REPOSITORY_PORT } from '../../src/features/sa/application/spreadsheet-audit-repository.port.js'; import { PrismaSpreadsheetAuditRepositoryAdapter } from '../../src/features/sa/adapter/prisma-spreadsheet-audit-repository.adapter.js'; +import { FaModule } from '../../src/features/fa/fa.module.js'; +import { FOLDER_AUTOPILOT_REPOSITORY_PORT } from '../../src/features/fa/application/folder-autopilot-repository.port.js'; +import { PrismaFolderAutopilotRepositoryAdapter } from '../../src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.js'; function moduleTypes(): readonly unknown[] { const registered = AppModule.register({ allowInMemorySpreadsheetAuditRunRepository: true }); @@ -95,6 +98,22 @@ void test('[IAM-001, AUD-001, BUA-001] application composition includes identity assert.ok(types.includes(AudModule)); assert.ok(types.includes(BuaModule)); assert.ok(types.includes(SaModule)); + assert.ok(types.includes(FaModule)); +}); + +void test('[FA-001] configured Folder Autopilot persistence uses the Prisma adapter boundary', () => { + const database = {} as never; + const registered = FaModule.register({ folderAutopilotDatabase: database }); + const provider = registered.providers?.find( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + 'provide' in candidate && + candidate.provide === FOLDER_AUTOPILOT_REPOSITORY_PORT, + ); + assert.ok(provider && 'useValue' in provider); + if (!provider || !('useValue' in provider)) return; + assert.ok(provider.useValue instanceof PrismaFolderAutopilotRepositoryAdapter); }); void test('[SA-001] configured spreadsheet audit persistence uses the Prisma adapter', () => { diff --git a/services/api/test/openapi.test.ts b/services/api/test/openapi.test.ts index 6b05ae58..3cb3c6be 100644 --- a/services/api/test/openapi.test.ts +++ b/services/api/test/openapi.test.ts @@ -140,6 +140,16 @@ void test('generates deterministic versioned OpenAPI with safe headers, errors, '/v1/auth/register', '/v1/auth/sign-in', '/v1/auth/sign-out', + '/v1/autopilot-approvals/{approvalId}/decision', + '/v1/autopilot-assignments', + '/v1/autopilot-assignments/{assignmentId}', + '/v1/autopilot-assignments/{assignmentId}/pause', + '/v1/autopilot-dashboard', + '/v1/autopilot-executions/{executionId}/undo', + '/v1/autopilot-folder-bindings', + '/v1/autopilot-folder-bindings/{bindingId}', + '/v1/autopilot-profiles', + '/v1/autopilot-profiles/{profileId}', '/v1/data-mode-policies', '/v1/data-mode-policies/{policyId}', '/v1/dataset-exports', diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index fc9a0fab..9cef6faa 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -80,6 +80,9 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_conflicts"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."strict_local_package_manifests"/); assert.match(diff.stdout, /CREATE TABLE "sa"\."spreadsheet_audit_results"/); + assert.match(diff.stdout, /CREATE TABLE "fa"\."folder_autopilot_profiles"/); + assert.match(diff.stdout, /CREATE TABLE "fa"\."autopilot_folder_bindings"/); + assert.match(diff.stdout, /CREATE TABLE "fa"\."recipe_assignments"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."invitation_tokens"/); @@ -142,6 +145,8 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260804020000_iam_service_account_replay_bounds', '20260804030000_iam_recovery_compensation_failures', '20260804040000_iam_invitation_delivery_failures', + '20260804050000_fa_folder_autopilot', + '20260804120000_fa_assignment_scope_key', 'migration_lock.toml', ]); const migration = await readFile( @@ -514,6 +519,22 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const folderAutopilotMigration = await readFile( + path.join(migrationsDirectory, inventory[46], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE SCHEMA IF NOT EXISTS "fa"', + 'CREATE TABLE "fa"."folder_autopilot_profiles"', + 'CREATE TABLE "fa"."autopilot_folder_bindings"', + 'CREATE TABLE "fa"."recipe_assignments"', + 'expected_capability_digest', + ]) { + assert.match( + folderAutopilotMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } const lineageUniquenessMigration = await readFile( path.join(migrationsDirectory, inventory[32], 'migration.sql'), 'utf8', diff --git a/services/engine/src/databreeze_engine/folder_autopilot_contracts.py b/services/engine/src/databreeze_engine/folder_autopilot_contracts.py new file mode 100644 index 00000000..e81fde6e --- /dev/null +++ b/services/engine/src/databreeze_engine/folder_autopilot_contracts.py @@ -0,0 +1,246 @@ +"""Closed, content-free Folder Autopilot contracts shared by the engine boundary.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Annotated, Any, Literal + +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + StrictBool, + StrictInt, + StrictStr, + field_validator, + model_validator, +) + +# The Desktop observation adapter buffers bytes before hashing. Keep the +# cross-runtime contract aligned with that bounded, content-free adapter. +MAX_AUTOPILOT_FILE_BYTES = 512 * 1024 * 1024 +MAX_PLAN_STEPS = 100 +MAX_DESTINATIONS = 10_000 +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_DIGEST = re.compile(r"^[0-9a-f]{64}$") +_NANOSECOND_TIMESTAMP = re.compile(r"^\d{1,32}$") + + +def _invalid() -> ValueError: + return ValueError("INVALID_OBSERVATION") + + +def _valid_name(value: str) -> bool: + return ( + bool(value) + and value not in {".", ".."} + and "/" not in value + and "\\" not in value + and all(ord(character) >= 32 and ord(character) != 127 for character in value) + ) + + +def _tuple_from_json(value: Any) -> Any: + return tuple(value) if isinstance(value, list) else value + + +class FileObservation(BaseModel): + """A bounded, value-free identity for one locally observed file.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + observationId: StrictStr + displayName: StrictStr = Field(min_length=1, max_length=255) + sizeBytes: StrictInt = Field(ge=0, le=MAX_AUTOPILOT_FILE_BYTES) + modifiedAtNs: StrictStr = Field(pattern=r"^\d{1,32}$") + contentSha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + stableExecutionKey: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + @field_validator("observationId") + @classmethod + def validate_observation_id(cls, value: str) -> str: + if _SAFE_ID.fullmatch(value) is None: + raise _invalid() + return value + + @field_validator("displayName") + @classmethod + def validate_display_name(cls, value: str) -> str: + if not _valid_name(value): + raise _invalid() + return value + + @field_validator("contentSha256", "stableExecutionKey") + @classmethod + def validate_digest(cls, value: str) -> str: + if _DIGEST.fullmatch(value) is None: + raise _invalid() + return value + + @model_validator(mode="after") + def validate_stable_execution_key(self) -> FileObservation: + expected = stable_execution_key( + observation_id=self.observationId, + display_name=self.displayName, + size_bytes=self.sizeBytes, + modified_at_ns=self.modifiedAtNs, + content_sha256=self.contentSha256, + ) + if self.stableExecutionKey != expected: + raise _invalid() + return self + + +def stable_execution_key( + *, + observation_id: str, + display_name: str, + size_bytes: int, + modified_at_ns: str, + content_sha256: str, +) -> str: + if _NANOSECOND_TIMESTAMP.fullmatch(modified_at_ns) is None: + raise _invalid() + canonical = json.dumps( + { + "contentSha256": content_sha256, + "displayName": display_name, + "modifiedAtNs": modified_at_ns, + "observationId": observation_id, + "sizeBytes": size_bytes, + }, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +ActionType = Literal["INSPECT", "VALIDATE", "RENAME", "COPY", "MOVE"] +CollisionPolicy = Literal["REVIEW", "SKIP", "UNIQUE_NAME"] + + +class DestinationState(BaseModel): + """Content-free occupancy state keyed by a Desktop-local output binding.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + bindingId: StrictStr = Field(min_length=1, max_length=128) + displayName: StrictStr = Field(min_length=1, max_length=255) + occupied: StrictBool + + @model_validator(mode="after") + def validate_destination(self) -> DestinationState: + if _SAFE_ID.fullmatch(self.bindingId) is None or not _valid_name(self.displayName): + raise ValueError("INVALID_DESTINATION") + return self + + +class PlanStep(BaseModel): + """A single action from the closed Folder Autopilot action catalog.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + stepId: StrictStr = Field(min_length=1, max_length=128) + action: ActionType + destinationBindingId: StrictStr | None = Field(default=None, max_length=128) + destinationName: StrictStr | None = Field(default=None, max_length=255) + collisionPolicy: CollisionPolicy = "REVIEW" + requiresApproval: StrictBool = False + + @model_validator(mode="after") + def validate_shape(self) -> PlanStep: + if _SAFE_ID.fullmatch(self.stepId) is None: + raise ValueError("INVALID_STEP") + writes_destination = self.action in {"RENAME", "COPY", "MOVE"} + if writes_destination: + if self.destinationBindingId is None or self.destinationName is None: + raise ValueError("DESTINATION_REQUIRED") + if _SAFE_ID.fullmatch(self.destinationBindingId) is None: + raise ValueError("INVALID_DESTINATION_BINDING") + if not _valid_name(self.destinationName): + raise ValueError("INVALID_DESTINATION") + elif self.destinationBindingId is not None or self.destinationName is not None: + raise ValueError("DESTINATION_FORBIDDEN") + return self + + +class AutopilotPlanRequest(BaseModel): + """Local evaluator input; it carries IDs and names, never bytes or OS paths.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + recipeVersionId: StrictStr = Field(min_length=1, max_length=128) + assignmentId: StrictStr = Field(min_length=1, max_length=128) + observation: FileObservation + allowedOutputBindingIds: Annotated[tuple[StrictStr, ...], BeforeValidator(_tuple_from_json)] = ( + Field(min_length=1, max_length=20) + ) + existingDestinations: Annotated[ + tuple[DestinationState, ...], BeforeValidator(_tuple_from_json) + ] = Field(max_length=MAX_DESTINATIONS) + steps: Annotated[tuple[PlanStep, ...], BeforeValidator(_tuple_from_json)] = Field( + min_length=1, max_length=MAX_PLAN_STEPS + ) + + @model_validator(mode="after") + def validate_bindings_and_steps(self) -> AutopilotPlanRequest: + if any(_SAFE_ID.fullmatch(binding) is None for binding in self.allowedOutputBindingIds): + raise ValueError("INVALID_DESTINATION_BINDING") + if len(set(self.allowedOutputBindingIds)) != len(self.allowedOutputBindingIds): + raise ValueError("DUPLICATE_DESTINATION_BINDING") + if len({step.stepId for step in self.steps}) != len(self.steps): + raise ValueError("DUPLICATE_STEP") + return self + + +class PlanOperation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + sequence: int = Field(ge=0, le=MAX_PLAN_STEPS) + stepId: StrictStr = Field(min_length=1, max_length=128) + action: ActionType + sourceObservationId: StrictStr = Field(min_length=1, max_length=128) + destinationBindingId: StrictStr | None = None + destinationName: StrictStr | None = Field(default=None, max_length=255) + requiresApproval: StrictBool + + @model_validator(mode="after") + def validate_shape(self) -> PlanOperation: + if ( + _SAFE_ID.fullmatch(self.stepId) is None + or _SAFE_ID.fullmatch(self.sourceObservationId) is None + ): + raise ValueError("INVALID_OPERATION") + writes_destination = self.action in {"RENAME", "COPY", "MOVE"} + if writes_destination: + if self.destinationBindingId is None or self.destinationName is None: + raise ValueError("DESTINATION_REQUIRED") + if _SAFE_ID.fullmatch(self.destinationBindingId) is None or not _valid_name( + self.destinationName + ): + raise ValueError("INVALID_DESTINATION") + elif self.destinationBindingId is not None or self.destinationName is not None: + raise ValueError("DESTINATION_FORBIDDEN") + return self + + +PlanStatus = Literal["READY", "REVIEW", "SKIPPED"] +PlanReason = Literal[ + "DESTINATION_COLLISION", + "DESTINATION_COLLISION_SKIPPED", + "MOVE_REQUIRES_APPROVAL", +] + + +class AutopilotPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + status: PlanStatus + operations: tuple[PlanOperation, ...] = Field(max_length=MAX_PLAN_STEPS) + reasonCodes: tuple[PlanReason, ...] = Field(max_length=MAX_PLAN_STEPS) + planHash: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") diff --git a/services/engine/src/databreeze_engine/models.py b/services/engine/src/databreeze_engine/models.py index 3ad71a1f..e8908b80 100644 --- a/services/engine/src/databreeze_engine/models.py +++ b/services/engine/src/databreeze_engine/models.py @@ -17,6 +17,8 @@ model_validator, ) +from .folder_autopilot_contracts import AutopilotPlan, AutopilotPlanRequest + MAX_HANDLES = 32 @@ -91,7 +93,7 @@ class SpreadsheetAuditParameters(ClosedModel): resultManifestId: Identifier -ActionParameters = FoundationMetadataParameters | SpreadsheetAuditParameters +ActionParameters = FoundationMetadataParameters | SpreadsheetAuditParameters | AutopilotPlanRequest class EngineExecutionRequest(ClosedModel): @@ -102,7 +104,7 @@ class EngineExecutionRequest(ClosedModel): action: ActionReference inputHandles: Annotated[list[OpaqueHandle], Field(max_length=MAX_HANDLES)] outputHandle: OpaqueHandle - parameters: FoundationMetadataParameters | SpreadsheetAuditParameters + parameters: ActionParameters deadline: UtcTimestamp locale: Literal["vi-VN", "en"] @@ -169,13 +171,13 @@ class SpreadsheetAuditProcessorResult(ClosedModel): processorVersion: Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] -ActionOutput = FoundationDigestResult | SpreadsheetAuditProcessorResult +ActionOutput = FoundationDigestResult | SpreadsheetAuditProcessorResult | AutopilotPlan class EngineResult(ClosedModel): attemptId: Identifier status: Literal["SUCCEEDED"] - output: FoundationDigestResult | SpreadsheetAuditProcessorResult + output: FoundationDigestResult | SpreadsheetAuditProcessorResult | AutopilotPlan EngineErrorCode = Literal[ diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index bbe0c545..cb16c787 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1,5 +1,28 @@ """Reviewed built-in processors composed into the closed registry.""" +from .folder_autopilot import ( + FileObservation, + build_file_observation, + fingerprint_bytes, +) +from .folder_autopilot_action import ( + ACTION_TYPE as FOLDER_AUTOPILOT_ACTION_TYPE, +) +from .folder_autopilot_action import ( + ACTION_VERSION as FOLDER_AUTOPILOT_ACTION_VERSION, +) +from .folder_autopilot_action import ( + handle as handle_folder_autopilot, +) +from .folder_autopilot_plan import ( + AutopilotPlan, + AutopilotPlanRequest, + DestinationState, + PlanEvaluationError, + PlanOperation, + PlanStep, + evaluate_autopilot_plan, +) from .spreadsheet_auditor import ( SpreadsheetAuditError, SpreadsheetAuditResult, @@ -22,14 +45,27 @@ ) __all__ = [ + "FOLDER_AUTOPILOT_ACTION_TYPE", + "FOLDER_AUTOPILOT_ACTION_VERSION", "SPREADSHEET_AUDITOR_ACTION_TYPE", "SPREADSHEET_AUDITOR_ACTION_VERSION", + "AutopilotPlan", + "AutopilotPlanRequest", + "DestinationState", + "FileObservation", + "PlanEvaluationError", + "PlanOperation", + "PlanStep", "SpreadsheetAuditError", "SpreadsheetAuditManifest", "SpreadsheetAuditManifestFinding", "SpreadsheetAuditManifestSheet", "SpreadsheetAuditResult", "audit_workbook", + "build_file_observation", "build_spreadsheet_audit_manifest", + "evaluate_autopilot_plan", + "fingerprint_bytes", + "handle_folder_autopilot", "handle_spreadsheet_auditor", ] diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot.py b/services/engine/src/databreeze_engine/processors/folder_autopilot.py new file mode 100644 index 00000000..4af22fcf --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot.py @@ -0,0 +1,76 @@ +"""Content-free deterministic primitives for the Folder Autopilot local executor.""" + +from __future__ import annotations + +import hashlib + +from ..folder_autopilot_contracts import ( + MAX_AUTOPILOT_FILE_BYTES, + ActionType, + CollisionPolicy, + FileObservation, + stable_execution_key, +) + + +def fingerprint_bytes(content: bytes) -> str: + """Return a lowercase SHA-256 fingerprint without retaining the bytes.""" + if not isinstance(content, bytes): + raise ValueError("INVALID_OBSERVATION") + return hashlib.sha256(content).hexdigest() + + +def _stable_execution_key( + *, + observation_id: str, + display_name: str, + size_bytes: int, + modified_at_ns: str, + content_sha256: str, +) -> str: + return stable_execution_key( + observation_id=observation_id, + display_name=display_name, + size_bytes=size_bytes, + modified_at_ns=modified_at_ns, + content_sha256=content_sha256, + ) + + +def build_file_observation( + *, + observation_id: str, + display_name: str, + size_bytes: int, + modified_at_ns: str, + content_sha256: str, +) -> FileObservation: + """Build an immutable observation and derive its idempotency key.""" + try: + stable_key = _stable_execution_key( + observation_id=observation_id, + display_name=display_name, + size_bytes=size_bytes, + modified_at_ns=modified_at_ns, + content_sha256=content_sha256, + ) + return FileObservation( + observationId=observation_id, + displayName=display_name, + sizeBytes=size_bytes, + modifiedAtNs=modified_at_ns, + contentSha256=content_sha256, + stableExecutionKey=stable_key, + ) + except Exception as error: + raise ValueError("INVALID_OBSERVATION") from error + + +__all__ = [ + "MAX_AUTOPILOT_FILE_BYTES", + "ActionType", + "CollisionPolicy", + "FileObservation", + "build_file_observation", + "fingerprint_bytes", +] diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot_action.py b/services/engine/src/databreeze_engine/processors/folder_autopilot_action.py new file mode 100644 index 00000000..88d679e5 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot_action.py @@ -0,0 +1,29 @@ +"""Reviewed read-only Folder Autopilot plan evaluator action.""" + +from __future__ import annotations + +from typing import Any + +from databreeze_engine.handler import ActionExecutionError, HandlerContext + +from .folder_autopilot_plan import ( + AutopilotPlan, + AutopilotPlanRequest, + PlanEvaluationError, + evaluate_autopilot_plan, +) + +ACTION_TYPE = "folder-autopilot.plan-evaluate" +ACTION_VERSION = "1.0.0" +INPUT_SCHEMA_ID = "folder-autopilot.plan-request.v1" +OUTPUT_SCHEMA_ID = "folder-autopilot.plan-result.v1" + + +def handle(context: HandlerContext, parameters: Any) -> AutopilotPlan: + """Evaluate only typed metadata; local file effects remain Desktop-owned.""" + if context.input_handles or not isinstance(parameters, AutopilotPlanRequest): + raise ActionExecutionError("VALIDATION_FAILED") + try: + return evaluate_autopilot_plan(parameters) + except PlanEvaluationError as error: + raise ActionExecutionError(error.code) from None diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py b/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py new file mode 100644 index 00000000..7118ec23 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py @@ -0,0 +1,160 @@ +"""Bounded typed Folder Autopilot plan evaluation without filesystem side effects.""" + +from __future__ import annotations + +import hashlib +import json + +from ..folder_autopilot_contracts import ( + AutopilotPlan, + AutopilotPlanRequest, + CollisionPolicy, + DestinationState, + PlanOperation, + PlanReason, + PlanStatus, + PlanStep, +) + +MAX_UNIQUE_NAME_ATTEMPTS = 1_000 + + +class PlanEvaluationError(ValueError): + """Stable, content-free plan rejection.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +def _unique_name(name: str, occupied: set[tuple[str, str]], binding_id: str) -> str | None: + stem, separator, extension = name.rpartition(".") + if not separator or not stem: + stem, extension = name, "" + suffix = f".{extension}" if extension else "" + for index in range(1, MAX_UNIQUE_NAME_ATTEMPTS + 1): + index_suffix = f" ({index})" + stem_limit = 255 - len(index_suffix) - len(suffix) + if stem_limit < 1: + return None + candidate = f"{stem[:stem_limit]}{index_suffix}{suffix}" + if (binding_id, candidate.casefold()) not in occupied: + return candidate + return None + + +def _plan_hash( + request: AutopilotPlanRequest, + status: PlanStatus, + operations: tuple[PlanOperation, ...], + reason_codes: tuple[PlanReason, ...], +) -> str: + canonical = json.dumps( + { + "assignmentId": request.assignmentId, + "observationKey": request.observation.stableExecutionKey, + "operations": [operation.model_dump(mode="json") for operation in operations], + "reasonCodes": reason_codes, + "recipeVersionId": request.recipeVersionId, + "status": status, + }, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def evaluate_autopilot_plan(request: AutopilotPlanRequest) -> AutopilotPlan: + """Evaluate a bounded typed plan without reading, writing, or shelling out.""" + occupied = { + (destination.bindingId, destination.displayName.casefold()) + for destination in request.existingDestinations + if destination.occupied + } + operations: list[PlanOperation] = [] + reason_codes: list[PlanReason] = [] + review_required = False + skipped = False + + for sequence, step in enumerate(request.steps): + if step.action in {"INSPECT", "VALIDATE"}: + operations.append( + PlanOperation( + sequence=sequence, + stepId=step.stepId, + action=step.action, + sourceObservationId=request.observation.observationId, + requiresApproval=step.requiresApproval, + ) + ) + review_required = review_required or step.requiresApproval + continue + + binding_id = step.destinationBindingId + destination_name = step.destinationName + if binding_id is None or destination_name is None: + raise PlanEvaluationError("DESTINATION_REQUIRED") + if binding_id not in request.allowedOutputBindingIds: + raise PlanEvaluationError("DESTINATION_BINDING_NOT_ALLOWED") + + requested_key = (binding_id, destination_name.casefold()) + collision_review = False + if requested_key in occupied: + if step.collisionPolicy == "REVIEW": + collision_review = True + review_required = True + reason_codes.append("DESTINATION_COLLISION") + elif step.collisionPolicy == "SKIP": + skipped = True + reason_codes.append("DESTINATION_COLLISION_SKIPPED") + continue + else: + destination_name = _unique_name(destination_name, occupied, binding_id) + if destination_name is None: + raise PlanEvaluationError("UNIQUE_NAME_EXHAUSTED") + + requires_approval = step.requiresApproval or step.action == "MOVE" or collision_review + if step.action == "MOVE" and not step.requiresApproval: + reason_codes.append("MOVE_REQUIRES_APPROVAL") + review_required = review_required or requires_approval + operation = PlanOperation( + sequence=sequence, + stepId=step.stepId, + action=step.action, + sourceObservationId=request.observation.observationId, + destinationBindingId=binding_id, + destinationName=destination_name, + requiresApproval=requires_approval, + ) + operations.append(operation) + occupied.add((binding_id, destination_name.casefold())) + + status: PlanStatus + if review_required: + status = "REVIEW" + elif not operations and skipped: + status = "SKIPPED" + else: + status = "READY" + reason_tuple = tuple(dict.fromkeys(reason_codes)) + operation_tuple = tuple(operations) + return AutopilotPlan( + status=status, + operations=operation_tuple, + reasonCodes=reason_tuple, + planHash=_plan_hash(request, status, operation_tuple, reason_tuple), + ) + + +__all__ = [ + "AutopilotPlan", + "AutopilotPlanRequest", + "CollisionPolicy", + "DestinationState", + "PlanEvaluationError", + "PlanOperation", + "PlanStep", + "evaluate_autopilot_plan", +] diff --git a/services/engine/src/databreeze_engine/registry.py b/services/engine/src/databreeze_engine/registry.py index 4b436f0e..1d3399d2 100644 --- a/services/engine/src/databreeze_engine/registry.py +++ b/services/engine/src/databreeze_engine/registry.py @@ -10,6 +10,7 @@ from importlib.resources import files from types import MappingProxyType +from .folder_autopilot_contracts import AutopilotPlan, AutopilotPlanRequest from .handler import ActionHandler from .models import ( ActionManifest, @@ -20,6 +21,19 @@ SpreadsheetAuditProcessorResult, ) from .processors import handle_spreadsheet_auditor, metadata_digest +from .processors.folder_autopilot_action import ( + ACTION_TYPE as FOLDER_AUTOPILOT_ACTION_TYPE, +) +from .processors.folder_autopilot_action import ( + ACTION_VERSION as FOLDER_AUTOPILOT_ACTION_VERSION, +) +from .processors.folder_autopilot_action import ( + INPUT_SCHEMA_ID as FOLDER_AUTOPILOT_INPUT_SCHEMA_ID, +) +from .processors.folder_autopilot_action import ( + OUTPUT_SCHEMA_ID as FOLDER_AUTOPILOT_OUTPUT_SCHEMA_ID, +) +from .processors.folder_autopilot_action import handle as handle_folder_autopilot from .processors.spreadsheet_auditor_action import ( ACTION_TYPE as SPREADSHEET_AUDITOR_ACTION_TYPE, ) @@ -39,6 +53,9 @@ REVIEWED_SPREADSHEET_AUDITOR_HANDLER_DIGEST = ( "sha256:9f2f92194aa2e08e79afaeb791f67a481e35c183b2b359f83348eea67389b079" ) +REVIEWED_FOLDER_AUTOPILOT_HANDLER_DIGEST = ( + "sha256:4a07508954ea27e31c608ef63aff75e0e2e7f9ad3c62288dad89111415163f54" +) class RegistryError(Exception): @@ -103,6 +120,26 @@ def _verify_reviewed_spreadsheet_auditor_artifact(content: bytes | None = None) raise RegistryError("HANDLER_ARTIFACT_DIGEST_MISMATCH") +def _verify_reviewed_folder_autopilot_artifact(content: bytes | None = None) -> None: + artifact = content + if artifact is None: + try: + processors = files("databreeze_engine.processors") + artifact = b"\0".join( + processors.joinpath(name).read_bytes() + for name in ( + "folder_autopilot_action.py", + "folder_autopilot.py", + "folder_autopilot_plan.py", + ) + ) + except OSError: + raise RegistryError("HANDLER_ARTIFACT_UNAVAILABLE") from None + actual = "sha256:" + hashlib.sha256(artifact).hexdigest() + if actual != REVIEWED_FOLDER_AUTOPILOT_HANDLER_DIGEST: + raise RegistryError("HANDLER_ARTIFACT_DIGEST_MISMATCH") + + def _validate_action_boundary(action_type: str) -> None: action_boundary = action_type.replace("_", "-").split(".") prohibited_tokens = { @@ -188,11 +225,46 @@ def _reviewed_spreadsheet_auditor_definition() -> _ActionDefinition: return _ActionDefinition(manifest=manifest, handler=handle_spreadsheet_auditor) +def _reviewed_folder_autopilot_definition() -> _ActionDefinition: + _verify_reviewed_folder_autopilot_artifact() + manifest = ActionManifest( + actionType=FOLDER_AUTOPILOT_ACTION_TYPE, + actionVersion=FOLDER_AUTOPILOT_ACTION_VERSION, + handlerDigest=REVIEWED_FOLDER_AUTOPILOT_HANDLER_DIGEST, + engineVersion="0.1.0", + protocolVersion="1.0", + inputSchemaId=FOLDER_AUTOPILOT_INPUT_SCHEMA_ID, + outputSchemaId=FOLDER_AUTOPILOT_OUTPUT_SCHEMA_ID, + executionModes=("LOCAL",), + executionTargets=("DESKTOP",), + dataModes=("LOCAL",), + requiredCapabilities=("metadata.read",), + sideEffectClass="NONE", + riskClass="READ_ONLY", + determinism="DETERMINISTIC", + seedPolicy="NONE", + resources=ResourceLimits( + maxInputBytes=16 * 1024 * 1024, + maxOutputBytes=1024 * 1024, + maxMemoryBytes=64 * 1024 * 1024, + maxTemporaryStorageBytes=0, + maxDurationMilliseconds=5_000, + progressCadenceMilliseconds=500, + ), + networkPermitted=False, + filesystemWritesPermitted=False, + externalProvidersPermitted=False, + ) + return _ActionDefinition(manifest=manifest, handler=handle_folder_autopilot) + + def validate_action_parameters(manifest: ActionManifest, parameters: object) -> bool: if manifest.inputSchemaId == "foundation.metadata-fixture.v1": return isinstance(parameters, FoundationMetadataParameters) if manifest.inputSchemaId == SPREADSHEET_AUDITOR_INPUT_SCHEMA_ID: return isinstance(parameters, SpreadsheetAuditParameters) + if manifest.inputSchemaId == FOLDER_AUTOPILOT_INPUT_SCHEMA_ID: + return isinstance(parameters, AutopilotPlanRequest) return True @@ -201,6 +273,8 @@ def validate_action_output(manifest: ActionManifest, output: object) -> bool: return isinstance(output, FoundationDigestResult) if manifest.outputSchemaId == SPREADSHEET_AUDITOR_OUTPUT_SCHEMA_ID: return isinstance(output, SpreadsheetAuditProcessorResult) + if manifest.outputSchemaId == FOLDER_AUTOPILOT_OUTPUT_SCHEMA_ID: + return isinstance(output, AutopilotPlan) return True @@ -213,7 +287,11 @@ class ActionRegistry: _manifests: tuple[ActionManifest, ...] = field(init=False, repr=False) def __init__(self) -> None: - definitions = (_reviewed_definition(), _reviewed_spreadsheet_auditor_definition()) + definitions = ( + _reviewed_definition(), + _reviewed_spreadsheet_auditor_definition(), + _reviewed_folder_autopilot_definition(), + ) for definition in definitions: _validate_action_boundary(definition.manifest.actionType) actions = { diff --git a/services/engine/tests/test_folder_autopilot_action.py b/services/engine/tests/test_folder_autopilot_action.py new file mode 100644 index 00000000..0733394a --- /dev/null +++ b/services/engine/tests/test_folder_autopilot_action.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from databreeze_engine.dispatcher import dispatch_execution +from databreeze_engine.models import EngineExecutionRequest +from databreeze_engine.processors.folder_autopilot import build_file_observation +from databreeze_engine.registry import default_registry + + +def _payload(action: dict[str, str]) -> dict[str, Any]: + return { + "protocolVersion": "1.0", + "requestId": "00000000-0000-4000-8000-000000000101", + "attemptId": "00000000-0000-4000-8000-000000000102", + "correlation": {"correlationId": "00000000-0000-4000-8000-000000000103"}, + "action": action, + "inputHandles": [], + "outputHandle": { + "handleId": "output-folder-plan", + "byteLength": 1_048_576, + "sha256": "b" * 64, + "schemaId": "folder-autopilot.plan-result.v1", + }, + "parameters": { + "recipeVersionId": "recipe-001", + "assignmentId": "assignment-001", + "observation": { + "observationId": "obs-001", + "displayName": "invoice.csv", + "sizeBytes": 12, + "modifiedAtNs": "10", + "contentSha256": "a" * 64, + "stableExecutionKey": build_file_observation( + observation_id="obs-001", + display_name="invoice.csv", + size_bytes=12, + modified_at_ns="10", + content_sha256="a" * 64, + ).stableExecutionKey, + }, + "allowedOutputBindingIds": ["binding-out"], + "existingDestinations": [], + "steps": [ + { + "stepId": "inspect", + "action": "INSPECT", + "collisionPolicy": "REVIEW", + "requiresApproval": False, + } + ], + }, + "deadline": "2099-01-01T00:00:00Z", + "locale": "vi-VN", + } + + +def test_registry_exposes_content_free_folder_plan_action() -> None: + manifest = next( + manifest + for manifest in default_registry().manifests + if manifest.actionType == "folder-autopilot.plan-evaluate" + ) + assert manifest.inputSchemaId == "folder-autopilot.plan-request.v1" + assert manifest.outputSchemaId == "folder-autopilot.plan-result.v1" + assert manifest.filesystemWritesPermitted is False + assert manifest.networkPermitted is False + assert manifest.externalProvidersPermitted is False + + +def test_dispatch_evaluates_typed_folder_plan_without_input_bytes() -> None: + manifest = next( + manifest + for manifest in default_registry().manifests + if manifest.actionType == "folder-autopilot.plan-evaluate" + ) + request = EngineExecutionRequest.model_validate( + _payload( + { + "type": manifest.actionType, + "version": manifest.actionVersion, + "handlerDigest": manifest.handlerDigest, + } + ) + ) + + result = dispatch_execution( + request, + wall_clock=lambda: datetime(2026, 1, 1, tzinfo=UTC), + monotonic_clock=lambda: 1.0, + ) + + assert result.status == "SUCCEEDED" + assert result.output.status == "READY" + assert result.output.operations[0].action == "INSPECT" diff --git a/services/engine/tests/test_folder_autopilot_observation.py b/services/engine/tests/test_folder_autopilot_observation.py new file mode 100644 index 00000000..a5b4d8bc --- /dev/null +++ b/services/engine/tests/test_folder_autopilot_observation.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from databreeze_engine.processors.folder_autopilot import ( + FileObservation, + build_file_observation, + fingerprint_bytes, +) + + +def test_fingerprint_and_observation_are_deterministic_and_content_free() -> None: + content = b"invoice content" + fingerprint = fingerprint_bytes(content) + first = build_file_observation( + observation_id="obs-001", + display_name="Hóa đơn 01.xlsx", + size_bytes=len(content), + modified_at_ns="123", + content_sha256=fingerprint, + ) + second = build_file_observation( + observation_id="obs-001", + display_name="Hóa đơn 01.xlsx", + size_bytes=len(content), + modified_at_ns="123", + content_sha256=hashlib.sha256(content).hexdigest(), + ) + + assert first == second + assert first.stableExecutionKey == second.stableExecutionKey + assert first.contentSha256 == fingerprint + assert "path" not in first.model_dump() + assert "content" not in first.model_dump() + + +def test_observation_key_changes_when_fingerprint_or_timestamp_changes() -> None: + base = build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=4, + modified_at_ns="10", + content_sha256="a" * 64, + ) + changed_content = build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=4, + modified_at_ns="10", + content_sha256="b" * 64, + ) + changed_time = build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=4, + modified_at_ns="11", + content_sha256="a" * 64, + ) + + assert base.stableExecutionKey != changed_content.stableExecutionKey + assert base.stableExecutionKey != changed_time.stableExecutionKey + + +@pytest.mark.parametrize( + "name", ["..", ".", "nested\\file.csv", "nested/file.csv", "line\nfeed.csv"] +) +def test_observation_rejects_path_like_or_control_names(name: str) -> None: + with pytest.raises(ValueError, match="INVALID_OBSERVATION"): + build_file_observation( + observation_id="obs-001", + display_name=name, + size_bytes=1, + modified_at_ns="1", + content_sha256="a" * 64, + ) + + +def test_observation_rejects_invalid_fingerprint_and_bounds() -> None: + with pytest.raises(ValueError): + build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=512 * 1024 * 1024 + 1, + modified_at_ns="1", + content_sha256="not-a-digest", + ) + + with pytest.raises(ValueError): + FileObservation.model_validate( + { + "observationId": "obs-001", + "displayName": "report.csv", + "sizeBytes": 1, + "modifiedAtNs": "1", + "contentSha256": "a" * 64, + "stableExecutionKey": "b" * 64, + "path": "C:\\secret", + } + ) + + +def test_observation_normalizes_invalid_timestamp_key_failures() -> None: + with pytest.raises(ValueError, match="INVALID_OBSERVATION"): + build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=1, + modified_at_ns="not-a-number", + content_sha256="a" * 64, + ) diff --git a/services/engine/tests/test_folder_autopilot_plan.py b/services/engine/tests/test_folder_autopilot_plan.py new file mode 100644 index 00000000..2df9f838 --- /dev/null +++ b/services/engine/tests/test_folder_autopilot_plan.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import pytest + +from databreeze_engine.processors.folder_autopilot import build_file_observation +from databreeze_engine.processors.folder_autopilot_plan import ( + AutopilotPlanRequest, + CollisionPolicy, + DestinationState, + PlanEvaluationError, + PlanStep, + evaluate_autopilot_plan, +) + + +def _observation(): + return build_file_observation( + observation_id="obs-001", + display_name="invoice.csv", + size_bytes=12, + modified_at_ns="10", + content_sha256="a" * 64, + ) + + +def _request(*steps: PlanStep, destinations: tuple[DestinationState, ...] = ()): + return AutopilotPlanRequest( + recipeVersionId="recipe-001", + assignmentId="assignment-001", + observation=_observation(), + allowedOutputBindingIds=("binding-out",), + existingDestinations=destinations, + steps=steps, + ) + + +def test_evaluator_returns_typed_deterministic_operations_and_hash() -> None: + request = _request( + PlanStep(stepId="inspect", action="INSPECT"), + PlanStep(stepId="validate", action="VALIDATE"), + PlanStep( + stepId="rename", + action="RENAME", + destinationBindingId="binding-out", + destinationName="invoice-reviewed.csv", + ), + ) + + first = evaluate_autopilot_plan(request) + second = evaluate_autopilot_plan(request) + + assert first.status == "READY" + assert [operation.action for operation in first.operations] == [ + "INSPECT", + "VALIDATE", + "RENAME", + ] + assert first.operations[-1].destinationName == "invoice-reviewed.csv" + assert first.planHash == second.planHash + assert first.operations == second.operations + + +@pytest.mark.parametrize("policy", ["REVIEW", "SKIP", "UNIQUE_NAME"]) +def test_collision_policy_is_explicit_and_never_overwrites(policy: CollisionPolicy) -> None: + step = PlanStep( + stepId="copy", + action="COPY", + destinationBindingId="binding-out", + destinationName="invoice.csv", + collisionPolicy=policy, + ) + request = _request( + step, + destinations=( + DestinationState(bindingId="binding-out", displayName="invoice.csv", occupied=True), + ), + ) + + result = evaluate_autopilot_plan(request) + + if policy == "REVIEW": + assert result.status == "REVIEW" + assert result.operations[0].requiresApproval is True + assert "DESTINATION_COLLISION" in result.reasonCodes + elif policy == "SKIP": + assert result.status == "SKIPPED" + assert result.operations == () + assert result.reasonCodes == ("DESTINATION_COLLISION_SKIPPED",) + else: + assert result.status == "READY" + assert result.operations[0].destinationName == "invoice (1).csv" + assert result.operations[0].requiresApproval is False + + +def test_unique_name_generation_is_bounded_and_deterministic() -> None: + step = PlanStep( + stepId="copy", + action="COPY", + destinationBindingId="binding-out", + destinationName="invoice.csv", + collisionPolicy="UNIQUE_NAME", + ) + occupied = tuple( + [ + DestinationState(bindingId="binding-out", displayName="invoice.csv", occupied=True), + *( + DestinationState( + bindingId="binding-out", displayName=f"invoice ({i}).csv", occupied=True + ) + for i in range(1, 101) + ), + ] + ) + + result = evaluate_autopilot_plan(_request(step, destinations=occupied)) + + assert result.status == "READY" + assert result.operations[0].destinationName == "invoice (101).csv" + + +def test_destination_collisions_use_windows_case_folding() -> None: + result = evaluate_autopilot_plan( + _request( + PlanStep( + stepId="copy-case", + action="COPY", + destinationBindingId="binding-out", + destinationName="Invoice.csv", + collisionPolicy="REVIEW", + ), + destinations=( + DestinationState(bindingId="binding-out", displayName="invoice.csv", occupied=True), + ), + ) + ) + assert result.status == "REVIEW" + assert "DESTINATION_COLLISION" in result.reasonCodes + + +def test_unique_name_generation_preserves_the_255_character_contract_limit() -> None: + name = f"{'a' * 251}.csv" + result = evaluate_autopilot_plan( + _request( + PlanStep( + stepId="copy-long", + action="COPY", + destinationBindingId="binding-out", + destinationName=name, + collisionPolicy="UNIQUE_NAME", + ), + destinations=( + DestinationState(bindingId="binding-out", displayName=name, occupied=True), + ), + ) + ) + assert result.status == "READY" + assert result.operations[0].destinationName is not None + assert len(result.operations[0].destinationName) <= 255 + + +def test_evaluator_rejects_unbound_destinations_and_untyped_actions() -> None: + with pytest.raises(PlanEvaluationError, match="DESTINATION_BINDING_NOT_ALLOWED"): + evaluate_autopilot_plan( + _request( + PlanStep( + stepId="move", + action="MOVE", + destinationBindingId="other-binding", + destinationName="invoice.csv", + ) + ) + ) + + with pytest.raises(ValueError): + PlanStep(stepId="shell", action="RUN_SHELL") + + +def test_evaluator_rejects_unbounded_steps_and_path_like_destination_names() -> None: + with pytest.raises(ValueError): + AutopilotPlanRequest( + recipeVersionId="recipe-001", + assignmentId="assignment-001", + observation=_observation(), + allowedOutputBindingIds=("binding-out",), + existingDestinations=(), + steps=tuple(PlanStep(stepId=f"step-{i}", action="INSPECT") for i in range(101)), + ) + + with pytest.raises(ValueError): + PlanStep( + stepId="rename", + action="RENAME", + destinationBindingId="binding-out", + destinationName="..\\escape.txt", + ) diff --git a/tools/fixture-validation/python/run_fixtures.py b/tools/fixture-validation/python/run_fixtures.py index 4a970075..95d67048 100644 --- a/tools/fixture-validation/python/run_fixtures.py +++ b/tools/fixture-validation/python/run_fixtures.py @@ -9,12 +9,15 @@ from databreeze_contracts.v1 import ( ActorMetadata, + AutopilotFolderBinding, CommandEnvelope, CorrelationMetadata, CursorPage, EventEnvelope, + FolderAutopilotProfile, Identifier, ProblemDetails, + RecipeAssignment, Revision, TenantScope, UtcTimestamp, @@ -23,12 +26,15 @@ SCHEMA_BASE = "https://schemas.databreeze.dev/contracts/v1" ADAPTERS: dict[str, TypeAdapter[Any]] = { f"{SCHEMA_BASE}/actor-metadata": TypeAdapter(ActorMetadata), + f"{SCHEMA_BASE}/autopilot-folder-binding": TypeAdapter(AutopilotFolderBinding), f"{SCHEMA_BASE}/command-envelope": TypeAdapter(CommandEnvelope[dict[str, Any]]), f"{SCHEMA_BASE}/correlation-metadata": TypeAdapter(CorrelationMetadata), f"{SCHEMA_BASE}/cursor-page": TypeAdapter(CursorPage[Any]), f"{SCHEMA_BASE}/event-envelope": TypeAdapter(EventEnvelope[dict[str, Any]]), + f"{SCHEMA_BASE}/folder-autopilot-profile": TypeAdapter(FolderAutopilotProfile), f"{SCHEMA_BASE}/identifier": TypeAdapter(Identifier), f"{SCHEMA_BASE}/problem-details": TypeAdapter(ProblemDetails), + f"{SCHEMA_BASE}/recipe-assignment": TypeAdapter(RecipeAssignment), f"{SCHEMA_BASE}/revision": TypeAdapter(Revision), f"{SCHEMA_BASE}/tenant-scope": TypeAdapter(TenantScope), f"{SCHEMA_BASE}/utc-timestamp": TypeAdapter(UtcTimestamp), diff --git a/tools/fixture-validation/test/contract-parity.test.mjs b/tools/fixture-validation/test/contract-parity.test.mjs index 0a1aee09..371085ce 100644 --- a/tools/fixture-validation/test/contract-parity.test.mjs +++ b/tools/fixture-validation/test/contract-parity.test.mjs @@ -246,9 +246,9 @@ test('the real TypeScript Python and Kotlin consumers agree on every shared fixt ); assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`); assert.deepEqual(JSON.parse(run.stdout), { - caseCount: 28, - expectedAccepted: 14, - expectedRejected: 14, + caseCount: 34, + expectedAccepted: 17, + expectedRejected: 17, runtimes: ['typescript', 'python', 'kotlin'], }); assert.deepEqual( @@ -260,6 +260,6 @@ test('the real TypeScript Python and Kotlin consumers agree on every shared fixt test('fixture expectations stay independently balanced', () => { const manifest = JSON.parse(readFileSync(fixtureManifestPath, 'utf8')); - assert.equal(manifest.cases.filter((fixtureCase) => fixtureCase.expectedAcceptance).length, 14); - assert.equal(manifest.cases.filter((fixtureCase) => !fixtureCase.expectedAcceptance).length, 14); + assert.equal(manifest.cases.filter((fixtureCase) => fixtureCase.expectedAcceptance).length, 17); + assert.equal(manifest.cases.filter((fixtureCase) => !fixtureCase.expectedAcceptance).length, 17); });