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:
@@ -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)
|
||||
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user