feat(tasks): inline ARTIFACT/SESSION links in the context bundle

The context bundle resolved only TASK and DOC link targets; ARTIFACT and
SESSION stayed raw. Add two ports (TaskArtifactResolver/TaskSessionResolver)
with apps/server adapters: artifacts resolve to producing stage/session +
content excerpt from CAS, sessions to status/intent/workflow. Unresolved
targets still fall back to raw links. Wired through the tool and REST bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:59:18 +00:00
parent 6e044d0ab9
commit 6b00d89c2c
12 changed files with 364 additions and 39 deletions
@@ -221,8 +221,20 @@ fun main() {
// Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the
// L3 retriever this needs no late binding.
val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot)
// Inline ARTIFACT/SESSION link targets too: both read the event log (+ CAS) that already exists
// here, so — like the doc resolver — they need no late binding.
val taskArtifactResolver =
com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore)
val taskSessionResolver =
com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore)
val taskService = TaskService(eventStore)
val taskTools = TaskTools.forService(taskService, taskKnowledgeRetriever, taskDocumentResolver)
val taskTools = TaskTools.forService(
taskService,
taskKnowledgeRetriever,
taskDocumentResolver,
taskArtifactResolver,
taskSessionResolver,
)
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
workspaceRoot,
@@ -521,6 +533,8 @@ fun main() {
healthMonitor = healthMonitor,
taskKnowledgeRetriever = taskKnowledgeRetriever,
taskDocumentResolver = taskDocumentResolver,
taskArtifactResolver = taskArtifactResolver,
taskSessionResolver = taskSessionResolver,
)
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
// services. Built after the module so the rebuild hook can swap them in place.
@@ -119,6 +119,10 @@ class ServerModule(
val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null,
// Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links.
val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null,
// Resolves linked artifacts inline in the task context bundle. Null leaves them as raw links.
val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null,
// Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links.
val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null,
) {
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator,
@@ -91,6 +91,8 @@ fun Route.taskRoutes(module: ServerModule) {
service,
module.taskKnowledgeRetriever,
module.taskDocumentResolver,
module.taskArtifactResolver,
module.taskSessionResolver,
)
route("/tasks") {
taskCollectionRoutes(service)
@@ -0,0 +1,53 @@
package com.correx.apps.server.tasks
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.tasks.ResolvedArtifact
import com.correx.core.tasks.TaskArtifactResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val EXCERPT_MAX = 280
/**
* Resolves ARTIFACT link targets for the task context bundle by scanning the event log for the
* artifact's lifecycle events: [ArtifactCreatedEvent]/[ArtifactContentStoredEvent] give the
* producing stage + session, and the content hash is dereferenced against the CAS [ArtifactStore]
* for an excerpt. Returns null when no events match the id — the link then stays a raw related link.
*/
class EventStoreTaskArtifactResolver(
private val eventStore: EventStore,
private val artifactStore: ArtifactStore,
private val excerptMax: Int = EXCERPT_MAX,
) : TaskArtifactResolver {
override suspend fun resolve(targetId: String): ResolvedArtifact? = withContext(Dispatchers.IO) {
val artifactId = ArtifactId(targetId)
var stage: String? = null
var sessionId: String? = null
var contentHash: ArtifactId? = null
eventStore.allEvents().forEach { stored ->
when (val p = stored.payload) {
is ArtifactCreatedEvent -> if (p.artifactId == artifactId) {
stage = p.stageId.value
sessionId = p.sessionId.value
}
is ArtifactContentStoredEvent -> if (p.artifactId == artifactId) {
stage = stage ?: p.stageId.value
sessionId = sessionId ?: p.sessionId.value
contentHash = p.contentHash
}
else -> Unit
}
}
if (stage == null && sessionId == null && contentHash == null) return@withContext null
val excerpt = contentHash?.let { hash ->
runCatching { artifactStore.get(hash) }.getOrNull()
?.toString(Charsets.UTF_8)?.trim()?.takeIf { it.isNotEmpty() }?.take(excerptMax)
}
ResolvedArtifact(stage = stage, sessionId = sessionId, excerpt = excerpt)
}
}
@@ -0,0 +1,33 @@
package com.correx.apps.server.tasks
import com.correx.core.events.stores.EventStore
import com.correx.core.sessions.SessionSummaryProjector
import com.correx.core.tasks.ResolvedSession
import com.correx.core.tasks.TaskSessionResolver
import com.correx.core.utils.TypeId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Resolves SESSION link targets for the task context bundle by projecting the session's events into
* a [com.correx.core.sessions.SessionSummary] (status / intent / workflow). Returns null when the
* session has no events — the link then stays a raw related link.
*/
class SessionSummaryTaskSessionResolver(
private val eventStore: EventStore,
) : TaskSessionResolver {
private val projector = SessionSummaryProjector()
override suspend fun resolve(targetId: String): ResolvedSession? = withContext(Dispatchers.IO) {
val sessionId = TypeId(targetId)
val events = eventStore.read(sessionId)
if (events.isEmpty()) return@withContext null
val summary = projector.project(sessionId, events)
ResolvedSession(
status = summary.status,
intent = summary.intent,
workflowId = summary.workflowId,
)
}
}
@@ -0,0 +1,83 @@
package com.correx.apps.server.tasks
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.util.UUID
class TaskLinkResolverTest {
private val store: EventStore = InMemoryEventStore()
private suspend fun append(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload) {
store.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
),
)
}
@Test
fun `session resolver projects status and intent and is null for an unknown id`() = runBlocking {
val sessionId = SessionId("sess-9")
append(sessionId, InitialIntentEvent(sessionId, "build auth"))
append(sessionId, WorkflowStartedEvent(sessionId, "role_pipeline", StageId("architect")))
append(sessionId, WorkflowCompletedEvent(sessionId, StageId("review"), totalStages = 3))
val resolver = SessionSummaryTaskSessionResolver(store)
val resolved = resolver.resolve("sess-9")!!
assertEquals("COMPLETED", resolved.status)
assertEquals("build auth", resolved.intent)
assertEquals("role_pipeline", resolved.workflowId)
assertNull(resolver.resolve("sess-unknown"))
}
@Test
fun `artifact resolver reads stage, session and content excerpt and is null for an unknown id`() = runBlocking {
val sessionId = SessionId("sess-9")
val artifactId = ArtifactId("art-1")
val contentHash = ArtifactId("hash-1")
val artifactStore = FakeArtifactStore(mapOf(contentHash to "approach: use redis".toByteArray()))
append(sessionId, ArtifactContentStoredEvent(artifactId, contentHash, sessionId, StageId("architect")))
append(sessionId, ArtifactCreatedEvent(artifactId, sessionId, StageId("architect"), schemaVersion = 1))
val resolver = EventStoreTaskArtifactResolver(store, artifactStore)
val resolved = resolver.resolve("art-1")!!
assertEquals("architect", resolved.stage)
assertEquals("sess-9", resolved.sessionId)
assertEquals("approach: use redis", resolved.excerpt)
assertNull(resolver.resolve("art-unknown"))
}
private class FakeArtifactStore(private val blobs: Map<ArtifactId, ByteArray>) : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = error("unused")
override suspend fun get(id: ArtifactId): ByteArray? = blobs[id]
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
}