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()
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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<RelatedTask>()
|
||||
val documents = mutableListOf<TaskDocument>()
|
||||
val related = mutableListOf<RelatedLink>()
|
||||
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<RelatedTask>,
|
||||
related: MutableList<RelatedLink>,
|
||||
) {
|
||||
/** Accumulators for one assembly pass — keeps the per-kind helpers to a single parameter. */
|
||||
private class ResolvedLinks {
|
||||
val dependencies = mutableListOf<RelatedTask>()
|
||||
val documents = mutableListOf<TaskDocument>()
|
||||
val artifacts = mutableListOf<TaskArtifact>()
|
||||
val sessions = mutableListOf<RelatedSession>()
|
||||
val related = mutableListOf<RelatedLink>()
|
||||
}
|
||||
|
||||
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<TaskDocument>,
|
||||
related: MutableList<RelatedLink>,
|
||||
) {
|
||||
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<KnowledgeHit> {
|
||||
|
||||
@@ -18,6 +18,8 @@ data class TaskContextBundle(
|
||||
val relevantFiles: List<String>,
|
||||
val dependencies: List<RelatedTask>,
|
||||
val documents: List<TaskDocument>,
|
||||
val artifacts: List<TaskArtifact> = emptyList(),
|
||||
val sessions: List<RelatedSession> = emptyList(),
|
||||
val relatedLinks: List<RelatedLink>,
|
||||
val notes: List<NoteEntry>,
|
||||
val relevantKnowledge: List<KnowledgeHit> = 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(
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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:"))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -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<Tool> =
|
||||
listOf(
|
||||
TaskCreateTool(service),
|
||||
TaskUpdateTool(service),
|
||||
TaskDeleteTool(service),
|
||||
TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)),
|
||||
TaskContextTool(
|
||||
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user