Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class SkillRepository private constructor(private val context: Context) {
private const val CONNECT_TIMEOUT = 15_000
private const val READ_TIMEOUT = 30_000
private const val BUFFER_SIZE = 64 * 1024
private const val OPERIT_META_DIR = ".operit"
private const val GITHUB_ZIP_KEY_FILE = "github_zip_key"
private val SKILL_ID_PATTERN = Regex("^[A-Za-z0-9._-]+$")

fun getInstance(context: Context): SkillRepository {
Expand Down Expand Up @@ -69,7 +71,15 @@ class SkillRepository private constructor(private val context: Context) {

fun readSkillContent(skillName: String): String? = skillManager.readSkillContent(skillName)

fun deleteSkill(skillName: String): Boolean = skillManager.deleteSkill(skillName)
suspend fun deleteSkill(skillName: String): Boolean {
val skill = skillManager.getAvailableSkills()[skillName] ?: return false
val zipKey = readGithubZipKey(skill.directory)
val ok = skillManager.deleteSkill(skillName)
if (ok && !zipKey.isNullOrBlank()) {
SkillRepoZipPoolManager.invalidate(zipKey)
}
return ok
}

suspend fun importSkillFromZip(zipFile: File): String {
return withContext(Dispatchers.IO) {
Expand Down Expand Up @@ -99,10 +109,19 @@ class SkillRepository private constructor(private val context: Context) {
?: getGithubDefaultBranch(owner, repoName)?.also { defaultBranchCache[repoKey] = it }
?: return@withContext SkillRepoImportResult(context.getString(R.string.skill_cannot_determine_default_branch, "$owner/$repoName"), null)

val encodedRef = encodePathSegment(ref)
val zipUrl = "https://codeload.github.com/$owner/$repoName/zip/$encodedRef"
val repoRefKey = "$owner/$repoName@$ref"
val pooledZip = SkillRepoZipPoolManager.getOrDownloadZip(repoRefKey) { outFile ->
val commitSha = resolveGithubCommitSha(owner, repoName, ref)
val zipIdentity = commitSha ?: ref
val zipUrl = "https://codeload.github.com/$owner/$repoName/zip/${encodePathSegment(zipIdentity)}"
val repoRefKey = SkillRepoZipPoolManager.poolKey(owner, repoName, zipIdentity)
if (!commitSha.isNullOrBlank() && commitSha != ref) {
SkillRepoZipPoolManager.invalidate(
SkillRepoZipPoolManager.poolKey(owner, repoName, ref)
)
}
val pooledZip = SkillRepoZipPoolManager.getOrDownloadZip(
key = repoRefKey,
forceRefresh = commitSha.isNullOrBlank()
) { outFile ->
downloadFromUrl(zipUrl, outFile)
}

Expand All @@ -127,6 +146,9 @@ class SkillRepository private constructor(private val context: Context) {
}

val result = skillManager.importSkillFromZipDetailed(zipFile, target.subDir)
if (result.installedDir != null) {
runCatching { writeGithubZipKey(result.installedDir, repoRefKey) }
}

if (pooledZip == null) {
runCatching { fallbackTempFile.delete() }
Expand Down Expand Up @@ -411,13 +433,68 @@ class SkillRepository private constructor(private val context: Context) {
return true
}

private fun isFullCommitSha(value: String): Boolean {
return value.length == 40 && value.all { ch ->
ch.isDigit() || ch.lowercaseChar() in 'a'..'f'
}
}

private fun resolveGithubCommitSha(owner: String, repoName: String, ref: String): String? {
if (isFullCommitSha(ref)) {
return ref.lowercase()
}
return getGithubCommitSha(owner, repoName, ref)
}

private fun getGithubCommitSha(owner: String, repoName: String, ref: String): String? {
val apiUrl = "https://api.github.com/repos/$owner/$repoName/commits/${encodePathSegment(ref)}"
return try {
val url = URL(apiUrl)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github.v3+json")
setRequestProperty("User-Agent", "Operit-Skill-Client")
connectTimeout = CONNECT_TIMEOUT
readTimeout = READ_TIMEOUT
}
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().use { it.readText() }
val jsonObject = JsonParser.parseString(response).asJsonObject
jsonObject.get("sha")?.asString?.trim()?.takeIf { it.isNotEmpty() }
} else {
AppLogger.w(TAG, "GitHub commit lookup failed, HTTP ${connection.responseCode}")
null
}
} catch (e: Exception) {
AppLogger.w(TAG, "Failed to fetch GitHub commit SHA for $owner/$repoName@$ref", e)
null
}
}

private fun githubZipKeyFile(skillDir: File): File {
return File(File(skillDir, OPERIT_META_DIR), GITHUB_ZIP_KEY_FILE)
}

private fun writeGithubZipKey(skillDir: File, key: String) {
val file = githubZipKeyFile(skillDir)
file.parentFile?.mkdirs()
file.writeText(key)
}

private fun readGithubZipKey(skillDir: File): String? {
val file = githubZipKeyFile(skillDir)
if (!file.isFile) return null
return file.readText().trim().takeIf { it.isNotEmpty() }
}

private fun getGithubDefaultBranch(owner: String, repoName: String): String? {
val apiUrl = "https://api.github.com/repos/$owner/$repoName"
return try {
val url = URL(apiUrl)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github.v3+json")
setRequestProperty("User-Agent", "Operit-Skill-Client")
connectTimeout = CONNECT_TIMEOUT
readTimeout = READ_TIMEOUT
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ object SkillRepoZipPoolManager {
}
}

fun poolKey(owner: String, repo: String, identity: String): String {
return "${owner.trim()}/${repo.trim()}@${identity.trim()}"
}

internal fun cacheFileName(key: String): String = "repo_${sha256Hex16(key)}.zip"

private fun sha256Hex16(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
Expand All @@ -44,7 +50,7 @@ object SkillRepoZipPoolManager {
return hex.take(16)
}

private fun zipFileFor(dir: File, key: String): File = File(dir, "repo_${sha256Hex16(key)}.zip")
private fun zipFileFor(dir: File, key: String): File = File(dir, cacheFileName(key))

private fun partFileFor(dir: File, key: String): File = File(dir, "repo_${sha256Hex16(key)}.download")

Expand All @@ -64,8 +70,18 @@ object SkillRepoZipPoolManager {
}
}

suspend fun invalidate(key: String) {
val dir = cacheDir ?: return
val mutex = keyMutexes.getOrPut(key) { Mutex() }
mutex.withLock {
runCatching { zipFileFor(dir, key).delete() }
runCatching { partFileFor(dir, key).delete() }
}
}

suspend fun getOrDownloadZip(
key: String,
forceRefresh: Boolean = false,
downloadTo: suspend (outFile: File) -> Boolean
): File? {
val dir = cacheDir
Expand All @@ -77,11 +93,15 @@ object SkillRepoZipPoolManager {
val mutex = keyMutexes.getOrPut(key) { Mutex() }
return mutex.withLock {
val zipFile = zipFileFor(dir, key)
if (zipFile.exists() && zipFile.isFile && zipFile.length() > 0L) {
if (!forceRefresh && zipFile.exists() && zipFile.isFile && zipFile.length() > 0L) {
AppLogger.d(TAG, "ZIP 命中缓存: key=$key, file=${zipFile.name}, bytes=${zipFile.length()}")
touch(zipFile)
return@withLock zipFile
}
if (forceRefresh && zipFile.exists()) {
AppLogger.d(TAG, "ZIP 强制刷新,丢弃缓存: key=$key, file=${zipFile.name}")
runCatching { zipFile.delete() }
}

AppLogger.d(TAG, "ZIP 缓存未命中,开始下载: key=$key")
val partFile = partFileFor(dir, key)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.ai.assistance.operit.util

import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

class SkillRepoZipPoolManagerTest {

private var previousSystemLogEnabled = true
private lateinit var tempDir: File

@Before
fun setUp() {
previousSystemLogEnabled = AppLogger.enableSystemLog
AppLogger.enableSystemLog = false
AppLogger.enableFileLogging = false
tempDir = createTempDir(prefix = "skill-zip-pool-")
SkillRepoZipPoolManager.initialize(tempDir)
}

@After
fun tearDown() {
AppLogger.enableSystemLog = previousSystemLogEnabled
AppLogger.enableFileLogging = true
tempDir.deleteRecursively()
}

@Test
fun poolKey_usesOwnerRepoAndIdentity() {
assertEquals(
"owner/repo@abc123",
SkillRepoZipPoolManager.poolKey(" owner ", " repo ", " abc123 ")
)
}

@Test
fun getOrDownloadZip_reusesCachedFileUntilForcedRefresh() = runBlocking {
val key = SkillRepoZipPoolManager.poolKey("owner", "repo", "sha-old")
var downloads = 0

val first = SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile ->
downloads += 1
outFile.writeText("old-zip")
true
}
val cached = SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile ->
downloads += 1
outFile.writeText("should-not-run")
true
}
val refreshed = SkillRepoZipPoolManager.getOrDownloadZip(
key = key,
forceRefresh = true
) { outFile ->
downloads += 1
outFile.writeText("new-zip")
true
}

assertEquals(2, downloads)
assertEquals("old-zip", first?.readText())
assertEquals("old-zip", cached?.readText())
assertEquals("new-zip", refreshed?.readText())
assertEquals(
SkillRepoZipPoolManager.cacheFileName(key),
refreshed?.name
)
}

@Test
fun invalidate_deletesCachedZipSoNextCallRedownloads() = runBlocking {
val key = SkillRepoZipPoolManager.poolKey("owner", "repo", "sha-1")
var downloads = 0

SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile ->
downloads += 1
outFile.writeText("cached")
true
}
SkillRepoZipPoolManager.invalidate(key)
val afterInvalidate = SkillRepoZipPoolManager.getOrDownloadZip(key) { outFile ->
downloads += 1
outFile.writeText("fresh")
true
}

assertEquals(2, downloads)
assertEquals("fresh", afterInvalidate?.readText())
assertFalse(File(tempDir, "skill_repo_zip_pool/${SkillRepoZipPoolManager.cacheFileName("missing")}").exists())
assertTrue(afterInvalidate!!.exists())
}
}