feat(research): batch source-list fetch-approval (D)
POST /sessions/{id}/approve-sources extracts distinct hosts from a source list
(SourceListHosts.extract) and emits one EgressHostsGrantedEvent for the session, so
the live per-session egress allowlist auto-clears subsequent web_fetches to those
hosts — approve the list once instead of per-URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
package com.correx.apps.server.research
|
||||||
|
|
||||||
|
import com.correx.core.events.EventDispatcher
|
||||||
|
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
|
||||||
|
/** Operator context recorded on the grant emitted by [approveSourceList]. */
|
||||||
|
internal const val BATCH_SOURCE_LIST_REASON = "batch source-list approval"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a whole research
|
||||||
|
* source list at once instead of clearing each `web_fetch` per-URL. Extracts the distinct hosts from
|
||||||
|
* [urls] (a source list's URLs, or its source_dossier source lines) and emits a single
|
||||||
|
* [EgressHostsGrantedEvent] for [sessionId]. That grant folds into the live per-session egress
|
||||||
|
* allowlist (commit ab7e4be) via [com.correx.core.sessions.projections.EgressAllowlistProjection], so
|
||||||
|
* subsequent fetches to those hosts auto-clear and no longer prompt.
|
||||||
|
*
|
||||||
|
* No-op when [urls] yields no parseable host: nothing to grant, so no event is emitted. Returns the
|
||||||
|
* granted host set (empty when nothing was emitted) for the caller to report back.
|
||||||
|
*/
|
||||||
|
suspend fun approveSourceList(
|
||||||
|
dispatcher: EventDispatcher,
|
||||||
|
sessionId: SessionId,
|
||||||
|
urls: List<String>,
|
||||||
|
): Set<String> {
|
||||||
|
val hosts = SourceListHosts.extract(urls)
|
||||||
|
if (hosts.isEmpty()) return emptySet()
|
||||||
|
dispatcher.emit(
|
||||||
|
payload = EgressHostsGrantedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
hosts = hosts,
|
||||||
|
reason = BATCH_SOURCE_LIST_REASON,
|
||||||
|
),
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.correx.apps.server.research
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure host extraction for batch source-list approval (BACKLOG §D). Given the URLs (or dossier
|
||||||
|
* source lines) of a research source list, returns the distinct, lower-cased hostnames the operator
|
||||||
|
* is approving egress to — the input set for a single [com.correx.core.events.events.EgressHostsGrantedEvent].
|
||||||
|
*
|
||||||
|
* Mirrors [com.correx.core.toolintent.rules.NetworkHostRule]'s URL parsing so the granted hosts line
|
||||||
|
* up with what the network gate later matches: absolute URLs only (`scheme://host`), parsed via
|
||||||
|
* [java.net.URI], host lower-cased, port dropped (URI.host excludes it). Entries that don't parse to
|
||||||
|
* a host — relative paths, free text, malformed URLs — are ignored rather than failing the batch.
|
||||||
|
*
|
||||||
|
* source_dossier entries start with the URL but may carry a trailing note
|
||||||
|
* ("https://example.com/x — explains Y"); the leading whitespace-delimited token is taken so those
|
||||||
|
* lines still yield their host.
|
||||||
|
*/
|
||||||
|
object SourceListHosts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct lower-cased hostnames drawn from [urls]. Deterministic; unparseable/relative
|
||||||
|
* entries are skipped. Duplicate hosts (and differing-case or differing-port variants of the
|
||||||
|
* same host) collapse to one.
|
||||||
|
*/
|
||||||
|
fun extract(urls: List<String>): Set<String> =
|
||||||
|
urls.mapNotNullTo(LinkedHashSet()) { hostOf(it) }
|
||||||
|
|
||||||
|
private fun hostOf(raw: String): String? {
|
||||||
|
val token = raw.trim().substringBefore(' ').substringBefore('\t')
|
||||||
|
if (!token.contains("://")) return null
|
||||||
|
return runCatching { URI(token).host?.lowercase() }.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.correx.apps.server.routes
|
|||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.metrics.MetricsInspectionService
|
import com.correx.apps.server.metrics.MetricsInspectionService
|
||||||
|
import com.correx.apps.server.research.approveSourceList
|
||||||
|
import com.correx.core.events.EventDispatcher
|
||||||
import com.correx.apps.server.protocol.SessionConfigDto
|
import com.correx.apps.server.protocol.SessionConfigDto
|
||||||
import com.correx.apps.server.replay.ReplayInspectionService
|
import com.correx.apps.server.replay.ReplayInspectionService
|
||||||
import com.correx.apps.server.serialization.payloadDiscriminator
|
import com.correx.apps.server.serialization.payloadDiscriminator
|
||||||
@@ -56,6 +58,12 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class StartSessionResponse(val sessionId: String)
|
data class StartSessionResponse(val sessionId: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApproveSourcesRequest(val urls: List<String>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApproveSourcesResponse(val grantedHosts: List<String>)
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes")
|
private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes")
|
||||||
|
|
||||||
fun Route.sessionRoutes(module: ServerModule) {
|
fun Route.sessionRoutes(module: ServerModule) {
|
||||||
@@ -79,6 +87,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
|||||||
route("/{id}") {
|
route("/{id}") {
|
||||||
getSessionRoute(module)
|
getSessionRoute(module)
|
||||||
cancelSessionRoute(module)
|
cancelSessionRoute(module)
|
||||||
|
approveSourcesRoute(module)
|
||||||
undoSessionRoute(module)
|
undoSessionRoute(module)
|
||||||
resumeSessionRoute(module)
|
resumeSessionRoute(module)
|
||||||
getEventsRoute(module)
|
getEventsRoute(module)
|
||||||
@@ -129,6 +138,24 @@ private fun Route.cancelSessionRoute(module: ServerModule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a research
|
||||||
|
// source list once and egress is granted for all its hosts in one EgressHostsGrantedEvent, so the
|
||||||
|
// session's subsequent web_fetches to those hosts auto-clear instead of prompting per URL. Body
|
||||||
|
// carries the source list's URLs (or its source_dossier source lines).
|
||||||
|
private fun Route.approveSourcesRoute(module: ServerModule) {
|
||||||
|
post("/approve-sources") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
val body = call.receive<ApproveSourcesRequest>()
|
||||||
|
val granted = approveSourceList(
|
||||||
|
dispatcher = EventDispatcher(module.eventStore),
|
||||||
|
sessionId = TypeId(id),
|
||||||
|
urls = body.urls,
|
||||||
|
)
|
||||||
|
call.respond(HttpStatusCode.OK, ApproveSourcesResponse(grantedHosts = granted.sorted()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Route.undoSessionRoute(module: ServerModule) {
|
private fun Route.undoSessionRoute(module: ServerModule) {
|
||||||
post("/undo") {
|
post("/undo") {
|
||||||
val id = call.parameters["id"]
|
val id = call.parameters["id"]
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.correx.apps.server.research
|
||||||
|
|
||||||
|
import com.correx.core.events.EventDispatcher
|
||||||
|
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SourceListApprovalTest {
|
||||||
|
|
||||||
|
private val sessionId = SessionId("s1")
|
||||||
|
|
||||||
|
private fun grants(store: InMemoryEventStore, session: SessionId = sessionId): List<EgressHostsGrantedEvent> =
|
||||||
|
store.read(session).map { it.payload }.filterIsInstance<EgressHostsGrantedEvent>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits exactly one EgressHostsGrantedEvent with the extracted hosts for the session`() = runTest {
|
||||||
|
val store = InMemoryEventStore()
|
||||||
|
val granted = approveSourceList(
|
||||||
|
dispatcher = EventDispatcher(store),
|
||||||
|
sessionId = sessionId,
|
||||||
|
urls = listOf(
|
||||||
|
"https://example.com/a",
|
||||||
|
"https://example.com/b",
|
||||||
|
"https://docs.kotlinlang.org/spec",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf("example.com", "docs.kotlinlang.org"), granted)
|
||||||
|
|
||||||
|
val emitted = grants(store)
|
||||||
|
assertEquals(1, emitted.size)
|
||||||
|
val event = emitted.single()
|
||||||
|
assertEquals(sessionId, event.sessionId)
|
||||||
|
assertEquals(setOf("example.com", "docs.kotlinlang.org"), event.hosts)
|
||||||
|
assertEquals(BATCH_SOURCE_LIST_REASON, event.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grant is recorded under the right session`() = runTest {
|
||||||
|
val store = InMemoryEventStore()
|
||||||
|
approveSourceList(EventDispatcher(store), sessionId, listOf("https://only-mine.com/x"))
|
||||||
|
|
||||||
|
assertEquals(1, grants(store).size)
|
||||||
|
assertTrue(grants(store, SessionId("other")).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no parseable host emits no event`() = runTest {
|
||||||
|
val store = InMemoryEventStore()
|
||||||
|
val granted = approveSourceList(
|
||||||
|
dispatcher = EventDispatcher(store),
|
||||||
|
sessionId = sessionId,
|
||||||
|
urls = listOf("/relative", "just text", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(granted.isEmpty())
|
||||||
|
assertTrue(grants(store).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.correx.apps.server.research
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SourceListHostsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `distinct hosts are extracted from multiple urls`() {
|
||||||
|
val hosts = SourceListHosts.extract(
|
||||||
|
listOf(
|
||||||
|
"https://example.com/a",
|
||||||
|
"https://docs.rust-lang.org/book",
|
||||||
|
"http://api.github.com/repos",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("example.com", "docs.rust-lang.org", "api.github.com"), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate hosts collapse`() {
|
||||||
|
val hosts = SourceListHosts.extract(
|
||||||
|
listOf(
|
||||||
|
"https://example.com/one",
|
||||||
|
"https://example.com/two",
|
||||||
|
"http://example.com/three",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("example.com"), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ports are stripped`() {
|
||||||
|
val hosts = SourceListHosts.extract(listOf("https://example.com:8443/path"))
|
||||||
|
assertEquals(setOf("example.com"), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `host case is normalised to lower case`() {
|
||||||
|
val hosts = SourceListHosts.extract(
|
||||||
|
listOf("https://Example.COM/a", "https://EXAMPLE.com/b"),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("example.com"), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unparseable and relative entries are ignored`() {
|
||||||
|
val hosts = SourceListHosts.extract(
|
||||||
|
listOf(
|
||||||
|
"/local/path",
|
||||||
|
"not a url at all",
|
||||||
|
"ftp ://broken",
|
||||||
|
"",
|
||||||
|
"https://kept.com/x",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("kept.com"), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dossier source lines yield their leading url host`() {
|
||||||
|
val hosts = SourceListHosts.extract(
|
||||||
|
listOf("https://example.com/x — explains Y; bears on sub-question 2"),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("example.com"), hosts)
|
||||||
|
assertTrue("example.com" in hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user