From 6b00d89c2c14b361946afac671d67c7cfe914c03 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 19:59:18 +0000 Subject: [PATCH] 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 --- .../kotlin/com/correx/apps/server/Main.kt | 16 ++- .../com/correx/apps/server/ServerModule.kt | 4 + .../correx/apps/server/routes/TaskRoutes.kt | 2 + .../tasks/EventStoreTaskArtifactResolver.kt | 53 ++++++++++ .../SessionSummaryTaskSessionResolver.kt | 33 +++++++ .../apps/server/tasks/TaskLinkResolverTest.kt | 83 ++++++++++++++++ .../correx/core/tasks/TaskArtifactResolver.kt | 21 ++++ .../correx/core/tasks/TaskContextAssembler.kt | 98 ++++++++++++------- .../correx/core/tasks/TaskContextBundle.kt | 24 +++++ .../correx/core/tasks/TaskSessionResolver.kt | 21 ++++ .../core/tasks/TaskContextAssemblerTest.kt | 35 +++++++ .../infrastructure/tools/task/TaskTools.kt | 13 ++- 12 files changed, 364 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 2524047f..3659d831 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 4293727c..9a95f434 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -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, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 1df939a1..93c3d1ba 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -91,6 +91,8 @@ fun Route.taskRoutes(module: ServerModule) { service, module.taskKnowledgeRetriever, module.taskDocumentResolver, + module.taskArtifactResolver, + module.taskSessionResolver, ) route("/tasks") { taskCollectionRoutes(service) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt new file mode 100644 index 00000000..82d97bda --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt @@ -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) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt new file mode 100644 index 00000000..a58ef693 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt @@ -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, + ) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt new file mode 100644 index 00000000..5bbaaf71 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt @@ -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) : 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() + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt new file mode 100644 index 00000000..547d9e72 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt @@ -0,0 +1,21 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Port for resolving an ARTIFACT link target (an `ArtifactId`) to an inline summary, so the + * context bundle can carry the producing stage/session and a content excerpt instead of a bare id. + * Implemented in a higher layer (apps/server reads the event log + CAS); `core:tasks` stays + * decoupled from storage. Returns null when the artifact can't be resolved — the link then stays a + * raw related link. + */ +interface TaskArtifactResolver { + suspend fun resolve(targetId: String): ResolvedArtifact? +} + +@Serializable +data class ResolvedArtifact( + val stage: String?, + val sessionId: String?, + val excerpt: String?, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt index 3ff9fac6..b7bd3b0b 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt @@ -5,36 +5,36 @@ import com.correx.core.events.types.TaskTargetKind /** * Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded - * [com.correx.core.events.types.TaskTargetKind]: TASK targets are resolved to [RelatedTask] - * dependencies (with live status), DOC targets are resolved inline via [documentResolver], and - * anything unresolved (or ARTIFACT/SESSION, not inlined yet) becomes a raw [RelatedLink]. + * [com.correx.core.events.types.TaskTargetKind]: TASK targets resolve to [RelatedTask] dependencies + * (with live status), DOC targets inline via [documentResolver], ARTIFACT targets via + * [artifactResolver], SESSION targets via [sessionResolver]. Any kind whose resolver is absent or + * comes up empty falls back to a raw [RelatedLink]. * * When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with - * semantically-relevant snippets over the task's title+goal. Retrieval and doc-resolution failures - * are swallowed (the bundle still returns) so context assembly never fails on a flaky dependency. + * semantically-relevant snippets over the task's title+goal. Retrieval and resolution failures are + * swallowed (the bundle still returns) so context assembly never fails on a flaky dependency. */ class TaskContextAssembler( private val service: TaskService, private val retriever: TaskKnowledgeRetriever? = null, private val documentResolver: TaskDocumentResolver? = null, + private val artifactResolver: TaskArtifactResolver? = null, + private val sessionResolver: TaskSessionResolver? = null, private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT, ) { suspend fun assemble(taskId: TaskId): TaskContextBundle? { val state = service.getTask(taskId)?.state ?: return null - val dependencies = mutableListOf() - val documents = mutableListOf() - val related = mutableListOf() + val resolved = ResolvedLinks() for (link in state.links) { // Resolution dispatches on the link's recorded targetKind — no guessing from the id. - // A kind whose resolution comes up empty (missing task, unresolvable doc, or an - // ARTIFACT/SESSION we don't inline yet) falls back to a raw related link. + // A kind whose resolution comes up empty falls back to a raw related link. when (link.targetKind) { - TaskTargetKind.TASK -> addTask(link, dependencies, related) - TaskTargetKind.DOC -> addDocument(link, documents, related) - TaskTargetKind.ARTIFACT, TaskTargetKind.SESSION -> - related += RelatedLink(link.targetId, link.type.name) + TaskTargetKind.TASK -> addTask(link, resolved) + TaskTargetKind.DOC -> addDocument(link, resolved) + TaskTargetKind.ARTIFACT -> addArtifact(link, resolved) + TaskTargetKind.SESSION -> addSession(link, resolved) } } @@ -46,40 +46,43 @@ class TaskContextAssembler( goal = state.goal, acceptanceCriteria = state.acceptanceCriteria, relevantFiles = state.affectedPaths, - dependencies = dependencies, - documents = documents, - relatedLinks = related, + dependencies = resolved.dependencies, + documents = resolved.documents, + artifacts = resolved.artifacts, + sessions = resolved.sessions, + relatedLinks = resolved.related, notes = state.notes.map { NoteEntry(it.author.name, it.body) }, relevantKnowledge = retrieveKnowledge(state), ) } - private fun addTask( - link: TaskLink, - dependencies: MutableList, - related: MutableList, - ) { + /** Accumulators for one assembly pass — keeps the per-kind helpers to a single parameter. */ + private class ResolvedLinks { + val dependencies = mutableListOf() + val documents = mutableListOf() + val artifacts = mutableListOf() + val sessions = mutableListOf() + val related = mutableListOf() + } + + private fun addTask(link: TaskLink, out: ResolvedLinks) { val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull() if (linked != null) { - dependencies += RelatedTask( + out.dependencies += RelatedTask( id = linked.taskId.value, status = linked.state.status.name, title = linked.state.title, link = link.type.name, ) } else { - related += RelatedLink(link.targetId, link.type.name) + out.related += RelatedLink(link.targetId, link.type.name) } } - private suspend fun addDocument( - link: TaskLink, - documents: MutableList, - related: MutableList, - ) { - val doc = resolveDocument(link.targetId) + private suspend fun addDocument(link: TaskLink, out: ResolvedLinks) { + val doc = documentResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() } if (doc != null) { - documents += TaskDocument( + out.documents += TaskDocument( targetId = link.targetId, type = link.type.name, title = doc.title, @@ -87,13 +90,38 @@ class TaskContextAssembler( excerpt = doc.excerpt, ) } else { - related += RelatedLink(link.targetId, link.type.name) + out.related += RelatedLink(link.targetId, link.type.name) } } - private suspend fun resolveDocument(targetId: String): ResolvedDocument? { - val resolver = documentResolver ?: return null - return runCatching { resolver.resolve(targetId) }.getOrNull() + private suspend fun addArtifact(link: TaskLink, out: ResolvedLinks) { + val artifact = artifactResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() } + if (artifact != null) { + out.artifacts += TaskArtifact( + targetId = link.targetId, + type = link.type.name, + stage = artifact.stage, + sessionId = artifact.sessionId, + excerpt = artifact.excerpt, + ) + } else { + out.related += RelatedLink(link.targetId, link.type.name) + } + } + + private suspend fun addSession(link: TaskLink, out: ResolvedLinks) { + val session = sessionResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() } + if (session != null) { + out.sessions += RelatedSession( + targetId = link.targetId, + type = link.type.name, + status = session.status, + intent = session.intent, + workflowId = session.workflowId, + ) + } else { + out.related += RelatedLink(link.targetId, link.type.name) + } } private suspend fun retrieveKnowledge(state: TaskState): List { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt index 65d36c3c..db2090e6 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -18,6 +18,8 @@ data class TaskContextBundle( val relevantFiles: List, val dependencies: List, val documents: List, + val artifacts: List = emptyList(), + val sessions: List = emptyList(), val relatedLinks: List, val notes: List, val relevantKnowledge: List = emptyList(), @@ -30,6 +32,8 @@ data class TaskContextBundle( if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}") appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" } + appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() } + appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() } appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" } appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" } appendList("notes", notes) { " - [${it.author}] ${it.body}" } @@ -61,6 +65,26 @@ data class TaskDocument( val excerpt: String, ) +/** A link whose target resolved to a produced artifact: producing stage/session + content excerpt. */ +@Serializable +data class TaskArtifact( + val targetId: String, + val type: String, + val stage: String?, + val sessionId: String?, + val excerpt: String?, +) + +/** A link whose target resolved to an agent run: status + intent so the agent has the run's gist. */ +@Serializable +data class RelatedSession( + val targetId: String, + val type: String, + val status: String, + val intent: String?, + val workflowId: String?, +) + /** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */ @Serializable data class RelatedLink( diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt new file mode 100644 index 00000000..945828db --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt @@ -0,0 +1,21 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Port for resolving a SESSION link target (a `SessionId`) to an inline summary, so the context + * bundle can carry the run's status/intent instead of a bare id. Implemented in a higher layer + * (apps/server projects the session's events); `core:tasks` stays decoupled from the session + * aggregate. Returns null when the session can't be resolved — the link then stays a raw related + * link. + */ +interface TaskSessionResolver { + suspend fun resolve(targetId: String): ResolvedSession? +} + +@Serializable +data class ResolvedSession( + val status: String, + val intent: String?, + val workflowId: String?, +) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt index 5c3cf5d1..13f623f1 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt @@ -125,4 +125,39 @@ class TaskContextAssemblerTest { assertTrue(bundle.relevantKnowledge.isEmpty()) assertEquals("JWT refresh", bundle.title) } + + @Test + fun `linked artifacts and sessions are resolved inline and unresolved ones stay raw`() = runBlocking { + val artifacts = object : TaskArtifactResolver { + override suspend fun resolve(targetId: String): ResolvedArtifact? = + if (targetId == "art-1") ResolvedArtifact("architect", "sess-9", "approach: redis") else null + } + val sessions = object : TaskSessionResolver { + override suspend fun resolve(targetId: String): ResolvedSession? = + if (targetId == "sess-9") ResolvedSession("COMPLETED", "build auth", "role_pipeline") else null + } + val enriched = TaskContextAssembler(service, artifactResolver = artifacts, sessionResolver = sessions) + val task = service.createTask(project, "JWT refresh", "auth") + service.link(task.taskId, "art-1", TaskLinkType.PRODUCED, TaskTargetKind.ARTIFACT) + service.link(task.taskId, "sess-9", TaskLinkType.CONTEXT, TaskTargetKind.SESSION) + service.link(task.taskId, "art-missing", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // unresolved → raw + + val bundle = enriched.assemble(task.taskId)!! + + val artifact = bundle.artifacts.single() + assertEquals("art-1", artifact.targetId) + assertEquals("architect", artifact.stage) + assertEquals("sess-9", artifact.sessionId) + assertEquals("PRODUCED", artifact.type) + + val session = bundle.sessions.single() + assertEquals("sess-9", session.targetId) + assertEquals("COMPLETED", session.status) + assertEquals("build auth", session.intent) + assertEquals("CONTEXT", session.type) + + assertEquals(RelatedLink("art-missing", "RELATES_TO"), bundle.relatedLinks.single()) + assertTrue(bundle.render().contains("artifacts:")) + assertTrue(bundle.render().contains("sessions:")) + } } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index fe25c87a..869f0ac6 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -1,26 +1,33 @@ package com.correx.infrastructure.tools.task +import com.correx.core.tasks.TaskArtifactResolver import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskDocumentResolver import com.correx.core.tasks.TaskKnowledgeRetriever import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskSessionResolver import com.correx.core.tools.contract.Tool /** Builds the task tool set over a single [TaskService] for registration in the tool registry. */ object TaskTools { /** - * [retriever] semantically enriches the `task_context` bundle; [documentResolver] inlines - * linked ADRs/docs. Both optional — null leaves the bundle deterministic. + * [retriever] semantically enriches the `task_context` bundle; [documentResolver], + * [artifactResolver] and [sessionResolver] inline DOC/ARTIFACT/SESSION link targets. All + * optional — null leaves those links raw and the bundle deterministic. */ fun forService( service: TaskService, retriever: TaskKnowledgeRetriever? = null, documentResolver: TaskDocumentResolver? = null, + artifactResolver: TaskArtifactResolver? = null, + sessionResolver: TaskSessionResolver? = null, ): List = listOf( TaskCreateTool(service), TaskUpdateTool(service), TaskDeleteTool(service), - TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)), + TaskContextTool( + TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), + ), ) }