From 0c6ac903b7cf6991e7a0badcce6bbba6c4ec4ad0 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 19:00:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20native=20task=20tracking=20?= =?UTF-8?q?=E2=80=94=20aggregate,=20agent=20tools,=20context=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The event log stays the only source of truth; the board is a projection, and all of a project's task events live in one stream (tasks:). core: - new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/ BoardProjector/CounterProjection/Repository + TaskService write path that appends events via the existing EventStore (no new storage) - task events in core:events (sealed marker TaskEvent), registered in Serialization.kt; status is derived by the reducer from lifecycle facts - work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle resolution dispatches on the recorded kind instead of guessing from the id - delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome surfaces: - agent tools (Tier T2 mutators, T1 read): task_create / task_update / task_delete / task_context, registered in both the main and per-workspace tool registries - REST GET /tasks/{id}/context for external agents context bundle (TaskContextAssembler): - task fields + acceptance criteria + relevant files + resolved dependency tasks (live status) + raw related links + notes, with a compact render() - semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing L3 retriever (late-bound holder for composition-root ordering) - ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter (docs/decisions/adr-*.md, padding-agnostic; *.md paths) Unit-verified across core:tasks / infrastructure:tools / apps:server; full gradlew check green. Not yet live-QA'd end-to-end against a running server. Co-Authored-By: Claude Opus 4.8 --- apps/server/build.gradle | 1 + .../com/correx/apps/server/Application.kt | 2 + .../kotlin/com/correx/apps/server/Main.kt | 20 +++ .../com/correx/apps/server/ServerModule.kt | 4 + .../memory/DeferredTaskKnowledgeRetriever.kt | 18 ++ .../memory/RepoKnowledgeTaskRetriever.kt | 19 ++ .../correx/apps/server/routes/TaskRoutes.kt | 33 ++++ .../server/tasks/FileTaskDocumentResolver.kt | 91 ++++++++++ .../tasks/FileTaskDocumentResolverTest.kt | 72 ++++++++ .../correx/core/events/events/TaskEvents.kt | 142 +++++++++++++++ .../events/serialization/Serialization.kt | 32 ++++ .../correx/core/events/types/IdentityTypes.kt | 4 + .../correx/core/events/types/TaskLinkType.kt | 18 ++ .../core/events/types/TaskNoteAuthor.kt | 11 ++ .../core/events/types/TaskTargetKind.kt | 16 ++ core/tasks/build.gradle | 12 ++ .../correx/core/tasks/DefaultTaskReducer.kt | 147 +++++++++++++++ .../core/tasks/DefaultTaskRepository.kt | 26 +++ .../main/kotlin/com/correx/core/tasks/Task.kt | 8 + .../kotlin/com/correx/core/tasks/TaskBoard.kt | 6 + .../correx/core/tasks/TaskBoardProjector.kt | 22 +++ .../correx/core/tasks/TaskContextAssembler.kt | 109 ++++++++++++ .../correx/core/tasks/TaskContextBundle.kt | 83 +++++++++ .../core/tasks/TaskCounterProjection.kt | 24 +++ .../com/correx/core/tasks/TaskCounterState.kt | 6 + .../correx/core/tasks/TaskDocumentResolver.kt | 21 +++ .../core/tasks/TaskKnowledgeRetriever.kt | 11 ++ .../kotlin/com/correx/core/tasks/TaskLink.kt | 11 ++ .../kotlin/com/correx/core/tasks/TaskNote.kt | 11 ++ .../com/correx/core/tasks/TaskReducer.kt | 10 ++ .../com/correx/core/tasks/TaskService.kt | 166 +++++++++++++++++ .../kotlin/com/correx/core/tasks/TaskState.kt | 26 +++ .../com/correx/core/tasks/TaskStatus.kt | 16 ++ .../com/correx/core/tasks/TaskStreams.kt | 16 ++ .../core/tasks/DefaultTaskReducerTest.kt | 147 +++++++++++++++ .../correx/core/tasks/InMemoryEventStore.kt | 50 ++++++ .../core/tasks/TaskBoardProjectorTest.kt | 74 ++++++++ .../core/tasks/TaskContextAssemblerTest.kt | 128 +++++++++++++ .../core/tasks/TaskCounterProjectionTest.kt | 51 ++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 84 +++++++++ docs/decisions/adr-0012-task-tracking.md | 112 ++++++++++++ .../infrastructure/InfrastructureModule.kt | 8 +- infrastructure/tools/build.gradle | 1 + .../tools/task/TaskContextTool.kt | 56 ++++++ .../tools/task/TaskCreateTool.kt | 89 ++++++++++ .../tools/task/TaskDeleteTool.kt | 55 ++++++ .../infrastructure/tools/task/TaskTools.kt | 26 +++ .../tools/task/TaskUpdateTool.kt | 158 ++++++++++++++++ .../infrastructure/tools/task/ToolParams.kt | 11 ++ .../tools/task/TaskToolsTest.kt | 168 ++++++++++++++++++ settings.gradle | 1 + 51 files changed, 2431 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt create mode 100644 core/tasks/build.gradle create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt create mode 100644 docs/decisions/adr-0012-task-tracking.md create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt create mode 100644 infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt diff --git a/apps/server/build.gradle b/apps/server/build.gradle index aea15c5e..95399e63 100644 --- a/apps/server/build.gradle +++ b/apps/server/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation project(':core:events') implementation project(':core:approvals') implementation project(':core:sessions') + implementation project(':core:tasks') implementation project(':core:kernel') implementation project(':core:journal') implementation project(':core:inference') diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt index 108b8acb..2bdee935 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt @@ -3,6 +3,7 @@ package com.correx.apps.server import com.correx.apps.server.health.HealthInspectionService import com.correx.apps.server.routes.providerRoutes import com.correx.apps.server.routes.sessionRoutes +import com.correx.apps.server.routes.taskRoutes import com.correx.apps.server.routes.workflowRoutes import com.correx.apps.server.ws.GlobalStreamHandler import io.ktor.http.HttpStatusCode @@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) { sessionRoutes(module) workflowRoutes(module) providerRoutes(module) + taskRoutes(module) } } 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 e65e3b87..2524047f 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 @@ -47,6 +47,7 @@ import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.SessionProjector import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tasks.TaskService import com.correx.core.tools.registry.ToolRegistry import com.correx.core.transitions.resolution.DefaultTransitionResolver import com.correx.core.validation.artifact.ArtifactPayloadValidator @@ -80,6 +81,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe import com.correx.core.inference.InferenceProvider import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider import com.correx.infrastructure.tools.DispatchingToolExecutor +import com.correx.infrastructure.tools.task.TaskTools import com.correx.infrastructure.tools.FileEditConfig import com.correx.infrastructure.tools.FileReadConfig import com.correx.infrastructure.tools.FileWriteConfig @@ -211,6 +213,16 @@ fun main() { null }, ) + // Agents create/update/delete tasks through the tool system (tier-gated like any tool); + // the service appends to the same event log. Project is derived from the task id prefix. + // The knowledge retriever is late-bound: the L3 retriever isn't built until later in this + // composition root, so the holder is captured now and its delegate set once L3 exists (below). + val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever() + // 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) + val taskService = TaskService(eventStore) + val taskTools = TaskTools.forService(taskService, taskKnowledgeRetriever, taskDocumentResolver) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( workspaceRoot, @@ -219,6 +231,7 @@ fun main() { toolsConfig, researchToolConfig, ), + extraTools = taskTools, ) val toolExecutor = InfrastructureModule.createToolExecutor( registry = toolRegistry, @@ -246,6 +259,7 @@ fun main() { val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace -> val wsRegistry = InfrastructureModule.createToolRegistry( buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig), + extraTools = taskTools, ) val wsExecutor = DispatchingToolExecutor(wsRegistry) WorkspaceTools(registry = wsRegistry, executor = wsExecutor) @@ -311,6 +325,10 @@ fun main() { } else { null } + // L3 retriever now exists — bind it into the task context bundle's knowledge port so the + // task_context tool and GET /tasks/{id}/context return semantically-relevant snippets. + taskKnowledgeRetriever.delegate = + repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) } val orchestrator = DefaultSessionOrchestrator( repositories = repositories, engines = engines, @@ -501,6 +519,8 @@ fun main() { operatorProfile = operatorProfile, profileAdaptationService = profileAdaptationService, healthMonitor = healthMonitor, + taskKnowledgeRetriever = taskKnowledgeRetriever, + taskDocumentResolver = taskDocumentResolver, ) // 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 5a794926..4293727c 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 @@ -115,6 +115,10 @@ class ServerModule( // Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false); // records degraded/restored edge events on the system session, started in [start]. private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null, + // Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it. + 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, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt new file mode 100644 index 00000000..25fbc020 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt @@ -0,0 +1,18 @@ +package com.correx.apps.server.memory + +import com.correx.core.tasks.KnowledgeHit +import com.correx.core.tasks.TaskKnowledgeRetriever + +/** + * Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry + * construction time, which is before the L3 retriever exists in the composition root; this holder + * is created early, captured by the tools and the route, and has its [delegate] set once the L3 + * retriever is available. Until then (or when L3 is disabled) it returns no hits. + */ +class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever { + @Volatile + var delegate: TaskKnowledgeRetriever? = null + + override suspend fun retrieve(query: String, limit: Int): List = + delegate?.retrieve(query, limit) ?: emptyList() +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt new file mode 100644 index 00000000..232411ec --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt @@ -0,0 +1,19 @@ +package com.correx.apps.server.memory + +import com.correx.apps.server.health.SYSTEM_SESSION +import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever +import com.correx.core.tasks.KnowledgeHit +import com.correx.core.tasks.TaskKnowledgeRetriever + +/** + * Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector + * retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not + * session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it). + */ +class RepoKnowledgeTaskRetriever( + private val delegate: RepoKnowledgeRetriever, +) : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List = + delegate.retrieve(SYSTEM_SESSION, query, limit) + .map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) } +} 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 new file mode 100644 index 00000000..9e91b282 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -0,0 +1,33 @@ +package com.correx.apps.server.routes + +import com.correx.apps.server.ServerModule +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskService +import com.correx.core.utils.TypeId +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.route + +/** + * Read surface for tasks. Exposes the agent context bundle (`GET /tasks/{id}/context`) for + * external agents (Claude Code / aider / codex). In-process roles use the `task_context` tool, + * which shares the same [TaskContextAssembler]. Both read from the event log; nothing is stored. + */ +fun Route.taskRoutes(module: ServerModule) { + val assembler = TaskContextAssembler( + TaskService(module.eventStore), + module.taskKnowledgeRetriever, + module.taskDocumentResolver, + ) + route("/tasks/{id}") { + get("/context") { + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + val bundle = assembler.assemble(TypeId(id)) + ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respond(bundle) + } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt new file mode 100644 index 00000000..c64decf3 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt @@ -0,0 +1,91 @@ +package com.correx.apps.server.tasks + +import com.correx.core.tasks.ResolvedDocument +import com.correx.core.tasks.TaskDocumentResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.readText + +private const val EXCERPT_MAX = 280 +private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$") +private val ADR_FILE = Regex("^adr-0*(\\d+)") +private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$") +private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$") + +/** + * Resolves link targets to repo documents for the task context bundle: + * - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr--*.md` (matched by number, + * padding-agnostic); + * - explicit "*.md" paths under the repo root. + * Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter + * `description:` or the first prose paragraph, capped. Returns null for anything else. + */ +class FileTaskDocumentResolver( + private val repoRoot: Path, + private val decisionsDir: Path = repoRoot.resolve("docs/decisions"), +) : TaskDocumentResolver { + + override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) { + resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim()) + } + + private fun resolveAdr(targetId: String): ResolvedDocument? { + val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null + if (!decisionsDir.isDirectory()) return null + val file = Files.list(decisionsDir).use { stream -> + stream.filter { it.isRegularFile() && it.name.endsWith(".md") } + .filter { adrNumber(it.name) == num } + .findFirst() + .orElse(null) + } ?: return null + return readDocument(file) + } + + private fun resolveMarkdownPath(targetId: String): ResolvedDocument? { + if (!targetId.endsWith(".md")) return null + val candidate = repoRoot.resolve(targetId).normalize() + if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null + return readDocument(candidate) + } + + private fun adrNumber(fileName: String): Int? = + ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull() + + private fun readDocument(file: Path): ResolvedDocument { + val text = runCatching { file.readText() }.getOrDefault("") + return ResolvedDocument( + title = extractTitle(text) ?: file.name, + path = repoRoot.relativize(file).toString(), + excerpt = extractExcerpt(text), + ) + } + + private fun extractTitle(text: String): String? { + FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it } + return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim() + } + + private fun extractExcerpt(text: String): String { + val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim() + return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim() + } + + private fun firstParagraph(text: String): String = + stripFrontmatter(text).lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") } + ?: "" + + private fun stripFrontmatter(text: String): String { + if (!text.startsWith("---")) return text + val lines = text.lines() + val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" } + return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt new file mode 100644 index 00000000..fc4a3728 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt @@ -0,0 +1,72 @@ +package com.correx.apps.server.tasks + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +class FileTaskDocumentResolverTest { + + @TempDir + lateinit var repo: Path + + private fun writeAdr() { + val dir = repo.resolve("docs/decisions").also { it.createDirectories() } + dir.resolve("adr-0007-redis.md").writeText( + """ + --- + name: "ADR 7 Redis Sessions" + description: "use redis for session storage" + --- + + # ADR 7: use redis for session storage + + **status:** accepted + + ## decision + Redis backs session storage. + """.trimIndent(), + ) + } + + @Test + fun `resolves an ADR id padding-agnostically`() = runBlocking { + writeAdr() + val resolver = FileTaskDocumentResolver(repo) + + val byShort = resolver.resolve("adr-7") + val byPadded = resolver.resolve("ADR-0007") + + assertEquals("ADR 7 Redis Sessions", byShort?.title) + assertEquals("use redis for session storage", byShort?.excerpt) + assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path) + assertEquals(byShort, byPadded) + } + + @Test + fun `unknown ADR resolves to null`() = runBlocking { + writeAdr() + assertNull(FileTaskDocumentResolver(repo).resolve("adr-999")) + } + + @Test + fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking { + repo.resolve("docs/notes").createDirectories() + repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n") + + val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md") + + assertEquals("Plan", doc?.title) + assertEquals("The plan body.", doc?.excerpt) + assertEquals("docs/notes/plan.md", doc?.path) + } + + @Test + fun `non-document target resolves to null`() = runBlocking { + assertNull(FileTaskDocumentResolver(repo).resolve("ext-123")) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt new file mode 100644 index 00000000..dac33e83 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt @@ -0,0 +1,142 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Marker over every task-lifecycle event. Deliberately *not* part of the serialized + * [EventPayload] hierarchy — concrete events implement both interfaces — so projections + * can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a + * giant `when`. Status is never carried on the wire: it is derived by the task reducer + * from these facts (mirroring how session status is derived from stage events). + */ +sealed interface TaskEvent { + val taskId: TaskId +} + +@Serializable +@SerialName("TaskCreated") +data class TaskCreatedEvent( + override val taskId: TaskId, + val projectId: ProjectId, + val key: String, + val title: String, + val goal: String, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskClaimed") +data class TaskClaimedEvent( + override val taskId: TaskId, + val claimant: String, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskReleased") +data class TaskReleasedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskBlocked") +data class TaskBlockedEvent( + override val taskId: TaskId, + val reason: String, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskUnblocked") +data class TaskUnblockedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskSubmittedForReview") +data class TaskSubmittedForReviewEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskCompleted") +data class TaskCompletedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskReopened") +data class TaskReopenedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskCancelled") +data class TaskCancelledEvent( + override val taskId: TaskId, + val reason: String? = null, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskEdited") +data class TaskEditedEvent( + override val taskId: TaskId, + val title: String? = null, + val goal: String? = null, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskAcceptanceCriteriaSet") +data class TaskAcceptanceCriteriaSetEvent( + override val taskId: TaskId, + val criteria: List, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskAffectedPathsSet") +data class TaskAffectedPathsSetEvent( + override val taskId: TaskId, + val paths: List, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskLinked") +data class TaskLinkedEvent( + override val taskId: TaskId, + val targetId: String, + // `linkType`, not `type`: the JSON polymorphic class discriminator is "type". + @SerialName("linkType") val type: TaskLinkType, + val targetKind: TaskTargetKind, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskUnlinked") +data class TaskUnlinkedEvent( + override val taskId: TaskId, + val targetId: String, + @SerialName("linkType") val type: TaskLinkType, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskNoteAdded") +data class TaskNoteAddedEvent( + override val taskId: TaskId, + val author: TaskNoteAuthor, + val body: String, +) : EventPayload, TaskEvent + +/** + * Soft-delete tombstone. The event stays in the log (history is never rewritten); the board + * projection drops the task from active views. Distinct from cancellation, which is a + * lifecycle outcome that stays visible as CANCELLED. + */ +@Serializable +@SerialName("TaskDeleted") +data class TaskDeletedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 533032f2..76667f4d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -72,6 +72,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowProposedEvent import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskUnlinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskDeletedEvent import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic @@ -151,6 +167,22 @@ val eventModule = SerializersModule { subclass(LowQualityExtractionEvent::class) subclass(EgressHostsGrantedEvent::class) subclass(StaticFindingsRecordedEvent::class) + subclass(TaskCreatedEvent::class) + subclass(TaskClaimedEvent::class) + subclass(TaskReleasedEvent::class) + subclass(TaskBlockedEvent::class) + subclass(TaskUnblockedEvent::class) + subclass(TaskSubmittedForReviewEvent::class) + subclass(TaskCompletedEvent::class) + subclass(TaskReopenedEvent::class) + subclass(TaskCancelledEvent::class) + subclass(TaskEditedEvent::class) + subclass(TaskAcceptanceCriteriaSetEvent::class) + subclass(TaskAffectedPathsSetEvent::class) + subclass(TaskLinkedEvent::class) + subclass(TaskUnlinkedEvent::class) + subclass(TaskNoteAddedEvent::class) + subclass(TaskDeletedEvent::class) } } diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt index dcc3e58d..d40471ec 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt @@ -19,6 +19,10 @@ typealias TransitionId = TypeId typealias ArtifactId = TypeId +// Tasks types +typealias TaskId = TypeId + + // Context types typealias ContextPackId = TypeId typealias ContextEntryId = TypeId diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt new file mode 100644 index 00000000..f1981591 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt @@ -0,0 +1,18 @@ +package com.correx.core.events.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]), + * or sessions (by [SessionId]) — the target is stored as a plain id string plus a type. + */ +@Serializable +enum class TaskLinkType { + @SerialName("DEPENDS_ON") DEPENDS_ON, + @SerialName("BLOCKS") BLOCKS, + @SerialName("IMPLEMENTS") IMPLEMENTS, + @SerialName("RELATES_TO") RELATES_TO, + @SerialName("PRODUCED") PRODUCED, + @SerialName("CONTEXT") CONTEXT, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt new file mode 100644 index 00000000..7f2fd37c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt @@ -0,0 +1,11 @@ +package com.correx.core.events.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Who authored a task note — used to keep the agent and human lanes separable. */ +@Serializable +enum class TaskNoteAuthor { + @SerialName("AGENT") AGENT, + @SerialName("HUMAN") HUMAN, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt new file mode 100644 index 00000000..e57cf254 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt @@ -0,0 +1,16 @@ +package com.correx.core.events.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * What a task link points at — recorded on the link so the context bundle dispatches resolution + * deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id. + */ +@Serializable +enum class TaskTargetKind { + @SerialName("TASK") TASK, + @SerialName("DOC") DOC, + @SerialName("ARTIFACT") ARTIFACT, + @SerialName("SESSION") SESSION, +} diff --git a/core/tasks/build.gradle b/core/tasks/build.gradle new file mode 100644 index 00000000..9fb5488c --- /dev/null +++ b/core/tasks/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + testImplementation(testFixtures(project(":testing:contracts"))) +} + +tasks.named("koverVerify").configure { enabled = false } diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt new file mode 100644 index 00000000..4591db35 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt @@ -0,0 +1,147 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskDeletedEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskUnlinkedEvent +import kotlinx.datetime.Instant + +/** + * Folds task events into [TaskState]. Field-update events always apply; lifecycle events + * apply only on a legal transition — an illegal transition is ignored and counted in + * [TaskState.invalidTransitions] (mirrors how `SessionState` tracks invalid transitions). + */ +class DefaultTaskReducer : TaskReducer { + + override fun reduce(state: TaskState, event: StoredEvent): TaskState { + val payload = event.payload as? TaskEvent ?: return state + val ts = event.metadata.timestamp + val base = state.copy( + createdAt = state.createdAt ?: ts, + updatedAt = ts, + ) + return reduceContent(state, base, payload, ts) + ?: reduceTransition(state, base, payload) + } + + /** Field updates that do not touch status. Returns null for lifecycle events. */ + private fun reduceContent( + state: TaskState, + base: TaskState, + payload: TaskEvent, + ts: Instant, + ): TaskState? = when (payload) { + is TaskCreatedEvent -> base.copy( + status = TaskStatus.TODO, + key = payload.key, + projectId = payload.projectId, + title = payload.title, + goal = payload.goal, + acceptanceCriteria = payload.acceptanceCriteria, + affectedPaths = payload.affectedPaths, + ) + + is TaskEditedEvent -> base.copy( + title = payload.title ?: state.title, + goal = payload.goal ?: state.goal, + ) + + is TaskAcceptanceCriteriaSetEvent -> base.copy(acceptanceCriteria = payload.criteria) + + is TaskAffectedPathsSetEvent -> base.copy(affectedPaths = payload.paths) + + is TaskLinkedEvent -> base.copy( + links = (state.links + TaskLink(payload.targetId, payload.type, payload.targetKind)).distinct(), + ) + + is TaskUnlinkedEvent -> base.copy( + links = state.links.filterNot { + it.targetId == payload.targetId && it.type == payload.type + }, + ) + + is TaskNoteAddedEvent -> base.copy( + notes = state.notes + TaskNote(payload.author, payload.body, ts), + ) + + is TaskDeletedEvent -> base.copy(deleted = true) + + else -> null + } + + /** Lifecycle events: apply on a legal transition, else count it invalid. */ + private fun reduceTransition( + state: TaskState, + base: TaskState, + payload: TaskEvent, + ): TaskState { + val from = state.status + return when (payload) { + is TaskClaimedEvent -> + base.transitionTo(TaskStatus.IN_PROGRESS, from == TaskStatus.TODO) { + it.copy(claimant = payload.claimant) + } + + is TaskReleasedEvent -> + base.transitionTo(TaskStatus.TODO, from == TaskStatus.IN_PROGRESS) { + it.copy(claimant = null) + } + + is TaskBlockedEvent -> + base.transitionTo(TaskStatus.BLOCKED, from in WORKING) + + is TaskUnblockedEvent -> + base.transitionTo( + if (state.claimant != null) TaskStatus.IN_PROGRESS else TaskStatus.TODO, + from == TaskStatus.BLOCKED, + ) + + is TaskSubmittedForReviewEvent -> + base.transitionTo(TaskStatus.IN_REVIEW, from == TaskStatus.IN_PROGRESS) + + is TaskCompletedEvent -> + base.transitionTo(TaskStatus.DONE, from == TaskStatus.IN_REVIEW) + + is TaskReopenedEvent -> + base.transitionTo(TaskStatus.TODO, from in TERMINAL) { + it.copy(claimant = null) + } + + is TaskCancelledEvent -> + base.transitionTo(TaskStatus.CANCELLED, from in CANCELLABLE) + + else -> state + } ?: state.copy(invalidTransitions = state.invalidTransitions + 1) + } + + private inline fun TaskState.transitionTo( + to: TaskStatus, + legal: Boolean, + mutate: (TaskState) -> TaskState = { it }, + ): TaskState? = if (legal) mutate(copy(status = to)) else null + + private companion object { + val WORKING = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW) + val TERMINAL = setOf(TaskStatus.DONE, TaskStatus.CANCELLED) + val CANCELLABLE = setOf( + TaskStatus.TODO, + TaskStatus.IN_PROGRESS, + TaskStatus.IN_REVIEW, + TaskStatus.BLOCKED, + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt new file mode 100644 index 00000000..830caf82 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt @@ -0,0 +1,26 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.core.sessions.projections.replay.EventReplayer + +/** + * Reads a project's task board by replaying its stream. Mirrors `DefaultSessionRepository`: + * the repository is a thin read model over an [EventReplayer]; the event log is the source + * of truth and the board is rebuilt from it. + */ +class DefaultTaskRepository( + private val replayer: EventReplayer, + private val streamId: SessionId, +) { + + fun board(): TaskBoard = replayer.rebuild(streamId) + + fun getTask(taskId: TaskId): Task? = + board()[taskId]?.takeUnless { it.deleted }?.let { Task(taskId, it) } + + fun list(): List = + board().filterValues { !it.deleted }.map { (id, state) -> Task(id, state) } + + fun count(): Int = list().size +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt new file mode 100644 index 00000000..41b246ed --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt @@ -0,0 +1,8 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId + +data class Task( + val taskId: TaskId, + val state: TaskState, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt new file mode 100644 index 00000000..2c47929b --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt @@ -0,0 +1,6 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId + +/** All tasks in a project stream, keyed by id — the unit a single replay produces. */ +typealias TaskBoard = Map diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt new file mode 100644 index 00000000..569d78ff --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt @@ -0,0 +1,22 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskEvent +import com.correx.core.sessions.projections.Projection + +/** + * Folds a per-project task stream into a [TaskBoard]. Each event is routed to its owning + * task by [TaskEvent.taskId]; non-task payloads leave the board untouched. + */ +class TaskBoardProjector( + private val reducer: TaskReducer, +) : Projection { + + override fun initial(): TaskBoard = emptyMap() + + override fun apply(state: TaskBoard, event: StoredEvent): TaskBoard { + val taskId = (event.payload as? TaskEvent)?.taskId ?: return state + val current = state[taskId] ?: TaskState() + return state + (taskId to reducer.reduce(current, event)) + } +} 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 new file mode 100644 index 00000000..3ff9fac6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt @@ -0,0 +1,109 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +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]. + * + * 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. + */ +class TaskContextAssembler( + private val service: TaskService, + private val retriever: TaskKnowledgeRetriever? = null, + private val documentResolver: TaskDocumentResolver? = 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() + 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. + 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) + } + } + + return TaskContextBundle( + id = taskId.value, + key = state.key, + status = state.status.name, + title = state.title, + goal = state.goal, + acceptanceCriteria = state.acceptanceCriteria, + relevantFiles = state.affectedPaths, + dependencies = dependencies, + documents = documents, + relatedLinks = related, + notes = state.notes.map { NoteEntry(it.author.name, it.body) }, + relevantKnowledge = retrieveKnowledge(state), + ) + } + + private fun addTask( + link: TaskLink, + dependencies: MutableList, + related: MutableList, + ) { + val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull() + if (linked != null) { + 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) + } + } + + private suspend fun addDocument( + link: TaskLink, + documents: MutableList, + related: MutableList, + ) { + val doc = resolveDocument(link.targetId) + if (doc != null) { + documents += TaskDocument( + targetId = link.targetId, + type = link.type.name, + title = doc.title, + path = doc.path, + excerpt = doc.excerpt, + ) + } else { + 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 retrieveKnowledge(state: TaskState): List { + val active = retriever ?: return emptyList() + val query = listOfNotNull(state.title, state.goal).joinToString(" ").trim() + if (query.isEmpty()) return emptyList() + return runCatching { active.retrieve(query, knowledgeLimit) }.getOrDefault(emptyList()) + } + + private companion object { + const val DEFAULT_KNOWLEDGE_LIMIT = 5 + } +} 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 new file mode 100644 index 00000000..65d36c3c --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -0,0 +1,83 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Token-optimized context bundle for a task — the single payload an agent needs to start work: + * the task itself, its acceptance criteria, resolved dependency tasks, the related (non-task) + * links it should fetch, the relevant files, and notes. Assembled by [TaskContextAssembler]. + */ +@Serializable +data class TaskContextBundle( + val id: String, + val key: String?, + val status: String, + val title: String?, + val goal: String?, + val acceptanceCriteria: List, + val relevantFiles: List, + val dependencies: List, + val documents: List, + val relatedLinks: List, + val notes: List, + val relevantKnowledge: List = emptyList(), +) { + /** Compact, label-per-line rendering for tool output (cheaper than JSON for the model). */ + fun render(): String = buildString { + appendLine("task $id [$status] ${title.orEmpty()}".trimEnd()) + goal?.let { appendLine("goal: $it") } + appendList("acceptance_criteria", acceptanceCriteria) { " - $it" } + 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("related", relatedLinks) { " - ${it.targetId} (${it.type})" } + appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" } + appendList("notes", notes) { " - [${it.author}] ${it.body}" } + }.trimEnd() + + private fun StringBuilder.appendList(label: String, items: List, line: (T) -> String) { + if (items.isEmpty()) return + appendLine("$label:") + items.forEach { appendLine(line(it)) } + } +} + +/** A linked task resolved against the board: enough for the agent to judge readiness. */ +@Serializable +data class RelatedTask( + val id: String, + val status: String, + val title: String?, + val link: String, +) + +/** A link whose target resolved to a document (ADR/doc): title + excerpt inlined for the agent. */ +@Serializable +data class TaskDocument( + val targetId: String, + val type: String, + val title: String, + val path: String, + val excerpt: String, +) + +/** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */ +@Serializable +data class RelatedLink( + val targetId: String, + val type: String, +) + +@Serializable +data class NoteEntry( + val author: String, + val body: String, +) + +/** A semantically-retrieved snippet relevant to the task's goal (e.g. a repo file or doc). */ +@Serializable +data class KnowledgeHit( + val source: String, + val text: String, + val score: Double, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt new file mode 100644 index 00000000..ddc32ac1 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt @@ -0,0 +1,24 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.sessions.projections.Projection + +/** + * Counts created tasks in a project stream. The numeric suffix of a human key + * (`-`) is taken from this count, so ids never collide even after a task is + * cancelled — it counts creations, not currently-live tasks. + */ +class TaskCounterProjection( + private val projectId: String, +) : Projection { + + override fun initial(): TaskCounterState = + TaskCounterState(projectId, 0) + + override fun apply( + state: TaskCounterState, + event: StoredEvent, + ): TaskCounterState = + if (event.payload is TaskCreatedEvent) state.copy(count = state.count + 1) else state +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt new file mode 100644 index 00000000..5a6ed45b --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt @@ -0,0 +1,6 @@ +package com.correx.core.tasks + +data class TaskCounterState( + val projectId: String, + val count: Int, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt new file mode 100644 index 00000000..9c3dde9a --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt @@ -0,0 +1,21 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Port for resolving a link target (e.g. "adr-7" or a "docs/…/x.md" path) to an inline document + * summary, so the context bundle can carry the doc's title + excerpt instead of a bare id the + * agent has to fetch separately. Implemented in a higher layer (apps/server reads the repo's + * docs); `core:tasks` stays decoupled from the filesystem. Returns null when the target is not a + * resolvable document — the link then stays a raw related link. + */ +interface TaskDocumentResolver { + suspend fun resolve(targetId: String): ResolvedDocument? +} + +@Serializable +data class ResolvedDocument( + val title: String, + val path: String, + val excerpt: String, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt new file mode 100644 index 00000000..5d2593be --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt @@ -0,0 +1,11 @@ +package com.correx.core.tasks + +/** + * Port for semantic enrichment of a task context bundle. Implemented in a higher layer + * (apps/server bridges it to the L3 vector retriever) and injected into [TaskContextAssembler]; + * `core:tasks` stays decoupled from the retrieval stack. Implementations must be side-effect-free + * and tolerate being called with an arbitrary natural-language query. + */ +interface TaskKnowledgeRetriever { + suspend fun retrieve(query: String, limit: Int): List +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt new file mode 100644 index 00000000..b7cfb582 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt @@ -0,0 +1,11 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskTargetKind + +/** One edge in the work graph: a typed pointer from a task to another entity, tagged by kind. */ +data class TaskLink( + val targetId: String, + val type: TaskLinkType, + val targetKind: TaskTargetKind, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt new file mode 100644 index 00000000..091ee7de --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt @@ -0,0 +1,11 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.datetime.Instant + +/** A note on a task, kept in agent/human lanes so consumers can filter by author. */ +data class TaskNote( + val author: TaskNoteAuthor, + val body: String, + val at: Instant, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt new file mode 100644 index 00000000..48c2c44d --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt @@ -0,0 +1,10 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent + +interface TaskReducer { + fun reduce( + state: TaskState, + event: StoredEvent, + ): TaskState +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt new file mode 100644 index 00000000..04c9566e --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -0,0 +1,166 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskDeletedEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskUnlinkedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import kotlinx.datetime.Clock +import java.util.UUID + +/** + * Write path for tasks: turns intents into events appended to the shared [EventStore]. + * The event log stays the single source of truth — the board is rebuilt by replay, never + * stored. All task events for a project live in one stream ([TaskStreams.forProject]). + * + * Keys are `-`, so a task's project is recoverable from its id; mutating + * methods therefore need only the [TaskId]. They return the rebuilt [Task], or null when the + * task does not exist (so callers — e.g. tools — can report a clean failure). Mutations that + * are illegal for the current status are still appended but are no-ops on replay (the reducer + * counts them in `invalidTransitions`). + */ +@Suppress("TooManyFunctions") +class TaskService( + private val eventStore: EventStore, + private val clock: Clock = Clock.System, +) { + private val boardReplayer = + DefaultEventReplayer(eventStore, TaskBoardProjector(DefaultTaskReducer())) + + fun board(projectId: ProjectId): TaskBoard = + boardReplayer.rebuild(TaskStreams.forProject(projectId)) + + fun list(projectId: ProjectId): List = + board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) } + + fun getTask(taskId: TaskId): Task? { + val state = board(projectOf(taskId))[taskId] ?: return null + return if (state.deleted) null else Task(taskId, state) + } + + suspend fun createTask( + projectId: ProjectId, + title: String, + goal: String, + acceptanceCriteria: List = emptyList(), + affectedPaths: List = emptyList(), + ): Task { + val key = nextKey(projectId) + val taskId = TaskId(key) + append( + projectId, + TaskCreatedEvent( + taskId = taskId, + projectId = projectId, + key = key, + title = title, + goal = goal, + acceptanceCriteria = acceptanceCriteria, + affectedPaths = affectedPaths, + ), + ) + return requireNotNull(getTask(taskId)) { "task $key missing after creation" } + } + + suspend fun edit(taskId: TaskId, title: String? = null, goal: String? = null): Task? = + mutate(taskId) { TaskEditedEvent(it, title = title, goal = goal) } + + suspend fun setAcceptanceCriteria(taskId: TaskId, criteria: List): Task? = + mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) } + + suspend fun setAffectedPaths(taskId: TaskId, paths: List): Task? = + mutate(taskId) { TaskAffectedPathsSetEvent(it, paths) } + + suspend fun claim(taskId: TaskId, claimant: String): Task? = + mutate(taskId) { TaskClaimedEvent(it, claimant) } + + suspend fun release(taskId: TaskId): Task? = + mutate(taskId) { TaskReleasedEvent(it) } + + suspend fun block(taskId: TaskId, reason: String): Task? = + mutate(taskId) { TaskBlockedEvent(it, reason) } + + suspend fun unblock(taskId: TaskId): Task? = + mutate(taskId) { TaskUnblockedEvent(it) } + + suspend fun submitForReview(taskId: TaskId): Task? = + mutate(taskId) { TaskSubmittedForReviewEvent(it) } + + suspend fun complete(taskId: TaskId): Task? = + mutate(taskId) { TaskCompletedEvent(it) } + + suspend fun reopen(taskId: TaskId): Task? = + mutate(taskId) { TaskReopenedEvent(it) } + + suspend fun cancel(taskId: TaskId, reason: String? = null): Task? = + mutate(taskId) { TaskCancelledEvent(it, reason) } + + suspend fun link(taskId: TaskId, targetId: String, type: TaskLinkType, targetKind: TaskTargetKind): Task? = + mutate(taskId) { TaskLinkedEvent(it, targetId, type, targetKind) } + + suspend fun unlink(taskId: TaskId, targetId: String, type: TaskLinkType): Task? = + mutate(taskId) { TaskUnlinkedEvent(it, targetId, type) } + + suspend fun addNote(taskId: TaskId, author: TaskNoteAuthor, body: String): Task? = + mutate(taskId) { TaskNoteAddedEvent(it, author, body) } + + /** Soft delete: appends a tombstone. After this, [getTask] returns null and [list] omits it. */ + suspend fun delete(taskId: TaskId): Boolean { + if (getTask(taskId) == null) return false + append(projectOf(taskId), TaskDeletedEvent(taskId)) + return true + } + + private suspend fun mutate(taskId: TaskId, event: (TaskId) -> EventPayload): Task? { + if (getTask(taskId) == null) return null + append(projectOf(taskId), event(taskId)) + return getTask(taskId) + } + + private fun nextKey(projectId: ProjectId): String { + val count = DefaultEventReplayer(eventStore, TaskCounterProjection(projectId.value)) + .rebuild(TaskStreams.forProject(projectId)).count + return "${projectId.value}-${count + 1}" + } + + private fun projectOf(taskId: TaskId): ProjectId = + ProjectId(taskId.value.substringBeforeLast('-')) + + private suspend fun append(projectId: ProjectId, payload: EventPayload) { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = TaskStreams.forProject(projectId), + timestamp = clock.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = payload, + ), + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt new file mode 100644 index 00000000..32ddd882 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt @@ -0,0 +1,26 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import kotlinx.datetime.Instant + +/** + * Projected state of a single task, rebuilt by folding its events through [TaskReducer]. + * Mirrors the shape of `SessionState`: defaults make the empty/pre-creation state valid. + */ +data class TaskState( + val status: TaskStatus = TaskStatus.TODO, + val key: String? = null, + val projectId: ProjectId? = null, + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), + val links: List = emptyList(), + val claimant: String? = null, + val notes: List = emptyList(), + val createdAt: Instant? = null, + val updatedAt: Instant? = null, + val invalidTransitions: Int = 0, + /** Soft-deleted tombstone — repository reads hide these; the events remain in the log. */ + val deleted: Boolean = false, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt new file mode 100644 index 00000000..3abc2be9 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt @@ -0,0 +1,16 @@ +package com.correx.core.tasks + +/** + * Derived task lifecycle status. Never serialized on events — the reducer computes it from + * the lifecycle facts. Workflow: TODO → IN_PROGRESS → IN_REVIEW → DONE, with BLOCKED + * (reversible) and CANCELLED (terminal). + */ +enum class TaskStatus { + TODO, + IN_PROGRESS, + IN_REVIEW, + BLOCKED, + DONE, + CANCELLED, + ; +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt new file mode 100644 index 00000000..2fcee168 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt @@ -0,0 +1,16 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId + +/** + * Task events live in one event stream per project, keyed `tasks:`. The + * `tasks:` prefix keeps these streams distinguishable from real agent sessions (the + * EventStore is partitioned by [SessionId], and [SessionId]/`TaskId` are both `TypeId`). + */ +object TaskStreams { + const val PREFIX: String = "tasks:" + + fun forProject(projectId: ProjectId): SessionId = + SessionId("$PREFIX${projectId.value}") +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt new file mode 100644 index 00000000..fae08179 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt @@ -0,0 +1,147 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class DefaultTaskReducerTest { + + private val reducer = DefaultTaskReducer() + private val taskId = TaskId("auth-1") + private val projectId = ProjectId("auth") + + private fun stored(payload: EventPayload, seq: Long = 1L) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun reduceAll(vararg payloads: EventPayload): TaskState = + payloads.foldIndexed(TaskState()) { i, state, payload -> + reducer.reduce(state, stored(payload, (i + 1).toLong())) + } + + private fun created() = TaskCreatedEvent( + taskId = taskId, + projectId = projectId, + key = "auth-1", + title = "Implement JWT refresh flow", + goal = "users stay authenticated", + ) + + @Test + fun `TaskCreatedEvent sets fields and TODO status`() { + val state = reduceAll(created()) + + assertEquals(TaskStatus.TODO, state.status) + assertEquals("auth-1", state.key) + assertEquals(projectId, state.projectId) + assertEquals("Implement JWT refresh flow", state.title) + assertEquals("users stay authenticated", state.goal) + assertEquals(Instant.parse("2026-01-01T00:00:00Z"), state.createdAt) + assertEquals(0, state.invalidTransitions) + } + + @Test + fun `claiming moves TODO to IN_PROGRESS and records claimant`() { + val state = reduceAll(created(), TaskClaimedEvent(taskId, claimant = "claude-opus")) + + assertEquals(TaskStatus.IN_PROGRESS, state.status) + assertEquals("claude-opus", state.claimant) + } + + @Test + fun `happy path created to claimed to review to done`() { + val state = reduceAll( + created(), + TaskClaimedEvent(taskId, claimant = "claude-opus"), + TaskSubmittedForReviewEvent(taskId), + TaskCompletedEvent(taskId), + ) + + assertEquals(TaskStatus.DONE, state.status) + assertEquals(0, state.invalidTransitions) + } + + @Test + fun `unblock restores the working status of a claimed task`() { + val state = reduceAll( + created(), + TaskClaimedEvent(taskId, claimant = "claude-opus"), + TaskBlockedEvent(taskId, reason = "waiting on auth-138"), + TaskUnblockedEvent(taskId), + ) + + assertEquals(TaskStatus.IN_PROGRESS, state.status) + assertEquals(0, state.invalidTransitions) + } + + @Test + fun `illegal transition is ignored and counted`() { + val state = reduceAll(created(), TaskCompletedEvent(taskId)) + + assertEquals(TaskStatus.TODO, state.status) + assertEquals(1, state.invalidTransitions) + } + + @Test + fun `links and notes accumulate without affecting status`() { + val state = reduceAll( + created(), + TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")), + TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK), + TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"), + ) + + assertEquals(TaskStatus.TODO, state.status) + assertEquals(listOf("refresh endpoint exists"), state.acceptanceCriteria) + assertEquals(1, state.links.size) + assertEquals(TaskLink("auth-138", TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK), state.links.single()) + assertEquals(1, state.notes.size) + assertEquals(TaskNoteAuthor.AGENT, state.notes.single().author) + } + + @Test + fun `non-task payload leaves state untouched`() { + val before = reduceAll(created()) + val nonTask = RefinementIterationEvent( + sessionId = SessionId("s-1"), + cycleKey = "implement->review", + iteration = 1, + maxIterations = 3, + ) + val after = reducer.reduce(before, stored(nonTask, seq = 9L)) + + assertEquals(before, after) + assertNull(after.claimant) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt new file mode 100644 index 00000000..02daa0d9 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt @@ -0,0 +1,50 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** Minimal append-capable in-memory event store for core:tasks tests, keyed per session. */ +internal class InMemoryEventStore : EventStore { + private val events = mutableListOf() + private var global = 0L + private val perSession = mutableMapOf() + + override suspend fun append(event: NewEvent): StoredEvent { + global += 1 + val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1 + perSession[event.metadata.sessionId] = seq + val stored = StoredEvent( + metadata = event.metadata, + sequence = global, + sessionSequence = seq, + payload = event.payload, + ) + events += stored + return stored + } + + override suspend fun appendAll(events: List): List = events.map { append(it) } + + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sessionSequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = + read(sessionId).maxOfOrNull { it.sessionSequence } + + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + + override fun subscribeAll(): Flow = emptyFlow() + + override suspend fun lastGlobalSequence(): Long = global + + override fun allEvents(): Sequence = events.asSequence() + + override fun allSessionIds(): Set = events.map { it.metadata.sessionId }.toSet() +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt new file mode 100644 index 00000000..4450982a --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt @@ -0,0 +1,74 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TaskBoardProjectorTest { + + private val projector = TaskBoardProjector(DefaultTaskReducer()) + private val projectId = ProjectId("auth") + + private fun stored(payload: EventPayload, seq: Long) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun created(id: TaskId) = + TaskCreatedEvent(id, projectId, id.value, "title ${id.value}", "goal") + + private fun fold(vararg events: StoredEvent): TaskBoard = + events.fold(projector.initial()) { board, event -> projector.apply(board, event) } + + @Test + fun `interleaved events update each task independently`() { + val one = TaskId("auth-1") + val two = TaskId("auth-2") + + val board = fold( + stored(created(one), 1), + stored(created(two), 2), + stored(TaskClaimedEvent(one, claimant = "claude-opus"), 3), + ) + + assertEquals(2, board.size) + assertEquals(TaskStatus.IN_PROGRESS, board[one]?.status) + assertEquals("claude-opus", board[one]?.claimant) + assertEquals(TaskStatus.TODO, board[two]?.status) + } + + @Test + fun `non-task payload leaves the board unchanged`() { + val one = TaskId("auth-1") + val withTask = fold(stored(created(one), 1)) + + val nonTask = RefinementIterationEvent( + sessionId = SessionId("s-1"), + cycleKey = "implement->review", + iteration = 1, + maxIterations = 3, + ) + val after = projector.apply(withTask, stored(nonTask, 2)) + + assertEquals(withTask, after) + } +} 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 new file mode 100644 index 00000000..5c3cf5d1 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt @@ -0,0 +1,128 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskContextAssemblerTest { + + private val store = InMemoryEventStore() + private val service = TaskService(store) + private val assembler = TaskContextAssembler(service) + private val project = ProjectId("auth") + + @Test + fun `bundle resolves linked tasks as dependencies and keeps non-task links raw`() = runBlocking { + val dep = service.createTask(project, "Design auth model", "model") + service.claim(dep.taskId, "claude-opus") + service.submitForReview(dep.taskId) + service.complete(dep.taskId) // dep is DONE + + val task = service.createTask( + project, + title = "Implement JWT refresh", + goal = "users stay authenticated", + acceptanceCriteria = listOf("refresh endpoint exists"), + affectedPaths = listOf("backend/auth/**"), + ) + service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + // DOC-tagged, but this assembler has no document resolver → stays a raw related link + service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) + service.addNote(task.taskId, TaskNoteAuthor.AGENT, "kickoff") + + val bundle = assembler.assemble(task.taskId)!! + + assertEquals("auth-2", bundle.id) + assertEquals("users stay authenticated", bundle.goal) + assertEquals(listOf("refresh endpoint exists"), bundle.acceptanceCriteria) + assertEquals(listOf("backend/auth/**"), bundle.relevantFiles) + + val dependency = bundle.dependencies.single() + assertEquals("auth-1", dependency.id) + assertEquals("DONE", dependency.status) + assertEquals("DEPENDS_ON", dependency.link) + + assertEquals(RelatedLink("adr-7", "IMPLEMENTS"), bundle.relatedLinks.single()) + assertEquals("kickoff", bundle.notes.single().body) + } + + @Test + fun `render produces a compact labelled bundle`() = runBlocking { + val task = service.createTask(project, "JWT refresh", "stay authed", listOf("rotates")) + val text = assembler.assemble(task.taskId)!!.render() + + assertTrue(text.startsWith("task auth-1 [TODO] JWT refresh")) + assertTrue(text.contains("goal: stay authed")) + assertTrue(text.contains("- rotates")) + } + + @Test + fun `assemble returns null for an unknown task`() = runBlocking { + assertNull(assembler.assemble(TaskId("auth-999"))) + } + + @Test + fun `bundle is enriched with knowledge retrieved over the goal`() = runBlocking { + val captured = mutableListOf() + val fakeRetriever = object : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List { + captured += query + return listOf(KnowledgeHit("backend/auth/Jwt.kt", "fun refresh(token)", 0.91)) + } + } + val enriched = TaskContextAssembler(service, fakeRetriever) + val task = service.createTask(project, "JWT refresh", "users stay authenticated") + + val bundle = enriched.assemble(task.taskId)!! + + assertEquals(listOf("JWT refresh users stay authenticated"), captured) + assertEquals("backend/auth/Jwt.kt", bundle.relevantKnowledge.single().source) + assertTrue(bundle.render().contains("relevant_knowledge")) + } + + @Test + fun `linked docs are resolved inline and dropped from raw related links`() = runBlocking { + val docs = object : TaskDocumentResolver { + override suspend fun resolve(targetId: String): ResolvedDocument? = + if (targetId == "adr-7") { + ResolvedDocument("ADR 7: use redis", "docs/decisions/adr-0007-redis.md", "faster invalidation") + } else { + null + } + } + val enriched = TaskContextAssembler(service, documentResolver = docs) + val task = service.createTask(project, "JWT refresh", "auth") + service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) // resolves to a doc + service.link(task.taskId, "ext-123", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // stays raw + + val bundle = enriched.assemble(task.taskId)!! + + val doc = bundle.documents.single() + assertEquals("adr-7", doc.targetId) + assertEquals("ADR 7: use redis", doc.title) + assertEquals("IMPLEMENTS", doc.type) + assertEquals(RelatedLink("ext-123", "RELATES_TO"), bundle.relatedLinks.single()) + assertTrue(bundle.render().contains("documents:")) + } + + @Test + fun `retrieval failure is swallowed and the bundle still returns`() = runBlocking { + val flaky = object : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List = + error("embedder offline") + } + val task = service.createTask(project, "JWT refresh", "stay authed") + + val bundle = TaskContextAssembler(service, flaky).assemble(task.taskId)!! + + assertTrue(bundle.relevantKnowledge.isEmpty()) + assertEquals("JWT refresh", bundle.title) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt new file mode 100644 index 00000000..dc707cc0 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt @@ -0,0 +1,51 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TaskCounterProjectionTest { + + private val projection = TaskCounterProjection("auth") + private val projectId = ProjectId("auth") + + private fun stored(payload: EventPayload, seq: Long) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun created(id: TaskId) = + TaskCreatedEvent(id, projectId, id.value, "title", "goal") + + @Test + fun `counts only created events and survives cancellation`() { + val events = listOf( + stored(created(TaskId("auth-1")), 1), + stored(created(TaskId("auth-2")), 2), + stored(TaskCancelledEvent(TaskId("auth-1")), 3), + ) + + val state = events.fold(projection.initial()) { s, e -> projection.apply(s, e) } + + assertEquals("auth", state.projectId) + assertEquals(2, state.count) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt new file mode 100644 index 00000000..83bc0213 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -0,0 +1,84 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskServiceTest { + + private val store = InMemoryEventStore() + private val service = TaskService(store) + private val project = ProjectId("auth") + + @Test + fun `createTask allocates sequential human keys per project`() = runBlocking { + val first = service.createTask(project, "Add JWT refresh", "users stay authenticated") + val second = service.createTask(project, "Rotate keys", "tokens rotate") + + assertEquals("auth-1", first.taskId.value) + assertEquals("auth-2", second.taskId.value) + assertEquals(TaskStatus.TODO, first.state.status) + assertEquals("Add JWT refresh", first.state.title) + } + + @Test + fun `lifecycle round-trips through append and replay`() = runBlocking { + val task = service.createTask(project, "Add JWT refresh", "users stay authenticated") + val id = task.taskId + + assertEquals(TaskStatus.IN_PROGRESS, service.claim(id, "claude-opus")?.state?.status) + assertEquals(TaskStatus.IN_REVIEW, service.submitForReview(id)?.state?.status) + val done = service.complete(id) + + assertEquals(TaskStatus.DONE, done?.state?.status) + assertEquals("claude-opus", done?.state?.claimant) + } + + @Test + fun `links and notes are persisted on the task`() = runBlocking { + val id = service.createTask(project, "Add JWT refresh", "auth").taskId + + service.link(id, targetId = "adr-7", type = TaskLinkType.IMPLEMENTS, targetKind = TaskTargetKind.DOC) + val withNote = service.addNote(id, TaskNoteAuthor.AGENT, "migration failed, retrying") + + assertEquals( + TaskLink("adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC), + withNote?.state?.links?.single(), + ) + assertEquals("migration failed, retrying", withNote?.state?.notes?.single()?.body) + } + + @Test + fun `delete is a soft tombstone hidden from reads`() = runBlocking { + val id = service.createTask(project, "throwaway", "oops").taskId + + assertTrue(service.delete(id)) + assertNull(service.getTask(id)) + assertTrue(service.list(project).none { it.taskId == id }) + // A second delete is a no-op: the task is already gone from reads. + assertFalse(service.delete(id)) + } + + @Test + fun `mutating an unknown task returns null`() = runBlocking { + assertNull(service.claim(TaskId("auth-999"), "claude-opus")) + } + + @Test + fun `keys keep incrementing even after deletion`() = runBlocking { + val first = service.createTask(project, "one", "g").taskId + service.delete(first) + val third = service.createTask(project, "two", "g") + + // count() is creation-based, so the deleted id is never reused. + assertEquals("auth-2", third.taskId.value) + } +} diff --git a/docs/decisions/adr-0012-task-tracking.md b/docs/decisions/adr-0012-task-tracking.md new file mode 100644 index 00000000..36a75314 --- /dev/null +++ b/docs/decisions/adr-0012-task-tracking.md @@ -0,0 +1,112 @@ +--- +name: "Adr 0012 Task Tracking" +description: "Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node" +depth: 2 +links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0005-persistence-choice.md", "./adr-0006-event-store-append-only.md"] +--- + +# ADR 0012: native task tracking as an event-sourced aggregate + +**status:** accepted +**date:** 23.06.2026 (June) +**deciders:** correx design team + +--- + +## context + +Correx is to gain **native task tracking** — a durable "task" (work item / issue) entity, +agent-first. Today the system has no concept of a task; it has `sessions` (agent runs) and +session/stage-scoped `artifacts`. A task is conceptually different: a durable unit of intent +that outlives any single session and *references* the artifacts and sessions that act on it. + +An originating pitch proposed storing tasks as YAML/markdown files as the **source of truth** +with Postgres as a cache, plus a `GET /tasks/next` assignment scheduler. Those technical +choices are **rejected** here: they contradict correx's foundational invariants — +"the event log is the only source of truth; projections are disposable and rebuilt from +events" (`.correx/project.toml`, ADR-0001, ADR-0006). Adopting hand-edited files as truth +would create two masters (files vs. log) and forfeit replay/idempotency. We keep the *goal* +(native tasks) and realise it with correx's actual architecture. + +This ADR covers the **foundational slice**: the aggregate, its events, and the work-graph +edge model. REST/CLI/agent-context-bundle/search are deliberately later phases. + +## decision + +**A task is a first-class event-sourced aggregate**, modelled on the existing `core:sessions` +aggregate (Status / State / Reducer / Projector / Repository / Counter), living in a new +`core:tasks` module. It is *not* an `ArtifactKind` — artifacts are session/stage-scoped LLM +outputs; a task is durable and cross-session. + +### 1. event log is the source of truth +Tasks are rebuilt by folding events. Any future markdown/file representation is a *disposable +projection* (export), never the master copy. + +### 2. per-project task stream +The `EventStore` is partitioned by `sessionId`. Since `SessionId` and `TaskId` are both +`TypeId`, all task events for a project live in one stream keyed `tasks:` +(`TaskStreams.forProject`). One read rebuilds the whole board (`TaskBoard = Map`). The `tasks:` prefix keeps these streams distinguishable from real agent +sessions; when task streams are later wired into the live server, session-listing consumers +filter the prefix. + +### 3. lifecycle as facts; status is derived +Mirroring sessions (whose events never carry `SessionStatus`), task events are facts +(`TaskClaimed`, `TaskBlocked`, `TaskCompleted`, …) and `DefaultTaskReducer` derives +`TaskStatus`. This keeps the `TaskStatus` enum out of the lowest module (`core:events`). A +`sealed interface TaskEvent { val taskId }` (a marker, *not* part of the serialized +`EventPayload` hierarchy) lets projections route any payload to its task without a giant `when`. + +### 4. status workflow +`TODO → IN_PROGRESS → IN_REVIEW → DONE`, with `BLOCKED` (reversible; unblock returns to +IN_PROGRESS if claimed, else TODO) and `CANCELLED` (terminal, reopenable). Illegal +transitions are ignored and counted in `TaskState.invalidTransitions` (as `SessionState` does). + +### 5. work-graph edges +`TaskLinkType` (sibling to the existing `ArtifactRelationshipType`): +`DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT`. A link target is a plain id +string — another task, an `ArtifactId`, or a `SessionId` — plus a type. This is the work-graph +backbone the agent-context bundle (later phase) will traverse. + +### 6. human-friendly ids +`TaskCounterProjection(projectId)` counts `TaskCreatedEvent`s in the project stream; the +numeric suffix of a key (`-`, e.g. `auth-142`) is the count of *created* tasks, so +ids never collide even after cancellation. The creating caller composes the key and stores it +in `TaskCreatedEvent.key`. (The creating service is a later phase; the projection and `key` +field land now.) + +## consequences + +**positive:** + +* reuses the entire event-sourcing backbone (sequencing, idempotency, replay, projections); + no new persistence model, no second source of truth +* the work graph (tasks ↔ tasks/artifacts/sessions) is native, enabling later + "everything touching X" queries and the agent-context bundle +* deterministic, replayable task state; status derivation is unit-testable in isolation + +**negative:** + +* overloading the `sessionId`-partitioned stream key with `tasks:` means + session-enumerating consumers must learn to filter the prefix once tasks go live + (acceptable; isolated to the live-wiring phase) +* per-project (not per-task) stream means rebuilding one task replays the project's task + events — fine at expected volumes; revisit with snapshots if a project's task log grows large + +## alternatives considered + +* **Task as an `ArtifactKind`:** rejected — artifacts are session/stage-scoped outputs; + tasks are durable, cross-session, and carry their own lifecycle/claiming. +* **YAML/markdown files as source of truth + Postgres cache (the pitch):** rejected — + violates ADR-0001/0006; creates two masters and loses replay/idempotency. +* **`GET /tasks/next` assignment scheduler (the pitch):** dropped — scheduling/prioritisation + is a separate hard problem; v1 offers `claim` (pull), not `assign` (push). +* **One stream per task:** cleaner per-task sequencing but pollutes session enumeration with + one stream per task and makes board/list queries fan out across many streams; the + per-project stream is the better default for a tracker that lists/boards constantly. + +## status + +Governs the foundational task-tracking slice. Re-evaluation expected when the REST surface, +the `GET /tasks/{id}/context` agent bundle, search, and git-driven auto status updates are +designed (those phases build on, and must not contradict, this ADR). diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 475965d6..8b65bc9e 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -35,6 +35,7 @@ import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.WorkflowSummary import com.correx.core.events.types.SessionId import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.registry.ToolRegistry import com.correx.infrastructure.artifactscas.CasArtifactStore @@ -104,8 +105,11 @@ object InfrastructureModule { return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) } } - fun createToolRegistry(config: ToolConfig): ToolRegistry = - DefaultToolRegistry.build(config.buildTools()) + fun createToolRegistry( + config: ToolConfig, + extraTools: List = emptyList(), + ): ToolRegistry = + DefaultToolRegistry.build(config.buildTools() + extraTools) fun createLlamaCppProvider( modelId: String, diff --git a/infrastructure/tools/build.gradle b/infrastructure/tools/build.gradle index 923bf246..c9dc23c3 100644 --- a/infrastructure/tools/build.gradle +++ b/infrastructure/tools/build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(":core:events") implementation project(":core:approvals") implementation project(":core:sessions") + implementation project(":core:tasks") implementation project(":infrastructure:tools:filesystem") implementation project(":core:artifacts") implementation project(":core:artifacts-store") diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt new file mode 100644 index 00000000..38953f8d --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt @@ -0,0 +1,56 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: fetch a task's token-optimized context bundle in one call. Read-only, so + * it sits at the lowest tier (like file_read) — agents call it before starting work on a task. + */ +class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolExecutor { + + override val name: String = "task_context" + override val description: String = + "Fetch a task's context bundle (goal, acceptance criteria, resolved dependencies, " + + "related links, relevant files, notes) in one call. Use before working a task." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") } + } + put("required", buildJsonArray { add(JsonPrimitive("id")) }) + } + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val id = request.stringParam("id") + ?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false) + val bundle = assembler.assemble(TaskId(id)) + ?: return ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false) + return ToolResult.Success( + invocationId = request.invocationId, + output = bundle.render(), + metadata = mapOf("taskId" to id, "status" to bundle.status), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt new file mode 100644 index 00000000..c98e8d7c --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -0,0 +1,89 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** Agent-facing tool: create a task in a project's work graph. */ +class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_create" + override val description: String = + "Create a task in a project's work graph and return its id (e.g. 'auth-142')." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("project") { + put("type", "string") + put("description", "Project key, e.g. 'auth'. The new task id is '-'.") + } + putJsonObject("title") { + put("type", "string") + put("description", "Short imperative title.") + } + putJsonObject("goal") { + put("type", "string") + put("description", "What 'done' looks like, in a sentence or two.") + } + putJsonObject("acceptance_criteria") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Concrete, verifiable criteria.") + } + putJsonObject("affected_paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Globs/paths this task is expected to touch.") + } + } + put( + "required", + buildJsonArray { + add(JsonPrimitive("project")) + add(JsonPrimitive("title")) + add(JsonPrimitive("goal")) + }, + ) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).") + request.stringParam("title") ?: return ValidationResult.Invalid("Missing 'title' (string).") + request.stringParam("goal") ?: return ValidationResult.Invalid("Missing 'goal' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val invalid = validateRequest(request) as? ValidationResult.Invalid + if (invalid != null) { + return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) + } + val task = service.createTask( + projectId = ProjectId(request.stringParam("project")!!), + title = request.stringParam("title")!!, + goal = request.stringParam("goal")!!, + acceptanceCriteria = request.listParam("acceptance_criteria"), + affectedPaths = request.listParam("affected_paths"), + ) + return ToolResult.Success( + invocationId = request.invocationId, + output = "Created ${task.taskId.value}: ${task.state.title}", + metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt new file mode 100644 index 00000000..0442fe8a --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt @@ -0,0 +1,55 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: soft-delete a task (tombstone). History is preserved in the event log; + * the task drops out of active views. Use 'task_update' with action=cancel to record an + * abandoned-but-visible outcome instead. + */ +class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_delete" + override val description: String = + "Soft-delete a task (removes it from active views; history is kept). " + + "For an abandoned-but-visible task, use task_update action=cancel instead." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") } + } + put("required", buildJsonArray { add(JsonPrimitive("id")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val id = request.stringParam("id") + ?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false) + return if (service.delete(TaskId(id))) { + ToolResult.Success(request.invocationId, output = "Deleted $id", metadata = mapOf("taskId" to id)) + } else { + ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false) + } + } +} 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 new file mode 100644 index 00000000..fe25c87a --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -0,0 +1,26 @@ +package com.correx.infrastructure.tools.task + +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.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. + */ + fun forService( + service: TaskService, + retriever: TaskKnowledgeRetriever? = null, + documentResolver: TaskDocumentResolver? = null, + ): List = + listOf( + TaskCreateTool(service), + TaskUpdateTool(service), + TaskDeleteTool(service), + TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)), + ) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt new file mode 100644 index 00000000..15a8f447 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -0,0 +1,158 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$") + +/** + * Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph + * link, or attach a note. The task's project is derived from its id, so only the id is required. + */ +class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_update" + override val description: String = + "Update a task: edit title/goal/criteria/paths, change status via 'action' " + + "(claim, release, block, unblock, submit_for_review, complete, reopen, cancel), " + + "add a link, or add a note." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") } + putJsonObject("title") { put("type", "string"); put("description", "New title.") } + putJsonObject("goal") { put("type", "string"); put("description", "New goal.") } + putJsonObject("acceptance_criteria") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Replaces the acceptance criteria.") + } + putJsonObject("affected_paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Replaces the affected paths.") + } + putJsonObject("action") { + put("type", "string") + put( + "description", + "Status transition: claim, release, block, unblock, submit_for_review, " + + "complete, reopen, cancel.", + ) + } + putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") } + putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") } + putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") } + putJsonObject("link_type") { + put("type", "string") + put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.") + } + putJsonObject("link_kind") { + put("type", "string") + put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.") + } + putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } + } + put("required", buildJsonArray { add(JsonPrimitive("id")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val id = request.stringParam("id") + ?: return fail(request, "Missing 'id' (string).") + val taskId = TaskId(id) + if (service.getTask(taskId) == null) return fail(request, "No such task: $id") + + applyFieldEdits(request, taskId) + + val action = request.stringParam("action") + if (action != null && !applyAction(taskId, action, request)) { + return fail(request, "Unknown action '$action'.") + } + + val linkTarget = request.stringParam("link_target") + if (linkTarget != null) { + val type = parseLinkType(request.stringParam("link_type")) + ?: return fail(request, "Invalid 'link_type'.") + val kind = resolveTargetKind(request.stringParam("link_kind"), linkTarget) + ?: return fail(request, "Invalid 'link_kind'.") + service.link(taskId, linkTarget, type, kind) + } + + request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } + + val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" + return ToolResult.Success( + invocationId = request.invocationId, + output = "Updated $id [$status]", + metadata = mapOf("taskId" to id, "status" to status), + ) + } + + private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) { + val title = request.stringParam("title") + val goal = request.stringParam("goal") + if (title != null || goal != null) service.edit(taskId, title, goal) + if (request.parameters.containsKey("acceptance_criteria")) { + service.setAcceptanceCriteria(taskId, request.listParam("acceptance_criteria")) + } + if (request.parameters.containsKey("affected_paths")) { + service.setAffectedPaths(taskId, request.listParam("affected_paths")) + } + } + + private suspend fun applyAction(taskId: TaskId, action: String, request: ToolRequest): Boolean { + val claimant = request.stringParam("claimant") ?: request.sessionId.value + val reason = request.stringParam("reason") + when (action) { + "claim" -> service.claim(taskId, claimant) + "release" -> service.release(taskId) + "block" -> service.block(taskId, reason ?: "blocked") + "unblock" -> service.unblock(taskId) + "submit_for_review" -> service.submitForReview(taskId) + "complete" -> service.complete(taskId) + "reopen" -> service.reopen(taskId) + "cancel" -> service.cancel(taskId, reason) + else -> return false + } + return true + } + + private fun parseLinkType(raw: String?): TaskLinkType? = + if (raw == null) TaskLinkType.RELATES_TO + else TaskLinkType.entries.firstOrNull { it.name == raw.uppercase() } + + /** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */ + private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? = + if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() } + else inferTargetKind(target) + + private fun inferTargetKind(target: String): TaskTargetKind = + if (ADR_ID.matches(target) || target.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt new file mode 100644 index 00000000..c8968a9c --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -0,0 +1,11 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.events.ToolRequest + +/** A non-blank string parameter, or null when absent/blank/not a string. */ +internal fun ToolRequest.stringParam(name: String): String? = + (parameters[name] as? String)?.takeIf { it.isNotBlank() } + +/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */ +internal fun ToolRequest.listParam(name: String): List = + (parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList() diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt new file mode 100644 index 00000000..888afb86 --- /dev/null +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -0,0 +1,168 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskStatus +import com.correx.core.tools.contract.ToolResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskToolsTest { + + private val service = TaskService(InMemoryEventStore()) + private val create = TaskCreateTool(service) + private val update = TaskUpdateTool(service) + private val delete = TaskDeleteTool(service) + private val context = TaskContextTool(TaskContextAssembler(service)) + + private var counter = 0 + private fun request(tool: String, params: Map) = ToolRequest( + invocationId = ToolInvocationId("inv-${counter++}"), + sessionId = SessionId("s-1"), + stageId = StageId("stage-1"), + toolName = tool, + parameters = params, + ) + + private suspend fun createTask(): String { + val result = create.execute( + request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")), + ) + assertTrue(result is ToolResult.Success) + return (result as ToolResult.Success).metadata.getValue("taskId") + } + + @Test + fun `task_create creates a task and returns its id`() = runBlocking { + val id = createTask() + assertEquals("auth-1", id) + assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status) + } + + @Test + fun `task_create rejects missing required fields`() = runBlocking { + val result = create.execute(request("task_create", mapOf("project" to "auth"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_update applies status action, edits, link and note`() = runBlocking { + val id = createTask() + val taskId = com.correx.core.events.types.TaskId(id) + + val result = update.execute( + request( + "task_update", + mapOf( + "id" to id, + "action" to "claim", + "claimant" to "claude-opus", + "title" to "JWT refresh flow", + "link_target" to "adr-7", + "link_type" to "implements", + "note" to "starting", + ), + ), + ) + + assertTrue(result is ToolResult.Success) + val state = service.getTask(taskId)!!.state + assertEquals(TaskStatus.IN_PROGRESS, state.status) + assertEquals("claude-opus", state.claimant) + assertEquals("JWT refresh flow", state.title) + assertEquals("adr-7", state.links.single().targetId) + assertEquals("starting", state.notes.single().body) + } + + @Test + fun `task_update rejects an unknown action`() = runBlocking { + val id = createTask() + val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_update on a missing task fails`() = runBlocking { + val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_context returns a rendered bundle with dependencies`() = runBlocking { + val id = createTask() + update.execute( + request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")), + ) + + val result = context.execute(request("task_context", mapOf("id" to id))) + + assertTrue(result is ToolResult.Success) + val output = (result as ToolResult.Success).output + assertTrue(output.startsWith("task $id [TODO]")) + assertTrue(output.contains("adr-7 (IMPLEMENTS)")) + } + + @Test + fun `task_context on a missing task fails`() = runBlocking { + assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure) + } + + @Test + fun `task_delete tombstones the task`() = runBlocking { + val id = createTask() + val deleted = delete.execute(request("task_delete", mapOf("id" to id))) + + assertTrue(deleted is ToolResult.Success) + assertNull(service.getTask(com.correx.core.events.types.TaskId(id))) + // Deleting again now fails: it is gone from active views. + assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure) + } +} + +/** Minimal append-capable in-memory store for tool tests. */ +private class InMemoryEventStore : EventStore { + private val events = mutableListOf() + private var global = 0L + private val perSession = mutableMapOf() + + override suspend fun append(event: NewEvent): StoredEvent { + global += 1 + val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1 + perSession[event.metadata.sessionId] = seq + val stored = StoredEvent(event.metadata, global, seq, event.payload) + events += stored + return stored + } + + override suspend fun appendAll(events: List): List = events.map { append(it) } + + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sessionSequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence } + + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + + override fun subscribeAll(): Flow = emptyFlow() + + override suspend fun lastGlobalSequence(): Long = global + + override fun allEvents(): Sequence = events.asSequence() + + override fun allSessionIds(): Set = events.map { it.metadata.sessionId }.toSet() +} diff --git a/settings.gradle b/settings.gradle index 6fabc8fd..8026899a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include ':core:tools' include ':core:toolintent' include ':core:router' include ':core:sessions' +include ':core:tasks' include ':core:config' include ':core:risk' include ':core:journal'