mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(android): add Skill Workshop settings panel (#101911)
* feat(android): add Skill Workshop settings * fix(android): scope Skill Workshop state by agent * fix(android): confirm Skill Workshop proposal actions * fix(android): strengthen Skill Workshop action proof gates * fix(android): satisfy Skill Workshop action ktlint * fix(android): serialize Skill Workshop lifecycle actions * chore(android): refresh native i18n inventory --------- Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
c9ec548e6f
commit
6fef79a28d
10 changed files with 2152 additions and 216 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
|
|||
- [x] Authenticated background presence beacons
|
||||
- [x] Voice tab full functionality
|
||||
- [x] Screen tab full functionality
|
||||
- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs
|
||||
|
||||
## Open in Android Studio
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ class MainViewModel(
|
|||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
|
||||
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> =
|
||||
runtimeState(initial = GatewayConnectionDisplay(false, "Offline", null)) { it.gatewayConnectionDisplay }
|
||||
val operatorAdminScopeAvailable: StateFlow<Boolean> = runtimeState(initial = false) { it.operatorAdminScopeAvailable }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
|
|
@ -184,6 +185,13 @@ class MainViewModel(
|
|||
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
|
||||
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
|
||||
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
|
||||
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> =
|
||||
runtimeState(initial = GatewaySkillWorkshopSummary(proposals = emptyList())) { it.skillWorkshopSummary }
|
||||
val skillWorkshopRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillWorkshopRefreshing }
|
||||
val skillWorkshopErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopErrorText }
|
||||
val skillWorkshopNoticeText: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopNoticeText }
|
||||
val skillWorkshopInspectingProposalId: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopInspectingProposalId }
|
||||
val skillWorkshopMutatingProposalId: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopMutatingProposalId }
|
||||
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
|
||||
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
|
||||
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
|
||||
|
|
@ -711,6 +719,46 @@ class MainViewModel(
|
|||
ensureRuntime().refreshSkills()
|
||||
}
|
||||
|
||||
fun refreshSkillWorkshopProposals(agentId: String? = null) {
|
||||
ensureRuntime().refreshSkillWorkshopProposals(agentId = agentId)
|
||||
}
|
||||
|
||||
fun resetSkillWorkshopAgentScope(agentId: String? = null) {
|
||||
ensureRuntime().resetSkillWorkshopAgentScope(agentId = agentId)
|
||||
}
|
||||
|
||||
fun inspectSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
ensureRuntime().inspectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
|
||||
}
|
||||
|
||||
fun applySkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
ensureRuntime().applySkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
|
||||
}
|
||||
|
||||
fun rejectSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
ensureRuntime().rejectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
|
||||
}
|
||||
|
||||
fun quarantineSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
ensureRuntime().quarantineSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
|
||||
}
|
||||
|
||||
fun clearSkillWorkshopMessage() {
|
||||
ensureRuntime().clearSkillWorkshopMessage()
|
||||
}
|
||||
|
||||
fun refreshNodesDevices() {
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,10 +89,13 @@ import kotlinx.coroutines.channels.Channel
|
|||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -113,6 +116,18 @@ import java.util.concurrent.atomic.AtomicReference
|
|||
|
||||
private const val MAX_PENDING_NOTIFICATION_EVENTS = 128
|
||||
private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
|
||||
private const val OperatorAdminScope = "operator.admin"
|
||||
|
||||
private enum class SkillWorkshopGatewayAction(
|
||||
val methodSuffix: String,
|
||||
val expectedStatus: String,
|
||||
val notice: String,
|
||||
val verb: String,
|
||||
) {
|
||||
Apply("apply", "applied", "Proposal applied.", "apply"),
|
||||
Reject("reject", "rejected", "Proposal rejected.", "reject"),
|
||||
Quarantine("quarantine", "quarantined", "Proposal quarantined.", "quarantine"),
|
||||
}
|
||||
|
||||
internal data class PendingNotificationNodeEvent(
|
||||
val event: String,
|
||||
|
|
@ -626,6 +641,12 @@ class NodeRuntime private constructor(
|
|||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
|
||||
private val _operatorScopes = MutableStateFlow<List<String>>(emptyList())
|
||||
val operatorScopes: StateFlow<List<String>> = _operatorScopes.asStateFlow()
|
||||
val operatorAdminScopeAvailable: StateFlow<Boolean> =
|
||||
operatorScopes
|
||||
.map { scopes -> scopes.any { it == OperatorAdminScope } }
|
||||
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
|
|
@ -708,6 +729,21 @@ class NodeRuntime private constructor(
|
|||
val skillsRefreshing: StateFlow<Boolean> = _skillsRefreshing.asStateFlow()
|
||||
private val _skillsErrorText = MutableStateFlow<String?>(null)
|
||||
val skillsErrorText: StateFlow<String?> = _skillsErrorText.asStateFlow()
|
||||
private val _skillWorkshopSummary = MutableStateFlow(GatewaySkillWorkshopSummary(proposals = emptyList()))
|
||||
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> = _skillWorkshopSummary.asStateFlow()
|
||||
private val _skillWorkshopRefreshing = MutableStateFlow(false)
|
||||
val skillWorkshopRefreshing: StateFlow<Boolean> = _skillWorkshopRefreshing.asStateFlow()
|
||||
private val _skillWorkshopErrorText = MutableStateFlow<String?>(null)
|
||||
val skillWorkshopErrorText: StateFlow<String?> = _skillWorkshopErrorText.asStateFlow()
|
||||
private val _skillWorkshopNoticeText = MutableStateFlow<String?>(null)
|
||||
val skillWorkshopNoticeText: StateFlow<String?> = _skillWorkshopNoticeText.asStateFlow()
|
||||
private val _skillWorkshopInspectingProposalId = MutableStateFlow<String?>(null)
|
||||
val skillWorkshopInspectingProposalId: StateFlow<String?> = _skillWorkshopInspectingProposalId.asStateFlow()
|
||||
private val _skillWorkshopMutatingProposalId = MutableStateFlow<String?>(null)
|
||||
val skillWorkshopMutatingProposalId: StateFlow<String?> = _skillWorkshopMutatingProposalId.asStateFlow()
|
||||
private val skillWorkshopListSeq = AtomicLong(0)
|
||||
private val skillWorkshopInspectSeq = AtomicLong(0)
|
||||
private val skillWorkshopMutationSeq = AtomicLong(0)
|
||||
private val _nodesDevicesSummary =
|
||||
MutableStateFlow(
|
||||
GatewayNodesDevicesSummary(
|
||||
|
|
@ -791,6 +827,7 @@ class NodeRuntime private constructor(
|
|||
_remoteAddress.value = hello.remoteAddress
|
||||
_gatewayVersion.value = hello.serverVersion
|
||||
_gatewayUpdateAvailable.value = hello.updateAvailable
|
||||
_operatorScopes.value = normalizeOperatorScopes(hello.authScopes)
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
|
||||
// Every successful connection refreshes history; reconnects preserve local run ownership.
|
||||
|
|
@ -839,6 +876,7 @@ class NodeRuntime private constructor(
|
|||
_remoteAddress.value = null
|
||||
_gatewayVersion.value = null
|
||||
_gatewayUpdateAvailable.value = null
|
||||
_operatorScopes.value = emptyList()
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_gatewayDefaultAgentId.value = null
|
||||
_gatewayAgents.value = emptyList()
|
||||
|
|
@ -859,6 +897,15 @@ class NodeRuntime private constructor(
|
|||
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
|
||||
_skillsRefreshing.value = false
|
||||
_skillsErrorText.value = null
|
||||
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(proposals = emptyList())
|
||||
_skillWorkshopRefreshing.value = false
|
||||
_skillWorkshopErrorText.value = null
|
||||
_skillWorkshopNoticeText.value = null
|
||||
_skillWorkshopInspectingProposalId.value = null
|
||||
_skillWorkshopMutatingProposalId.value = null
|
||||
skillWorkshopListSeq.incrementAndGet()
|
||||
skillWorkshopInspectSeq.incrementAndGet()
|
||||
skillWorkshopMutationSeq.incrementAndGet()
|
||||
_nodesDevicesSummary.value =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = emptyList(),
|
||||
|
|
@ -1377,6 +1424,74 @@ class NodeRuntime private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun refreshSkillWorkshopProposals(agentId: String? = null) {
|
||||
scope.launch {
|
||||
refreshSkillWorkshopProposalsFromGateway(agentId = agentId)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSkillWorkshopAgentScope(agentId: String? = null) {
|
||||
val normalizedAgentId = normalizeSkillWorkshopAgentId(agentId)
|
||||
skillWorkshopListSeq.incrementAndGet()
|
||||
skillWorkshopInspectSeq.incrementAndGet()
|
||||
skillWorkshopMutationSeq.incrementAndGet()
|
||||
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = normalizedAgentId, proposals = emptyList())
|
||||
_skillWorkshopRefreshing.value = false
|
||||
_skillWorkshopErrorText.value = null
|
||||
_skillWorkshopNoticeText.value = null
|
||||
_skillWorkshopInspectingProposalId.value = null
|
||||
_skillWorkshopMutatingProposalId.value = null
|
||||
}
|
||||
|
||||
fun inspectSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
val normalized = proposalId.trim()
|
||||
if (normalized.isEmpty()) return
|
||||
scope.launch {
|
||||
inspectSkillWorkshopProposalFromGateway(proposalId = normalized, agentId = agentId)
|
||||
}
|
||||
}
|
||||
|
||||
fun applySkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Apply)
|
||||
}
|
||||
|
||||
fun rejectSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Reject)
|
||||
}
|
||||
|
||||
fun quarantineSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String? = null,
|
||||
) {
|
||||
mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Quarantine)
|
||||
}
|
||||
|
||||
private fun mutateSkillWorkshopProposal(
|
||||
proposalId: String,
|
||||
agentId: String?,
|
||||
action: SkillWorkshopGatewayAction,
|
||||
) {
|
||||
val normalized = proposalId.trim()
|
||||
if (normalized.isEmpty()) return
|
||||
scope.launch {
|
||||
mutateSkillWorkshopProposalOnGateway(proposalId = normalized, agentId = agentId, action = action)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSkillWorkshopMessage() {
|
||||
_skillWorkshopErrorText.value = null
|
||||
_skillWorkshopNoticeText.value = null
|
||||
}
|
||||
|
||||
fun refreshNodesDevices() {
|
||||
if (mode == NodeRuntimeMode.ScreenshotFixture) return
|
||||
scope.launch {
|
||||
|
|
@ -3602,6 +3717,246 @@ class NodeRuntime private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSkillWorkshopProposalsFromGateway(agentId: String?) {
|
||||
val listSeq = skillWorkshopListSeq.incrementAndGet()
|
||||
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
|
||||
val gatewayScope = captureGatewayDataScope()
|
||||
if (gatewayScope == null || !operatorConnected) {
|
||||
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList())
|
||||
_skillWorkshopRefreshing.value = false
|
||||
_skillWorkshopErrorText.value = "Connect the gateway to load Skill Workshop proposals."
|
||||
return
|
||||
}
|
||||
publishGatewayData(gatewayScope) {
|
||||
_skillWorkshopRefreshing.value = true
|
||||
_skillWorkshopErrorText.value = null
|
||||
if (_skillWorkshopSummary.value.agentId != requestAgentId) {
|
||||
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList())
|
||||
_skillWorkshopNoticeText.value = null
|
||||
_skillWorkshopInspectingProposalId.value = null
|
||||
_skillWorkshopMutatingProposalId.value = null
|
||||
skillWorkshopInspectSeq.incrementAndGet()
|
||||
skillWorkshopMutationSeq.incrementAndGet()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val res =
|
||||
requestGatewayData(
|
||||
gatewayScope,
|
||||
"skills.proposals.list",
|
||||
skillWorkshopParams(agentId = agentId).toString(),
|
||||
)
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val previousById =
|
||||
_skillWorkshopSummary.value
|
||||
.takeIf { it.agentId == requestAgentId }
|
||||
?.proposals
|
||||
?.associateBy { it.id }
|
||||
.orEmpty()
|
||||
val proposals = parseSkillWorkshopProposals(root?.get("proposals") as? JsonArray, previousById)
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = proposals)
|
||||
}
|
||||
}
|
||||
} catch (err: CancellationException) {
|
||||
throw err
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopErrorText.value = "Could not load Skill Workshop proposals."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopRefreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun inspectSkillWorkshopProposalFromGateway(
|
||||
proposalId: String,
|
||||
agentId: String?,
|
||||
) {
|
||||
var inspectSeq = 0L
|
||||
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
|
||||
val gatewayScope = captureGatewayDataScope()
|
||||
if (gatewayScope == null || !operatorConnected) {
|
||||
_skillWorkshopErrorText.value = "Connect the gateway to inspect Skill Workshop proposals."
|
||||
return
|
||||
}
|
||||
var inspectStarted = false
|
||||
val scopeCurrent =
|
||||
publishGatewayData(gatewayScope) {
|
||||
val currentSummary = _skillWorkshopSummary.value
|
||||
if (
|
||||
currentSummary.agentId == requestAgentId &&
|
||||
currentSummary.proposals.any { it.id == proposalId } &&
|
||||
_skillWorkshopMutatingProposalId.value == null
|
||||
) {
|
||||
inspectStarted = true
|
||||
inspectSeq = skillWorkshopInspectSeq.incrementAndGet()
|
||||
_skillWorkshopInspectingProposalId.value = proposalId
|
||||
_skillWorkshopErrorText.value = null
|
||||
}
|
||||
}
|
||||
if (!scopeCurrent || !inspectStarted) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res =
|
||||
requestGatewayData(
|
||||
gatewayScope,
|
||||
"skills.proposals.inspect",
|
||||
skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(),
|
||||
)
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val previous =
|
||||
_skillWorkshopSummary.value
|
||||
.takeIf { it.agentId == requestAgentId }
|
||||
?.proposals
|
||||
?.firstOrNull { it.id == proposalId }
|
||||
val inspected =
|
||||
parseSkillWorkshopProposalInspect(root, previous)
|
||||
?: throw IllegalStateException("skills.proposals.inspect returned no proposal")
|
||||
publishGatewayData(gatewayScope) {
|
||||
val currentSummary = _skillWorkshopSummary.value
|
||||
if (
|
||||
skillWorkshopInspectSeq.get() == inspectSeq &&
|
||||
currentSummary.agentId == requestAgentId &&
|
||||
currentSummary.proposals.any { it.id == proposalId }
|
||||
) {
|
||||
_skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(inspected)
|
||||
}
|
||||
}
|
||||
} catch (err: CancellationException) {
|
||||
throw err
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopErrorText.value = "Could not inspect Skill Workshop proposal."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopInspectingProposalId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mutateSkillWorkshopProposalOnGateway(
|
||||
proposalId: String,
|
||||
agentId: String?,
|
||||
action: SkillWorkshopGatewayAction,
|
||||
) {
|
||||
var mutationSeq = 0L
|
||||
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
|
||||
if (!operatorAdminScopeAvailable.value) {
|
||||
_skillWorkshopErrorText.value = "Skill Workshop proposal actions require operator.admin scope."
|
||||
return
|
||||
}
|
||||
val gatewayScope = captureGatewayDataScope()
|
||||
if (gatewayScope == null || !operatorConnected) {
|
||||
_skillWorkshopErrorText.value = "Connect the gateway to update Skill Workshop proposals."
|
||||
return
|
||||
}
|
||||
var mutationStarted = false
|
||||
val scopeCurrent =
|
||||
publishGatewayData(gatewayScope) {
|
||||
val currentSummary = _skillWorkshopSummary.value
|
||||
if (
|
||||
currentSummary.agentId == requestAgentId &&
|
||||
currentSummary.proposals.any { it.id == proposalId } &&
|
||||
_skillWorkshopMutatingProposalId.value == null
|
||||
) {
|
||||
mutationStarted = true
|
||||
mutationSeq = skillWorkshopMutationSeq.incrementAndGet()
|
||||
// A lifecycle action supersedes any older detail read. Without this
|
||||
// guard, a late inspect response can restore the pre-action status.
|
||||
skillWorkshopInspectSeq.incrementAndGet()
|
||||
_skillWorkshopInspectingProposalId.value = null
|
||||
_skillWorkshopMutatingProposalId.value = proposalId
|
||||
_skillWorkshopErrorText.value = null
|
||||
_skillWorkshopNoticeText.value = null
|
||||
}
|
||||
}
|
||||
if (!scopeCurrent || !mutationStarted) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res =
|
||||
requestGatewayData(
|
||||
gatewayScope,
|
||||
"skills.proposals.${action.methodSuffix}",
|
||||
skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(),
|
||||
)
|
||||
val updatedProposal =
|
||||
parseSkillWorkshopProposalActionResult(
|
||||
root = json.parseToJsonElement(res).asObjectOrNull(),
|
||||
previous =
|
||||
_skillWorkshopSummary.value
|
||||
.takeIf { it.agentId == requestAgentId }
|
||||
?.proposals
|
||||
?.firstOrNull { it.id == proposalId },
|
||||
)
|
||||
var mutationConfirmed = false
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
if (updatedProposal?.status == action.expectedStatus) {
|
||||
_skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(updatedProposal)
|
||||
_skillWorkshopNoticeText.value = action.notice
|
||||
mutationConfirmed = true
|
||||
} else {
|
||||
val statusLabel = updatedProposal?.status?.takeIf { it.isNotBlank() } ?: "unknown"
|
||||
_skillWorkshopErrorText.value =
|
||||
"Gateway returned status '$statusLabel' after ${action.verb}."
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mutationConfirmed) return
|
||||
var refreshStillCurrent = false
|
||||
publishGatewayData(gatewayScope) {
|
||||
refreshStillCurrent =
|
||||
skillWorkshopMutationSeq.get() == mutationSeq &&
|
||||
_skillWorkshopSummary.value.agentId == requestAgentId
|
||||
}
|
||||
if (refreshStillCurrent) {
|
||||
refreshSkillWorkshopProposalsFromGateway(agentId = agentId)
|
||||
}
|
||||
} catch (err: CancellationException) {
|
||||
throw err
|
||||
} catch (_: Throwable) {
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopErrorText.value = "Could not ${action.verb} Skill Workshop proposal."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
publishGatewayData(gatewayScope) {
|
||||
if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
|
||||
_skillWorkshopMutatingProposalId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeSkillWorkshopAgentId(agentId: String?): String = agentId?.trim().orEmpty()
|
||||
|
||||
private fun skillWorkshopParams(
|
||||
agentId: String?,
|
||||
proposalId: String? = null,
|
||||
): JsonObject =
|
||||
buildJsonObject {
|
||||
val normalizedAgentId = agentId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (normalizedAgentId != null) put("agentId", JsonPrimitive(normalizedAgentId))
|
||||
val normalizedProposalId = proposalId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (normalizedProposalId != null) put("proposalId", JsonPrimitive(normalizedProposalId))
|
||||
}
|
||||
|
||||
private suspend fun refreshNodesDevicesFromGateway() {
|
||||
val gatewayScope = captureGatewayDataScope() ?: return
|
||||
val refreshGeneration = nodeApprovalRefreshGuard.begin()
|
||||
|
|
@ -4199,6 +4554,113 @@ class NodeRuntime private constructor(
|
|||
)
|
||||
}.orEmpty()
|
||||
|
||||
private fun parseSkillWorkshopProposals(
|
||||
proposals: JsonArray?,
|
||||
previousById: Map<String, GatewaySkillWorkshopProposal>,
|
||||
): List<GatewaySkillWorkshopProposal> {
|
||||
val parsed =
|
||||
proposals?.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val id = obj.skillWorkshopString("id") ?: return@mapNotNull null
|
||||
val previous = previousById[id]
|
||||
val updatedAt = obj.skillWorkshopString("updatedAt").orEmpty()
|
||||
GatewaySkillWorkshopProposal(
|
||||
id = id,
|
||||
kind = obj.skillWorkshopString("kind") ?: "proposal",
|
||||
status = obj.skillWorkshopString("status") ?: "pending",
|
||||
title = obj.skillWorkshopString("title") ?: obj.skillWorkshopString("skillName") ?: id,
|
||||
description = obj.skillWorkshopString("description"),
|
||||
skillName = obj.skillWorkshopString("skillName") ?: id,
|
||||
skillKey = obj.skillWorkshopString("skillKey") ?: id,
|
||||
createdAt = obj.skillWorkshopString("createdAt").orEmpty(),
|
||||
updatedAt = updatedAt,
|
||||
scanState = obj.skillWorkshopString("scanState"),
|
||||
content = previous?.content?.takeIf { previous.updatedAt == updatedAt },
|
||||
supportFiles = previous?.supportFiles?.takeIf { previous.updatedAt == updatedAt }.orEmpty(),
|
||||
)
|
||||
}
|
||||
return parsed.orEmpty().sortedByDescending { it.updatedAt }
|
||||
}
|
||||
|
||||
private fun parseSkillWorkshopProposalInspect(
|
||||
root: JsonObject?,
|
||||
previous: GatewaySkillWorkshopProposal?,
|
||||
): GatewaySkillWorkshopProposal? {
|
||||
val source = root ?: return null
|
||||
val record = source["record"].asObjectOrNull() ?: return null
|
||||
val id = record.skillWorkshopString("id") ?: previous?.id ?: return null
|
||||
val target = record["target"].asObjectOrNull()
|
||||
val updatedAt = record.skillWorkshopString("updatedAt").orEmpty()
|
||||
return GatewaySkillWorkshopProposal(
|
||||
id = id,
|
||||
kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal",
|
||||
status = record.skillWorkshopString("status") ?: previous?.status ?: "pending",
|
||||
title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id,
|
||||
description = record.skillWorkshopString("description") ?: previous?.description,
|
||||
skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id,
|
||||
skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id,
|
||||
createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(),
|
||||
updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() },
|
||||
scanState = record.skillWorkshopString("scanState") ?: previous?.scanState,
|
||||
content = stripSkillWorkshopFrontmatter(source["content"].asStringOrNull().orEmpty()),
|
||||
supportFiles = parseSkillWorkshopSupportFiles(source["supportFiles"] as? JsonArray),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSkillWorkshopProposalActionResult(
|
||||
root: JsonObject?,
|
||||
previous: GatewaySkillWorkshopProposal?,
|
||||
): GatewaySkillWorkshopProposal? {
|
||||
val record =
|
||||
root?.get("record").asObjectOrNull()
|
||||
?: root?.takeIf { it.skillWorkshopString("status") != null }
|
||||
?: return null
|
||||
val id = record.skillWorkshopString("id") ?: previous?.id ?: return null
|
||||
val target = record["target"].asObjectOrNull()
|
||||
val updatedAt = record.skillWorkshopString("updatedAt").orEmpty()
|
||||
return GatewaySkillWorkshopProposal(
|
||||
id = id,
|
||||
kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal",
|
||||
status = record.skillWorkshopString("status") ?: previous?.status ?: "pending",
|
||||
title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id,
|
||||
description = record.skillWorkshopString("description") ?: previous?.description,
|
||||
skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id,
|
||||
skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id,
|
||||
createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(),
|
||||
updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() },
|
||||
scanState =
|
||||
record["scan"].asObjectOrNull()?.skillWorkshopString("state")
|
||||
?: record.skillWorkshopString("scanState")
|
||||
?: previous?.scanState,
|
||||
content = previous?.content,
|
||||
supportFiles = previous?.supportFiles.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSkillWorkshopSupportFiles(files: JsonArray?): List<GatewaySkillWorkshopSupportFile> {
|
||||
val parsed =
|
||||
files?.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val path = obj.skillWorkshopString("path") ?: return@mapNotNull null
|
||||
GatewaySkillWorkshopSupportFile(
|
||||
path = path,
|
||||
content = obj["content"].asStringOrNull()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
return parsed.orEmpty()
|
||||
}
|
||||
|
||||
private fun stripSkillWorkshopFrontmatter(content: String): String {
|
||||
val withoutFrontmatter = content.replace(Regex("(?s)^---\\r?\\n.*?\\r?\\n---\\r?\\n?"), "")
|
||||
return withoutFrontmatter.trim()
|
||||
}
|
||||
|
||||
private fun JsonObject.skillWorkshopString(key: String): String? =
|
||||
get(key)
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 }
|
||||
|
||||
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
|
||||
|
|
@ -4674,6 +5136,13 @@ internal fun operatorConnectScopesForAuth(
|
|||
return ConnectionManager.nativeClientOperatorScopes
|
||||
}
|
||||
|
||||
internal fun normalizeOperatorScopes(scopes: List<String>): List<String> =
|
||||
scopes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
|
|
@ -4767,6 +5236,38 @@ data class GatewaySkillsSummary(
|
|||
val skills: List<GatewaySkillSummary>,
|
||||
)
|
||||
|
||||
data class GatewaySkillWorkshopSummary(
|
||||
val agentId: String = "",
|
||||
val proposals: List<GatewaySkillWorkshopProposal>,
|
||||
) {
|
||||
fun withProposal(proposal: GatewaySkillWorkshopProposal): GatewaySkillWorkshopSummary =
|
||||
copy(
|
||||
proposals =
|
||||
(proposals.filterNot { it.id == proposal.id } + proposal)
|
||||
.sortedByDescending { it.updatedAt },
|
||||
)
|
||||
}
|
||||
|
||||
data class GatewaySkillWorkshopProposal(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val status: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val skillName: String,
|
||||
val skillKey: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val scanState: String?,
|
||||
val content: String? = null,
|
||||
val supportFiles: List<GatewaySkillWorkshopSupportFile> = emptyList(),
|
||||
)
|
||||
|
||||
data class GatewaySkillWorkshopSupportFile(
|
||||
val path: String,
|
||||
val content: String?,
|
||||
)
|
||||
|
||||
data class GatewaySkillSummary(
|
||||
val skillKey: String,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ data class GatewayHelloSummary(
|
|||
val serverVersion: String?,
|
||||
val mainSessionKey: String?,
|
||||
val updateAvailable: GatewayUpdateAvailableSummary?,
|
||||
val authRole: String? = null,
|
||||
val authScopes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class GatewayUpdateAvailableSummary(
|
||||
|
|
@ -887,6 +889,10 @@ class GatewaySession(
|
|||
): List<String>? =
|
||||
when (role.trim()) {
|
||||
"node" -> emptyList()
|
||||
// Setup-code bootstrap handoff is deliberately least-privilege. It never
|
||||
// persists operator.admin, so Skill Workshop lifecycle actions remain
|
||||
// disabled until shared token/password auth or an owner-approved scope
|
||||
// upgrade issues an admin-scoped operator device token.
|
||||
"operator" -> {
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
|
|
@ -993,6 +999,8 @@ class GatewaySession(
|
|||
serverVersion = serverVersion,
|
||||
mainSessionKey = nextMainSessionKey,
|
||||
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
|
||||
authRole = authRole,
|
||||
authScopes = authScopes,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ internal enum class SettingsRoute {
|
|||
CronJobs,
|
||||
Usage,
|
||||
Skills,
|
||||
SkillWorkshop,
|
||||
NodesDevices,
|
||||
Channels,
|
||||
Dreaming,
|
||||
|
|
@ -184,6 +185,7 @@ internal fun SettingsDetailScreen(
|
|||
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Skills -> SkillsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.SkillWorkshop -> SkillWorkshopSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.NodesDevices -> NodesDevicesSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Channels -> ChannelsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Dreaming -> DreamingSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ai.openclaw.app.GatewayDreamingSummary
|
|||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewaySkillSummary
|
||||
import ai.openclaw.app.GatewaySkillWorkshopSummary
|
||||
import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NodeRuntime
|
||||
|
|
@ -1438,6 +1439,7 @@ private fun SettingsShellScreen(
|
|||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val usageSummary by viewModel.usageSummary.collectAsState()
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val skillWorkshopSummary by viewModel.skillWorkshopSummary.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
|
||||
|
|
@ -1452,6 +1454,7 @@ private fun SettingsShellScreen(
|
|||
viewModel.refreshCronJobs()
|
||||
viewModel.refreshUsage()
|
||||
viewModel.refreshSkills()
|
||||
viewModel.refreshSkillWorkshopProposals()
|
||||
viewModel.refreshNodesDevices()
|
||||
viewModel.refreshChannels()
|
||||
viewModel.refreshDreaming()
|
||||
|
|
@ -1519,6 +1522,13 @@ private fun SettingsShellScreen(
|
|||
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
|
||||
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
|
||||
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
|
||||
SettingsRow(
|
||||
"Skill Workshop",
|
||||
skillWorkshopSummaryText(skillWorkshopSummary),
|
||||
Icons.Default.Settings,
|
||||
status = skillWorkshopStatus(skillWorkshopSummary),
|
||||
route = SettingsRoute.SkillWorkshop,
|
||||
),
|
||||
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
|
||||
SettingsRow("Terminal", "Shell in the agent workspace", Icons.Outlined.Terminal, status = isConnected, route = SettingsRoute.Terminal),
|
||||
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
|
||||
|
|
@ -1620,6 +1630,28 @@ private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
|
|||
else -> true
|
||||
}
|
||||
|
||||
/** Mirrors the Skill Workshop review queue in one compact Settings row. */
|
||||
internal fun skillWorkshopSummaryText(summary: GatewaySkillWorkshopSummary): String {
|
||||
val pending = summary.proposals.count { it.status == "pending" }
|
||||
if (pending > 0) return if (pending == 1) "1 pending" else "$pending pending"
|
||||
val held = summary.proposals.count { it.status == "quarantined" || it.status == "stale" }
|
||||
val applied = summary.proposals.count { it.status == "applied" }
|
||||
return when {
|
||||
summary.proposals.isEmpty() -> "No proposals"
|
||||
held > 0 -> if (held == 1) "1 held" else "$held held"
|
||||
applied > 0 -> if (applied == 1) "1 applied" else "$applied applied"
|
||||
else -> "${summary.proposals.size} proposals"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun skillWorkshopStatus(summary: GatewaySkillWorkshopSummary): Boolean? =
|
||||
when {
|
||||
summary.proposals.any { it.status == "pending" } -> false
|
||||
summary.proposals.any { it.status == "quarantined" || it.status == "stale" } -> false
|
||||
summary.proposals.any { it.status == "applied" } -> true
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Prioritizes pending pairings over online counts for compact node/device summaries. */
|
||||
private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String {
|
||||
val online = summary.nodes.count { it.connected }
|
||||
|
|
@ -1726,6 +1758,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
|
|||
SettingsRoute.CronJobs,
|
||||
SettingsRoute.Usage,
|
||||
SettingsRoute.Skills,
|
||||
SettingsRoute.SkillWorkshop,
|
||||
SettingsRoute.Dreaming,
|
||||
SettingsRoute.Terminal,
|
||||
-> "Agents & automation"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,689 @@
|
|||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewaySkillWorkshopProposal
|
||||
import ai.openclaw.app.GatewaySkillWorkshopSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSegmentedControl
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val skillWorkshopFilterLabels = listOf("Pending", "Held", "Applied", "Rejected", "All")
|
||||
|
||||
@Composable
|
||||
internal fun SkillWorkshopSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.skillWorkshopSummary.collectAsState()
|
||||
val refreshing by viewModel.skillWorkshopRefreshing.collectAsState()
|
||||
val errorText by viewModel.skillWorkshopErrorText.collectAsState()
|
||||
val noticeText by viewModel.skillWorkshopNoticeText.collectAsState()
|
||||
val inspectingProposalId by viewModel.skillWorkshopInspectingProposalId.collectAsState()
|
||||
val mutatingProposalId by viewModel.skillWorkshopMutatingProposalId.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState()
|
||||
val agents by viewModel.gatewayAgents.collectAsState()
|
||||
val defaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState()
|
||||
|
||||
var statusFilter by rememberSaveable { mutableStateOf("pending") }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var selectedAgentId by rememberSaveable { mutableStateOf("") }
|
||||
var selectedProposalId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingAction by remember { mutableStateOf<SkillWorkshopPendingAction?>(null) }
|
||||
val selectedAgentParam = selectedAgentId.trim().takeIf { it.isNotEmpty() }
|
||||
val visibleProposals = skillWorkshopVisibleProposals(summary, selectedAgentParam)
|
||||
val filteredProposals = skillWorkshopFilteredProposals(visibleProposals, statusFilter, query)
|
||||
val selectedProposal =
|
||||
filteredProposals.firstOrNull { it.id == selectedProposalId }
|
||||
?: filteredProposals.firstOrNull()
|
||||
|
||||
LaunchedEffect(isConnected, selectedAgentParam) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filteredProposals.map { it.id }) {
|
||||
if (selectedProposalId == null || filteredProposals.none { it.id == selectedProposalId }) {
|
||||
selectedProposalId = filteredProposals.firstOrNull()?.id
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProposal?.id, selectedProposal?.content, isConnected, selectedAgentParam) {
|
||||
if (isConnected && selectedProposal != null && selectedProposal.content == null) {
|
||||
viewModel.inspectSkillWorkshopProposal(proposalId = selectedProposal.id, agentId = selectedAgentParam)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProposal?.id, pendingAction?.proposalId) {
|
||||
if (pendingAction != null && selectedProposal?.id != pendingAction?.proposalId) {
|
||||
pendingAction = null
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction?.let { action ->
|
||||
SkillWorkshopActionConfirmDialog(
|
||||
action = action,
|
||||
onDismiss = { pendingAction = null },
|
||||
onConfirm = {
|
||||
pendingAction = null
|
||||
when (action.action) {
|
||||
SkillWorkshopProposalAction.Apply ->
|
||||
viewModel.applySkillWorkshopProposal(
|
||||
proposalId = action.proposalId,
|
||||
agentId = selectedAgentParam,
|
||||
)
|
||||
SkillWorkshopProposalAction.Reject ->
|
||||
viewModel.rejectSkillWorkshopProposal(
|
||||
proposalId = action.proposalId,
|
||||
agentId = selectedAgentParam,
|
||||
)
|
||||
SkillWorkshopProposalAction.Quarantine ->
|
||||
viewModel.quarantineSkillWorkshopProposal(
|
||||
proposalId = action.proposalId,
|
||||
agentId = selectedAgentParam,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Skill Workshop",
|
||||
subtitle = "Review generated skill proposals before they become live skills.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Pending", visibleProposals.count { it.status == "pending" }.toString()),
|
||||
SettingsMetric(
|
||||
"Held",
|
||||
visibleProposals.count { skillWorkshopStatusMatchesFilter(it.status, "held") }.toString(),
|
||||
),
|
||||
SettingsMetric("Applied", visibleProposals.count { it.status == "applied" }.toString()),
|
||||
SettingsMetric("Rejected", visibleProposals.count { it.status == "rejected" }.toString()),
|
||||
),
|
||||
)
|
||||
|
||||
SkillWorkshopControls(
|
||||
agents = agents,
|
||||
defaultAgentId = defaultAgentId,
|
||||
selectedAgentId = selectedAgentId,
|
||||
onAgentChange = { agentId ->
|
||||
selectedAgentId = agentId
|
||||
selectedProposalId = null
|
||||
viewModel.resetSkillWorkshopAgentScope(agentId = agentId.takeIf { it.isNotBlank() })
|
||||
},
|
||||
statusFilter = statusFilter,
|
||||
onStatusFilterChange = { filter ->
|
||||
statusFilter = filter
|
||||
selectedProposalId = null
|
||||
},
|
||||
query = query,
|
||||
onQueryChange = { value ->
|
||||
query = value
|
||||
selectedProposalId = null
|
||||
},
|
||||
refreshing = refreshing,
|
||||
isConnected = isConnected,
|
||||
onRefresh = { viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam) },
|
||||
)
|
||||
|
||||
noticeText?.let { message ->
|
||||
ClawPanel {
|
||||
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.success)
|
||||
}
|
||||
}
|
||||
errorText?.let { message ->
|
||||
ClawPanel {
|
||||
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
!isConnected ->
|
||||
SkillWorkshopEmptyPanel(
|
||||
title = "Gateway offline",
|
||||
detail = "Connect to a Gateway to load Skill Workshop proposals.",
|
||||
)
|
||||
filteredProposals.isEmpty() ->
|
||||
SkillWorkshopEmptyPanel(
|
||||
title = "No proposals",
|
||||
detail = "Matching proposals will appear here after agents create reusable skill drafts.",
|
||||
)
|
||||
else -> {
|
||||
SkillWorkshopProposalList(
|
||||
proposals = filteredProposals,
|
||||
selectedProposalId = selectedProposal?.id,
|
||||
inspectingProposalId = inspectingProposalId,
|
||||
mutatingProposalId = mutatingProposalId,
|
||||
onSelect = { proposal ->
|
||||
selectedProposalId = proposal.id
|
||||
viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam)
|
||||
},
|
||||
)
|
||||
selectedProposal?.let { proposal ->
|
||||
SkillWorkshopProposalDetail(
|
||||
proposal = proposal,
|
||||
inspecting = inspectingProposalId == proposal.id,
|
||||
mutating = mutatingProposalId == proposal.id,
|
||||
isConnected = isConnected,
|
||||
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
|
||||
onInspect = {
|
||||
viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam)
|
||||
},
|
||||
onApply = {
|
||||
pendingAction =
|
||||
SkillWorkshopPendingAction(
|
||||
action = SkillWorkshopProposalAction.Apply,
|
||||
proposalId = proposal.id,
|
||||
title = proposal.title,
|
||||
)
|
||||
},
|
||||
onReject = {
|
||||
pendingAction =
|
||||
SkillWorkshopPendingAction(
|
||||
action = SkillWorkshopProposalAction.Reject,
|
||||
proposalId = proposal.id,
|
||||
title = proposal.title,
|
||||
)
|
||||
},
|
||||
onQuarantine = {
|
||||
pendingAction =
|
||||
SkillWorkshopPendingAction(
|
||||
action = SkillWorkshopProposalAction.Quarantine,
|
||||
proposalId = proposal.id,
|
||||
title = proposal.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SkillWorkshopProposalAction(
|
||||
val label: String,
|
||||
) {
|
||||
Apply("Apply"),
|
||||
Reject("Reject"),
|
||||
Quarantine("Quarantine"),
|
||||
}
|
||||
|
||||
private data class SkillWorkshopPendingAction(
|
||||
val action: SkillWorkshopProposalAction,
|
||||
val proposalId: String,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopActionConfirmDialog(
|
||||
action: SkillWorkshopPendingAction,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("${action.action.label} proposal?") },
|
||||
text = {
|
||||
Text(
|
||||
text =
|
||||
"This will ${action.action.label.lowercase()} \"${action.title}\" and refresh Skill Workshop state from the gateway.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(action.action.label)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopControls(
|
||||
agents: List<GatewayAgentSummary>,
|
||||
defaultAgentId: String?,
|
||||
selectedAgentId: String,
|
||||
onAgentChange: (String) -> Unit,
|
||||
statusFilter: String,
|
||||
onStatusFilterChange: (String) -> Unit,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
refreshing: Boolean,
|
||||
isConnected: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SkillWorkshopAgentMenu(
|
||||
agents = agents,
|
||||
defaultAgentId = defaultAgentId,
|
||||
selectedAgentId = selectedAgentId,
|
||||
onAgentChange = onAgentChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = onRefresh,
|
||||
enabled = isConnected && !refreshing,
|
||||
icon = Icons.Default.Refresh,
|
||||
)
|
||||
}
|
||||
ClawSegmentedControl(
|
||||
options = skillWorkshopFilterLabels,
|
||||
selected = skillWorkshopFilterLabel(statusFilter),
|
||||
onSelect = { label -> onStatusFilterChange(skillWorkshopFilterFromLabel(label)) },
|
||||
)
|
||||
ClawTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = "Search proposals",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopAgentMenu(
|
||||
agents: List<GatewayAgentSummary>,
|
||||
defaultAgentId: String?,
|
||||
selectedAgentId: String,
|
||||
onAgentChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val label =
|
||||
skillWorkshopAgentLabel(
|
||||
agents = agents,
|
||||
defaultAgentId = defaultAgentId,
|
||||
selectedAgentId = selectedAgentId,
|
||||
)
|
||||
Box(modifier = modifier) {
|
||||
ClawSecondaryButton(
|
||||
text = label,
|
||||
onClick = { expanded = true },
|
||||
icon = Icons.Default.ArrowDropDown,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = agents.isNotEmpty(),
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Default agent") },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onAgentChange("")
|
||||
},
|
||||
)
|
||||
agents
|
||||
.filter { agent -> agent.id.trim().isNotEmpty() && agent.id != defaultAgentId }
|
||||
.sortedBy { it.name ?: it.id }
|
||||
.forEach { agent ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(agent.name?.takeIf { it.isNotBlank() } ?: agent.id) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onAgentChange(agent.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopProposalList(
|
||||
proposals: List<GatewaySkillWorkshopProposal>,
|
||||
selectedProposalId: String?,
|
||||
inspectingProposalId: String?,
|
||||
mutatingProposalId: String?,
|
||||
onSelect: (GatewaySkillWorkshopProposal) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
proposals.forEach { proposal ->
|
||||
SkillWorkshopProposalRow(
|
||||
proposal = proposal,
|
||||
selected = proposal.id == selectedProposalId,
|
||||
busy = proposal.id == inspectingProposalId || proposal.id == mutatingProposalId,
|
||||
onClick = { onSelect(proposal) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopProposalRow(
|
||||
proposal: GatewaySkillWorkshopProposal,
|
||||
selected: Boolean,
|
||||
busy: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ClawPanel {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
text = proposal.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = proposal.description ?: proposal.skillKey,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ClawStatusPill(
|
||||
text = if (busy) "loading" else proposal.status,
|
||||
status = skillWorkshopStatusPill(proposal.status),
|
||||
)
|
||||
Text(
|
||||
text = skillWorkshopDateLabel(proposal.updatedAt),
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
ClawSecondaryButton(
|
||||
text = if (selected) "Selected" else "Open",
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopProposalDetail(
|
||||
proposal: GatewaySkillWorkshopProposal,
|
||||
inspecting: Boolean,
|
||||
mutating: Boolean,
|
||||
isConnected: Boolean,
|
||||
operatorAdminScopeAvailable: Boolean,
|
||||
onInspect: () -> Unit,
|
||||
onApply: () -> Unit,
|
||||
onReject: () -> Unit,
|
||||
onQuarantine: () -> Unit,
|
||||
) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = proposal.title,
|
||||
style = ClawTheme.type.title,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = proposal.skillKey,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ClawStatusPill(text = proposal.status, status = skillWorkshopStatusPill(proposal.status))
|
||||
}
|
||||
proposal.description?.let { description ->
|
||||
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Kind", proposal.kind),
|
||||
SettingsMetric("Updated", skillWorkshopDateLabel(proposal.updatedAt)),
|
||||
SettingsMetric("Support Files", proposal.supportFiles.size.toString()),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = proposal.content ?: "Inspect this proposal to load its markdown.",
|
||||
style = ClawTheme.type.body,
|
||||
color = if (proposal.content == null) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
|
||||
)
|
||||
if (!operatorAdminScopeAvailable) {
|
||||
Text(
|
||||
text = "Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions.",
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.warning,
|
||||
)
|
||||
}
|
||||
if (proposal.supportFiles.isNotEmpty()) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border)
|
||||
proposal.supportFiles.forEach { file ->
|
||||
Text(
|
||||
text = file.path,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = "Skill Workshop inspect and apply actions" },
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClawSecondaryButton(
|
||||
text = if (inspecting) "Inspecting" else "Inspect",
|
||||
onClick = onInspect,
|
||||
enabled = isConnected && !inspecting && !mutating,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawPrimaryButton(
|
||||
text = if (mutating) "Working" else "Apply",
|
||||
onClick = onApply,
|
||||
enabled =
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = isConnected,
|
||||
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
|
||||
busy = inspecting || mutating,
|
||||
status = proposal.status,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = "Skill Workshop reject and quarantine actions" },
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClawSecondaryButton(
|
||||
text = "Reject",
|
||||
onClick = onReject,
|
||||
enabled =
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = isConnected,
|
||||
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
|
||||
busy = inspecting || mutating,
|
||||
status = proposal.status,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = "Quarantine",
|
||||
onClick = onQuarantine,
|
||||
enabled =
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = isConnected,
|
||||
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
|
||||
busy = inspecting || mutating,
|
||||
status = proposal.status,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillWorkshopEmptyPanel(
|
||||
title: String,
|
||||
detail: String,
|
||||
) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text)
|
||||
Text(text = detail, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun skillWorkshopVisibleProposals(
|
||||
summary: GatewaySkillWorkshopSummary,
|
||||
selectedAgentId: String?,
|
||||
): List<GatewaySkillWorkshopProposal> =
|
||||
when {
|
||||
summary.agentId == skillWorkshopAgentScope(selectedAgentId) -> summary.proposals
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
internal fun skillWorkshopAgentScope(agentId: String?): String = agentId?.trim().orEmpty()
|
||||
|
||||
internal fun skillWorkshopFilteredProposals(
|
||||
proposals: List<GatewaySkillWorkshopProposal>,
|
||||
statusFilter: String,
|
||||
query: String,
|
||||
): List<GatewaySkillWorkshopProposal> {
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
val matchingStatus =
|
||||
proposals.filter { proposal -> skillWorkshopStatusMatchesFilter(proposal.status, statusFilter) }
|
||||
val matchingQuery =
|
||||
matchingStatus.filter { proposal ->
|
||||
if (normalizedQuery.isEmpty()) return@filter true
|
||||
val pieces =
|
||||
listOf(proposal.title, proposal.description.orEmpty(), proposal.skillName, proposal.skillKey)
|
||||
val haystack = pieces.joinToString(" ").lowercase()
|
||||
haystack.contains(normalizedQuery)
|
||||
}
|
||||
return matchingQuery.sortedByDescending { it.updatedAt }
|
||||
}
|
||||
|
||||
internal fun skillWorkshopStatusMatchesFilter(
|
||||
status: String,
|
||||
filter: String,
|
||||
): Boolean =
|
||||
when (filter) {
|
||||
"all" -> true
|
||||
"held" -> status == "quarantined" || status == "stale"
|
||||
else -> status == filter
|
||||
}
|
||||
|
||||
internal fun skillWorkshopProposalActionEnabled(
|
||||
isConnected: Boolean,
|
||||
operatorAdminScopeAvailable: Boolean,
|
||||
busy: Boolean,
|
||||
status: String,
|
||||
): Boolean = isConnected && operatorAdminScopeAvailable && !busy && status == "pending"
|
||||
|
||||
private fun skillWorkshopFilterLabel(filter: String): String =
|
||||
when (filter) {
|
||||
"pending" -> "Pending"
|
||||
"held" -> "Held"
|
||||
"applied" -> "Applied"
|
||||
"rejected" -> "Rejected"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
private fun skillWorkshopFilterFromLabel(label: String): String =
|
||||
when (label) {
|
||||
"Pending" -> "pending"
|
||||
"Held" -> "held"
|
||||
"Applied" -> "applied"
|
||||
"Rejected" -> "rejected"
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
private fun skillWorkshopAgentLabel(
|
||||
agents: List<GatewayAgentSummary>,
|
||||
defaultAgentId: String?,
|
||||
selectedAgentId: String,
|
||||
): String {
|
||||
val selected = selectedAgentId.trim()
|
||||
if (selected.isNotEmpty()) {
|
||||
return agents.firstOrNull { it.id == selected }?.name?.takeIf { it.isNotBlank() } ?: selected
|
||||
}
|
||||
val defaultId = defaultAgentId?.trim().orEmpty()
|
||||
if (defaultId.isNotEmpty()) {
|
||||
return agents.firstOrNull { it.id == defaultId }?.name?.takeIf { it.isNotBlank() }
|
||||
?: "Default agent"
|
||||
}
|
||||
return "Default agent"
|
||||
}
|
||||
|
||||
private fun skillWorkshopStatusPill(status: String): ClawStatus =
|
||||
when (status) {
|
||||
"pending", "quarantined", "stale" -> ClawStatus.Warning
|
||||
"applied" -> ClawStatus.Success
|
||||
"rejected" -> ClawStatus.Neutral
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
|
||||
private fun skillWorkshopDateLabel(value: String): String = value.trim().takeIf { it.isNotEmpty() }?.take(10) ?: "unknown"
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.lang.reflect.Field
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class SkillWorkshopAgentScopeRuntimeTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Before
|
||||
fun clearPlainPrefs() {
|
||||
RuntimeEnvironment
|
||||
.getApplication()
|
||||
.getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetSkillWorkshopAgentScopeClearsRowsAndInFlightActionState() {
|
||||
val runtime = createTestRuntime()
|
||||
seedConnectedRuntime(runtime)
|
||||
runtime.resetSkillWorkshopAgentScope("main")
|
||||
readField<MutableStateFlow<GatewaySkillWorkshopSummary>>(runtime, "_skillWorkshopSummary").value =
|
||||
GatewaySkillWorkshopSummary(
|
||||
agentId = "main",
|
||||
proposals = listOf(skillWorkshopProposal("main-proposal")),
|
||||
)
|
||||
|
||||
runtime.resetSkillWorkshopAgentScope("ops")
|
||||
|
||||
assertEquals("ops", runtime.skillWorkshopSummary.value.agentId)
|
||||
assertEquals(emptyList<GatewaySkillWorkshopProposal>(), runtime.skillWorkshopSummary.value.proposals)
|
||||
assertFalse(runtime.skillWorkshopRefreshing.value)
|
||||
assertNull(runtime.skillWorkshopErrorText.value)
|
||||
assertNull(runtime.skillWorkshopNoticeText.value)
|
||||
assertNull(runtime.skillWorkshopInspectingProposalId.value)
|
||||
assertNull(runtime.skillWorkshopMutatingProposalId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inspectAndMutateDoNotStartForStaleSelectedAgentScope() {
|
||||
val runtime = createTestRuntime()
|
||||
seedConnectedRuntime(runtime)
|
||||
runtime.resetSkillWorkshopAgentScope("main")
|
||||
|
||||
runtime.inspectSkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops")
|
||||
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value = listOf("operator.admin")
|
||||
waitUntil { runtime.operatorAdminScopeAvailable.value }
|
||||
runtime.applySkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops")
|
||||
Thread.sleep(100)
|
||||
|
||||
assertEquals("main", runtime.skillWorkshopSummary.value.agentId)
|
||||
assertNull(runtime.skillWorkshopInspectingProposalId.value)
|
||||
assertNull(runtime.skillWorkshopMutatingProposalId.value)
|
||||
assertNull(runtime.skillWorkshopErrorText.value)
|
||||
assertNull(runtime.skillWorkshopNoticeText.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyProposalActionDoesNotInvalidateActiveRequestGenerations() {
|
||||
val runtime = createTestRuntime()
|
||||
seedConnectedRuntime(runtime)
|
||||
readField<MutableStateFlow<GatewaySkillWorkshopSummary>>(runtime, "_skillWorkshopSummary").value =
|
||||
GatewaySkillWorkshopSummary(
|
||||
agentId = "main",
|
||||
proposals = listOf(skillWorkshopProposal("proposal-1")),
|
||||
)
|
||||
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value = listOf("operator.admin")
|
||||
waitUntil { runtime.operatorAdminScopeAvailable.value }
|
||||
readField<MutableStateFlow<String?>>(runtime, "_skillWorkshopMutatingProposalId").value = "proposal-1"
|
||||
val mutationSeq = readField<AtomicLong>(runtime, "skillWorkshopMutationSeq").apply { set(41) }
|
||||
val inspectSeq = readField<AtomicLong>(runtime, "skillWorkshopInspectSeq").apply { set(17) }
|
||||
|
||||
runtime.applySkillWorkshopProposal(proposalId = "proposal-1", agentId = "main")
|
||||
runtime.inspectSkillWorkshopProposal(proposalId = "proposal-1", agentId = "main")
|
||||
Thread.sleep(100)
|
||||
|
||||
assertEquals(41, mutationSeq.get())
|
||||
assertEquals(17, inspectSeq.get())
|
||||
assertEquals("proposal-1", runtime.skillWorkshopMutatingProposalId.value)
|
||||
assertNull(runtime.skillWorkshopInspectingProposalId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun proposalActionResultUsesGatewayReturnedStatusAndPreservesInspectedDetails() {
|
||||
val runtime = createTestRuntime()
|
||||
val supportFiles =
|
||||
listOf(GatewaySkillWorkshopSupportFile(path = "references/proof.md", content = "proof"))
|
||||
val previous =
|
||||
skillWorkshopProposal("proposal-1")
|
||||
.copy(
|
||||
status = "pending",
|
||||
content = "inspected markdown",
|
||||
supportFiles = supportFiles,
|
||||
)
|
||||
|
||||
val rejected =
|
||||
parseSkillWorkshopActionResult(
|
||||
runtime,
|
||||
"""
|
||||
{
|
||||
"record": {
|
||||
"id": "proposal-1",
|
||||
"kind": "create",
|
||||
"status": "rejected",
|
||||
"title": "Rejected proposal",
|
||||
"description": "Gateway action response",
|
||||
"createdAt": "2026-07-08T00:00:00Z",
|
||||
"updatedAt": "2026-07-09T00:00:00Z",
|
||||
"scan": { "state": "clean" },
|
||||
"target": { "skillName": "Rejected Skill", "skillKey": "rejected-skill" }
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
previous,
|
||||
)
|
||||
|
||||
assertEquals("rejected", rejected?.status)
|
||||
assertEquals("2026-07-09T00:00:00Z", rejected?.updatedAt)
|
||||
assertEquals("Rejected Skill", rejected?.skillName)
|
||||
assertEquals("clean", rejected?.scanState)
|
||||
assertEquals("inspected markdown", rejected?.content)
|
||||
assertEquals(supportFiles, rejected?.supportFiles)
|
||||
}
|
||||
|
||||
private fun createTestRuntime(): NodeRuntime {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs))
|
||||
}
|
||||
|
||||
private fun seedConnectedRuntime(runtime: NodeRuntime) {
|
||||
writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789))
|
||||
writeField(runtime, "operatorConnected", true)
|
||||
}
|
||||
|
||||
private fun skillWorkshopProposal(id: String): GatewaySkillWorkshopProposal =
|
||||
GatewaySkillWorkshopProposal(
|
||||
id = id,
|
||||
status = "pending",
|
||||
kind = "create",
|
||||
title = "Proposal $id",
|
||||
skillKey = id,
|
||||
skillName = "Proposal $id",
|
||||
description = "desc",
|
||||
createdAt = "2026-07-08T00:00:00Z",
|
||||
updatedAt = "2026-07-08T00:00:00Z",
|
||||
scanState = null,
|
||||
)
|
||||
|
||||
private fun parseSkillWorkshopActionResult(
|
||||
runtime: NodeRuntime,
|
||||
payloadJson: String,
|
||||
previous: GatewaySkillWorkshopProposal,
|
||||
): GatewaySkillWorkshopProposal? {
|
||||
val method =
|
||||
runtime.javaClass.getDeclaredMethod(
|
||||
"parseSkillWorkshopProposalActionResult",
|
||||
JsonObject::class.java,
|
||||
GatewaySkillWorkshopProposal::class.java,
|
||||
)
|
||||
method.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return method.invoke(
|
||||
runtime,
|
||||
json.parseToJsonElement(payloadJson).jsonObject,
|
||||
previous,
|
||||
) as GatewaySkillWorkshopProposal?
|
||||
}
|
||||
|
||||
private fun waitUntil(condition: () -> Boolean) {
|
||||
repeat(50) {
|
||||
if (condition()) return
|
||||
Thread.sleep(10)
|
||||
}
|
||||
error("Expected condition to become true")
|
||||
}
|
||||
|
||||
private fun writeField(
|
||||
target: Any,
|
||||
name: String,
|
||||
value: Any?,
|
||||
) {
|
||||
var type: Class<*>? = target.javaClass
|
||||
while (type != null) {
|
||||
try {
|
||||
val field: Field = type.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
field.set(target, value)
|
||||
return
|
||||
} catch (_: NoSuchFieldException) {
|
||||
type = type.superclass
|
||||
}
|
||||
}
|
||||
error("Field $name not found on ${target.javaClass.name}")
|
||||
}
|
||||
|
||||
private fun <T> readField(
|
||||
target: Any,
|
||||
name: String,
|
||||
): T {
|
||||
var type: Class<*>? = target.javaClass
|
||||
while (type != null) {
|
||||
try {
|
||||
val field: Field = type.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(target) as T
|
||||
} catch (_: NoSuchFieldException) {
|
||||
type = type.superclass
|
||||
}
|
||||
}
|
||||
error("Field $name not found on ${target.javaClass.name}")
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,10 @@ import ai.openclaw.app.GatewayNodeApprovalState
|
|||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import ai.openclaw.app.GatewaySkillWorkshopProposal
|
||||
import ai.openclaw.app.GatewaySkillWorkshopSummary
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.normalizeOperatorScopes
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
|
|
@ -220,6 +223,121 @@ class ShellScreenLogicTest {
|
|||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skillWorkshopSummaryPrioritizesPendingAndHeldProposals() {
|
||||
assertEquals(
|
||||
"2 pending",
|
||||
skillWorkshopSummaryText(
|
||||
GatewaySkillWorkshopSummary(
|
||||
proposals =
|
||||
listOf(
|
||||
skillWorkshopProposal("one", "pending"),
|
||||
skillWorkshopProposal("two", "pending"),
|
||||
skillWorkshopProposal("three", "applied"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"1 held",
|
||||
skillWorkshopSummaryText(
|
||||
GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("held", "quarantined"))),
|
||||
),
|
||||
)
|
||||
assertEquals(null, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = emptyList())))
|
||||
assertEquals(false, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("pending", "pending")))))
|
||||
assertEquals(true, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("applied", "applied")))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skillWorkshopFilteringMatchesHeldAndSearchText() {
|
||||
val proposals =
|
||||
listOf(
|
||||
skillWorkshopProposal("pending", "pending", title = "Browser Playbook", skillKey = "browser-playbook"),
|
||||
skillWorkshopProposal("stale", "stale", title = "Old Draft", skillKey = "old-draft"),
|
||||
skillWorkshopProposal("quarantine", "quarantined", title = "Risky Skill", skillKey = "risky-skill"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("stale", "quarantine"), skillWorkshopFilteredProposals(proposals, "held", "").map { it.id })
|
||||
assertEquals(listOf("pending"), skillWorkshopFilteredProposals(proposals, "all", "browser").map { it.id })
|
||||
assertTrue(skillWorkshopStatusMatchesFilter("stale", "held"))
|
||||
assertFalse(skillWorkshopStatusMatchesFilter("applied", "held"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skillWorkshopVisibleProposalsAreKeyedBySelectedAgentScope() {
|
||||
val mainProposal = skillWorkshopProposal("main-proposal", "pending")
|
||||
val opsProposal = skillWorkshopProposal("ops-proposal", "pending")
|
||||
|
||||
assertEquals(
|
||||
listOf("main-proposal"),
|
||||
skillWorkshopVisibleProposals(
|
||||
GatewaySkillWorkshopSummary(agentId = "", proposals = listOf(mainProposal)),
|
||||
selectedAgentId = null,
|
||||
).map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<String>(),
|
||||
skillWorkshopVisibleProposals(
|
||||
GatewaySkillWorkshopSummary(agentId = "main", proposals = listOf(mainProposal)),
|
||||
selectedAgentId = "ops",
|
||||
).map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("ops-proposal"),
|
||||
skillWorkshopVisibleProposals(
|
||||
GatewaySkillWorkshopSummary(agentId = "ops", proposals = listOf(opsProposal)),
|
||||
selectedAgentId = " ops ",
|
||||
).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skillWorkshopProposalActionsRequireAdminScope() {
|
||||
assertTrue(
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = true,
|
||||
operatorAdminScopeAvailable = true,
|
||||
busy = false,
|
||||
status = "pending",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = true,
|
||||
operatorAdminScopeAvailable = false,
|
||||
busy = false,
|
||||
status = "pending",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = true,
|
||||
operatorAdminScopeAvailable = true,
|
||||
busy = true,
|
||||
status = "pending",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
skillWorkshopProposalActionEnabled(
|
||||
isConnected = true,
|
||||
operatorAdminScopeAvailable = true,
|
||||
busy = false,
|
||||
status = "applied",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun operatorScopesNormalizeForStableAdminChecks() {
|
||||
assertEquals(
|
||||
listOf("operator.admin", "operator.read", "operator.write"),
|
||||
normalizeOperatorScopes(
|
||||
listOf(" operator.write ", "operator.admin", "", "operator.write", "operator.read"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() {
|
||||
val rows =
|
||||
|
|
@ -664,4 +782,23 @@ class ShellScreenLogicTest {
|
|||
pauseReconnect = false,
|
||||
retryable = false,
|
||||
)
|
||||
|
||||
private fun skillWorkshopProposal(
|
||||
id: String,
|
||||
status: String,
|
||||
title: String = id,
|
||||
skillKey: String = id,
|
||||
): GatewaySkillWorkshopProposal =
|
||||
GatewaySkillWorkshopProposal(
|
||||
id = id,
|
||||
kind = "create",
|
||||
status = status,
|
||||
title = title,
|
||||
description = null,
|
||||
skillName = title,
|
||||
skillKey = skillKey,
|
||||
createdAt = "2026-07-08T00:00:00.000Z",
|
||||
updatedAt = "2026-07-08T00:00:00.000Z",
|
||||
scanState = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue