merge: integrate feat/backlog-burndown into master
master had advanced 16 commits past feat/backlog-burndown's base, and the two branches independently built four of the same features. Resolved 26 conflicts. Overlap features — kept master's implementation (more complete / production-wired / more robust), dropped the feature branch's parallel constellation: - llama-server health probe: kept master's event-store-backed tps probe; dropped the branch's LlamaLivenessClient (liveness-only, throughput unwired). - event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe. - brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording); dropped the branch's exact-set-diff BriefEchoComparator/Extractor. - static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner, wired); dropped the branch's structured-finding static_check stage (no-op seam). Its structured-findings model is filed as a follow-up in BACKLOG. Feature-branch net-new work brought in and kept (master had none): - native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer, dependency graph + gates, decompose, REST/CLI, TUI task board) - critique-outcome producer (role-rel §6 — master had deferred it) - stage-level plan checkpointing (C-A2, folded into runPostStageGates) - CLAUDE.md/AGENTS.md L0 standing context - cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser) Verified: full Gradle compile (all modules + tests) green; tests pass for core:events, core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Tool> = emptyList(),
|
||||
): ToolRegistry =
|
||||
DefaultToolRegistry.build(config.buildTools() + extraTools)
|
||||
|
||||
fun createLlamaCppProvider(
|
||||
modelId: String,
|
||||
|
||||
@@ -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")
|
||||
|
||||
+8
@@ -142,9 +142,13 @@ class FileReadTool(
|
||||
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
|
||||
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
|
||||
val content = lines.subList(start, end).joinToString("\n")
|
||||
// Record a content hash only for a whole-file read, so the stale-write gate baselines against
|
||||
// what the agent actually saw; a partial read establishes no baseline.
|
||||
val metadata = if (startLine == null && endLine == null) mapOf("contentHash" to sha256(path)) else emptyMap()
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = content,
|
||||
metadata = metadata,
|
||||
)
|
||||
}.getOrElse {
|
||||
ToolResult.Failure(
|
||||
@@ -154,6 +158,10 @@ class FileReadTool(
|
||||
)
|
||||
}
|
||||
|
||||
private fun sha256(path: Path): String =
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
|
||||
val entries = path.listDirectoryEntries().sortedBy { it.name }
|
||||
val output = entries.joinToString("\n") { entry ->
|
||||
|
||||
+40
@@ -4,12 +4,15 @@ import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||
import com.correx.core.events.events.SourceFetchedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
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.tools.contract.FileAffectingTool
|
||||
import com.correx.core.tools.contract.Tool
|
||||
@@ -82,6 +85,7 @@ class SandboxedToolExecutor(
|
||||
restoreOrClean(backupMap, success = true)
|
||||
cleanWorkingDir(workingDir)
|
||||
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff)
|
||||
emitResearchSourceEvents(sessionId, request.stageId, result)
|
||||
emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
|
||||
result
|
||||
}
|
||||
@@ -183,6 +187,37 @@ class SandboxedToolExecutor(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic
|
||||
* [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write
|
||||
* above is left intact for existing consumers. Only fires for results that carry the fetch
|
||||
* markers ([url] + [content_sha256] + [quality]), so non-fetch tools are untouched. The
|
||||
* low-quality threshold reuses the extractor's own marker — [ExtractionQuality.LOW_QUALITY], whose
|
||||
* name is what [WebFetchTool] writes into `quality` — rather than inventing a new policy here.
|
||||
*/
|
||||
private suspend fun emitResearchSourceEvents(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
result: ToolResult.Success,
|
||||
) {
|
||||
val md = result.metadata
|
||||
val url = md["url"]
|
||||
val contentSha256 = md["content_sha256"]
|
||||
val quality = md["quality"]
|
||||
if (url == null || contentSha256 == null || quality == null) return
|
||||
val byteCount = md["fetched_bytes"]?.toIntOrNull() ?: 0
|
||||
|
||||
emit(sessionId, SourceFetchedEvent(sessionId, stageId, url, contentSha256, quality, byteCount))
|
||||
|
||||
// Source of truth for the bar is the extractor (ExtractionQuality.LOW_QUALITY); its enum name
|
||||
// is what WebFetchTool records in `quality`. Keep the marker as a literal to avoid a core ->
|
||||
// infrastructure dependency inversion.
|
||||
if (quality == LOW_QUALITY_MARKER) {
|
||||
val reason = "extraction quality below the minimum-content bar ($byteCount bytes fetched)"
|
||||
emit(sessionId, LowQualityExtractionEvent(sessionId, stageId, url, contentSha256, reason))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
private fun computeDiff(affectedPaths: Set<Path>, backupMap: Map<Path, Path>): String? {
|
||||
val diffs = mutableListOf<String>()
|
||||
@@ -266,4 +301,9 @@ class SandboxedToolExecutor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Mirrors `ExtractionQuality.LOW_QUALITY.name` — the marker WebFetchTool writes into `quality`. */
|
||||
const val LOW_QUALITY_MARKER = "LOW_QUALITY"
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.TaskId
|
||||
|
||||
/**
|
||||
* Port: record that a session is working a task — emitted on claim (and on an affected_paths edit by
|
||||
* the claimant) so the gates can fold it into SessionContext.activeTask without scanning task
|
||||
* streams. The host supplies an adapter that appends a SessionWorkingTaskEvent to the session
|
||||
* stream. A null binding means no recording (the bare tool still works).
|
||||
*/
|
||||
fun interface SessionFactRecorder {
|
||||
suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List<String>)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Port: the paths a session has written this run. The host supplies an adapter that folds the
|
||||
* session's SessionContext (its writes come from the recorded receipt.affectedEntities). The
|
||||
* cite-before-claim gate in [TaskUpdateTool] uses it to confirm a task being completed actually saw
|
||||
* changes under its affected_paths. A null binding leaves the gate off.
|
||||
*/
|
||||
fun interface SessionWrites {
|
||||
fun pathsWritten(sessionId: SessionId): Set<String>
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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 =
|
||||
"Call this first, before working a task you've claimed or been pointed at, so you don't " +
|
||||
"re-derive context: it returns the task's goal, acceptance criteria, resolved " +
|
||||
"dependencies, related links, relevant files, and notes in one bundle."
|
||||
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<ToolCapability> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
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.events.types.TaskNoteAuthor
|
||||
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 =
|
||||
"Open a task when work spans multiple sessions, needs handoff between runs, or the " +
|
||||
"operator asks to track it. Search first (task_search) to avoid duplicates. Don't " +
|
||||
"create one for a single self-contained edit you finish now. Returns the new 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 '<project>-<n>'.")
|
||||
}
|
||||
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.")
|
||||
}
|
||||
putJsonObject("force") {
|
||||
put("type", "boolean")
|
||||
put("description", "Create even if an active task with the same title exists. Default false.")
|
||||
}
|
||||
putJsonObject("force_reason") {
|
||||
put("type", "string")
|
||||
put("description", "Required when force=true: why a duplicate is acceptable. Recorded on the task.")
|
||||
}
|
||||
}
|
||||
put(
|
||||
"required",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("project"))
|
||||
add(JsonPrimitive("title"))
|
||||
add(JsonPrimitive("goal"))
|
||||
},
|
||||
)
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = 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 precheckFailure = precheck(request)
|
||||
if (precheckFailure != null) return precheckFailure
|
||||
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"),
|
||||
)
|
||||
// Provenance: tie the new task to the session that spawned it so its context bundle
|
||||
// can show the originating run.
|
||||
service.linkOriginSession(task.taskId, request)
|
||||
// A forced creation bypassed the duplicate guard — record why, for the audit trail.
|
||||
if (request.boolParam("force")) {
|
||||
request.stringParam("force_reason")?.let {
|
||||
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "[force] created despite the duplicate guard: $it")
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required-field validation; then either a force-reason requirement (force=true must carry a
|
||||
* force_reason, recorded after creation) or the duplicate-title guard. Null when OK to create.
|
||||
*/
|
||||
private fun precheck(request: ToolRequest): ToolResult.Failure? {
|
||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||
if (request.boolParam("force")) {
|
||||
if (request.stringParam("force_reason") == null) {
|
||||
return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
|
||||
}
|
||||
return null
|
||||
}
|
||||
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||
return duplicates.takeIf { it.isNotEmpty() }?.let {
|
||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
|
||||
ToolResult.Failure(request.invocationId, reason, recoverable = false)
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
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.events.types.TaskLinkType
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
import com.correx.core.tasks.Task
|
||||
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.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
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: break a large goal into a small task graph in ONE approval, instead of N
|
||||
* separate [TaskCreateTool] calls. Use when the goal has dependency seams or independent
|
||||
* review/handoff points (a thing that must land before another, or a piece worth reviewing on its
|
||||
* own); for a single coherent unit you finish in one run, use task_create. Because a session works
|
||||
* one task at a time, each node becomes a future claim — this tool decides the *shape*, claiming is
|
||||
* still lazy (task_ready → claim), never scheduled.
|
||||
*
|
||||
* Creates the tasks and their `DEPENDS_ON` edges atomically (one dedup check, one approval) and
|
||||
* returns the new ids plus which are ready to work now. An optional `parent` epic `DEPENDS_ON` every
|
||||
* child (so it completes last) and each child `IMPLEMENTS` it (provenance).
|
||||
*
|
||||
* Nested args arrive flattened (the orchestrator stringifies non-primitive tool arguments), so the
|
||||
* `tasks`/`parent` JSON is parsed here directly rather than via the list-param accessor.
|
||||
*/
|
||||
class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_decompose"
|
||||
override val description: String =
|
||||
"Break a large goal into a small task graph (parent epic + dependency-linked children) in " +
|
||||
"one shot. Use when the goal has dependency seams or independent review/handoff points; " +
|
||||
"for a single coherent unit you finish now, use task_create. Creates the tasks and their " +
|
||||
"DEPENDS_ON links in a single approval; returns the new ids and which are ready to work now."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("project") {
|
||||
put("type", "string")
|
||||
put("description", "Project key shared by every task, e.g. 'webui'. Ids become '<project>-<n>'.")
|
||||
}
|
||||
putJsonObject("parent") {
|
||||
put("type", "object")
|
||||
put(
|
||||
"description",
|
||||
"Optional umbrella/epic. It DEPENDS_ON every child (completes last); each child IMPLEMENTS it.",
|
||||
)
|
||||
putJsonObject("properties") { unitProperties() }
|
||||
}
|
||||
putJsonObject("tasks") {
|
||||
put("type", "array")
|
||||
put("description", "The units of work, in order. Declare cross-task ordering with depends_on.")
|
||||
putJsonObject("items") {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("ref") {
|
||||
put("type", "string")
|
||||
put("description", "Local handle other tasks cite in depends_on (e.g. 'scaffold').")
|
||||
}
|
||||
unitProperties()
|
||||
putJsonObject("depends_on") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) })
|
||||
}
|
||||
}
|
||||
putJsonObject("force") {
|
||||
put("type", "boolean")
|
||||
put("description", "Create even if a title duplicates an active task. Default false.")
|
||||
}
|
||||
putJsonObject("force_reason") {
|
||||
put("type", "string")
|
||||
put("description", "Required when force=true: why duplicates are acceptable. Recorded on each task.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("project")); add(JsonPrimitive("tasks")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
|
||||
if (elementOf(request, "tasks") !is JsonArray) {
|
||||
return ValidationResult.Invalid("Missing 'tasks' (JSON array).")
|
||||
}
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val project = request.stringParam("project") ?: return fail(request, "Missing 'project' (string).")
|
||||
val specs = parseSpecs(elementOf(request, "tasks") as? JsonArray)
|
||||
?: return fail(request, "'tasks' must be a JSON array of {title, goal, ...}.")
|
||||
if (specs.isEmpty()) return fail(request, "'tasks' must contain at least one task.")
|
||||
specs.withIndex().firstOrNull { it.value.title.isBlank() || it.value.goal.isBlank() }?.let {
|
||||
return fail(request, "task #${it.index} is missing 'title' or 'goal'.")
|
||||
}
|
||||
val parent = parseSpec(elementOf(request, "parent") as? JsonObject)
|
||||
if (parent != null && (parent.title.isBlank() || parent.goal.isBlank())) {
|
||||
return fail(request, "'parent' needs both 'title' and 'goal'.")
|
||||
}
|
||||
|
||||
val adjacency = when (val e = buildEdges(specs)) {
|
||||
is Edges.Err -> return fail(request, e.message)
|
||||
is Edges.Ok -> e.adjacency
|
||||
}
|
||||
dedupFailure(request, project, specs, parent)?.let { return it }
|
||||
|
||||
val pid = ProjectId(project)
|
||||
val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
|
||||
val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
|
||||
wireLinks(adjacency, children, parentTask)
|
||||
recordProvenance(request, listOfNotNull(parentTask) + children)
|
||||
|
||||
val createdIds = (children + listOfNotNull(parentTask)).joinToString(",") { it.taskId.value }
|
||||
val readyIds = children.filter { service.blockers(it.taskId).isEmpty() }.map { it.taskId.value }
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = render(children, parentTask, readyIds),
|
||||
metadata = mapOf("taskIds" to createdIds, "readyIds" to readyIds.joinToString(","), "project" to project),
|
||||
)
|
||||
}
|
||||
|
||||
/** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */
|
||||
private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) {
|
||||
adjacency.forEachIndexed { i, deps ->
|
||||
deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) }
|
||||
}
|
||||
if (parentTask != null) {
|
||||
children.forEach { child ->
|
||||
// Parent waits on every child; child records the epic it implements.
|
||||
service.link(parentTask.taskId, child.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
|
||||
service.link(child.taskId, parentTask.taskId.value, TaskLinkType.IMPLEMENTS, TaskTargetKind.TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recordProvenance(request: ToolRequest, created: List<Task>) {
|
||||
created.forEach { service.linkOriginSession(it.taskId, request) }
|
||||
if (!request.boolParam("force")) return
|
||||
val reason = request.stringParam("force_reason") ?: return
|
||||
created.forEach {
|
||||
service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason")
|
||||
}
|
||||
}
|
||||
|
||||
/** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */
|
||||
private fun dedupFailure(request: ToolRequest, project: String, specs: List<TaskSpec>, parent: TaskSpec?): ToolResult.Failure? {
|
||||
val titles = (listOfNotNull(parent?.title) + specs.map { it.title })
|
||||
val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys
|
||||
if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
|
||||
if (request.boolParam("force")) {
|
||||
return if (request.stringParam("force_reason") == null) {
|
||||
fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val pid = ProjectId(project)
|
||||
val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value }
|
||||
return clashes.takeIf { it.isNotEmpty() }?.let {
|
||||
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
|
||||
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(children: List<Task>, parentTask: Task?, readyIds: List<String>): String {
|
||||
val lines = children.map { c ->
|
||||
val unmet = service.blockers(c.taskId).map { it.taskId.value }
|
||||
val state = if (unmet.isEmpty()) "READY" else "blocked by ${unmet.joinToString(", ")}"
|
||||
"- ${c.taskId.value} '${c.state.title.orEmpty()}' [$state]"
|
||||
} + listOfNotNull(
|
||||
parentTask?.let { p ->
|
||||
val unmet = service.blockers(p.taskId).map { it.taskId.value }
|
||||
"- ${p.taskId.value} '${p.state.title.orEmpty()}' (epic) [blocked by ${unmet.joinToString(", ")}]"
|
||||
},
|
||||
)
|
||||
val readyLine = if (readyIds.isEmpty()) {
|
||||
"Nothing is ready yet — check the dependency graph."
|
||||
} else {
|
||||
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete."
|
||||
}
|
||||
return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" +
|
||||
lines.joinToString("\n") + "\n" + readyLine
|
||||
}
|
||||
|
||||
// --- parsing (flattened JSON args) ---
|
||||
|
||||
private data class TaskSpec(
|
||||
val ref: String?,
|
||||
val title: String,
|
||||
val goal: String,
|
||||
val acceptanceCriteria: List<String>,
|
||||
val affectedPaths: List<String>,
|
||||
val deps: List<String>,
|
||||
)
|
||||
|
||||
private fun parseSpecs(element: JsonArray?): List<TaskSpec>? =
|
||||
element?.map { parseSpec(it as? JsonObject) ?: return null }
|
||||
|
||||
private fun parseSpec(obj: JsonObject?): TaskSpec? {
|
||||
if (obj == null) return null
|
||||
return TaskSpec(
|
||||
ref = str(obj, "ref"),
|
||||
title = str(obj, "title").orEmpty(),
|
||||
goal = str(obj, "goal").orEmpty(),
|
||||
acceptanceCriteria = strList(obj, "acceptance_criteria"),
|
||||
affectedPaths = strList(obj, "affected_paths"),
|
||||
deps = strList(obj, "depends_on"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun str(obj: JsonObject, key: String): String? =
|
||||
(obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
private fun strList(obj: JsonObject, key: String): List<String> =
|
||||
(obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList()
|
||||
|
||||
/** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */
|
||||
private fun elementOf(request: ToolRequest, name: String) =
|
||||
request.parameters[name]?.let { runCatching { Json.parseToJsonElement(it.toString()) }.getOrNull() }
|
||||
|
||||
private fun normalize(title: String): String = title.trim().lowercase().replace(Regex("\\s+"), " ")
|
||||
|
||||
// --- dependency edges + cycle check ---
|
||||
|
||||
private sealed interface Edges {
|
||||
data class Ok(val adjacency: List<List<Int>>) : Edges
|
||||
data class Err(val message: String) : Edges
|
||||
}
|
||||
|
||||
private fun buildEdges(specs: List<TaskSpec>): Edges {
|
||||
val refToIndex = HashMap<String, Int>()
|
||||
specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } }
|
||||
val adjacency = ArrayList<List<Int>>()
|
||||
specs.forEachIndexed { i, s ->
|
||||
val deps = ArrayList<Int>()
|
||||
for (d in s.deps) {
|
||||
val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices }
|
||||
?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.")
|
||||
if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.")
|
||||
deps.add(j)
|
||||
}
|
||||
adjacency.add(deps.distinct())
|
||||
}
|
||||
cyclePath(adjacency)?.let { path ->
|
||||
return Edges.Err("Dependency cycle: ${path.joinToString(" -> ") { specs[it].title }}.")
|
||||
}
|
||||
return Edges.Ok(adjacency)
|
||||
}
|
||||
|
||||
/** Returns a back-edge cycle as a list of task indices, or null when the graph is acyclic. */
|
||||
private fun cyclePath(adjacency: List<List<Int>>): List<Int>? {
|
||||
val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done
|
||||
val stack = ArrayList<Int>()
|
||||
fun dfs(u: Int): List<Int>? {
|
||||
color[u] = 1
|
||||
stack.add(u)
|
||||
for (v in adjacency[u]) {
|
||||
if (color[v] == 1) return stack.subList(stack.indexOf(v), stack.size).toList() + v
|
||||
if (color[v] == 0) dfs(v)?.let { return it }
|
||||
}
|
||||
color[u] = 2
|
||||
stack.removeAt(stack.lastIndex)
|
||||
return null
|
||||
}
|
||||
for (i in adjacency.indices) if (color[i] == 0) dfs(i)?.let { return it }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
|
||||
ToolResult.Failure(request.invocationId, reason, recoverable = false)
|
||||
}
|
||||
|
||||
/** The fields a task spec and the parent epic share, declared once for the schema. */
|
||||
private fun kotlinx.serialization.json.JsonObjectBuilder.unitProperties() {
|
||||
putJsonObject("title") { put("type", "string"); put("description", "Short imperative title.") }
|
||||
putJsonObject("goal") { put("type", "string"); put("description", "What 'done' looks like.") }
|
||||
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.")
|
||||
}
|
||||
}
|
||||
+56
@@ -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.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 =
|
||||
"Use only to remove a task opened by mistake or a duplicate: soft-deletes it (drops " +
|
||||
"from active views; history is kept). For real work that's been abandoned, use " +
|
||||
"task_update action=cancel instead so it stays visible."
|
||||
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<ToolCapability> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
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.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
private const val DEFAULT_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Agent-facing tool: list tasks that are ready to be worked right now — TODO with every dependency
|
||||
* satisfied. Read-only, lowest tier. This is how an agent looking for work discovers what it can
|
||||
* pick up; it then claims one with `task_update action=claim`. It surfaces work, it does not assign.
|
||||
*/
|
||||
class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_ready"
|
||||
override val description: String =
|
||||
"List tasks ready to work now — TODO with all dependencies satisfied — when you're looking " +
|
||||
"for what to pick up. Claim one with task_update action=claim. Optionally scope to a " +
|
||||
"project. Returns 'id [STATUS] title' lines."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("project") {
|
||||
put("type", "string")
|
||||
put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.")
|
||||
}
|
||||
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
|
||||
}
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val project = request.stringParam("project")?.let { ProjectId(it) }
|
||||
val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT
|
||||
val ready = service.ready(project).take(limit)
|
||||
val output = if (ready.isEmpty()) {
|
||||
"no ready tasks"
|
||||
} else {
|
||||
ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = output,
|
||||
metadata = mapOf("count" to ready.size.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
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
|
||||
|
||||
private const val DEFAULT_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Agent-facing tool: find tasks by text across projects (matches id/title/goal/criteria/notes),
|
||||
* ranked by relevance. Read-only, so it sits at the lowest tier (like [TaskContextTool]) — agents
|
||||
* use it to discover task ids before fetching a context bundle.
|
||||
*/
|
||||
class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_search"
|
||||
override val description: String =
|
||||
"Search tasks before opening a new one (avoid duplicates) and to find related or " +
|
||||
"blocking work, or to look up a task id by text. Matches id/title/goal/acceptance " +
|
||||
"criteria/notes, ranked by relevance; returns 'id [STATUS] title' lines. Pair with " +
|
||||
"task_context to load a hit."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") }
|
||||
putJsonObject("project") {
|
||||
put("type", "string")
|
||||
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.")
|
||||
}
|
||||
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("query")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("query") ?: return ValidationResult.Invalid("Missing 'query' (string).")
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val query = request.stringParam("query")
|
||||
?: return ToolResult.Failure(request.invocationId, "Missing 'query' (string).", recoverable = false)
|
||||
val project = request.stringParam("project")?.let { ProjectId(it) }
|
||||
val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT
|
||||
val hits = service.search(query, project).take(limit)
|
||||
val output = if (hits.isEmpty()) {
|
||||
"no tasks match \"$query\""
|
||||
} else {
|
||||
hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = output,
|
||||
metadata = mapOf("query" to query, "count" to hits.size.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.tasks.TaskArtifactResolver
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tasks.TaskDocumentResolver
|
||||
import com.correx.core.tasks.TaskKnowledgeRetriever
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tasks.TaskSessionResolver
|
||||
import com.correx.core.tools.contract.Tool
|
||||
|
||||
/** Builds the task tool set over a single [TaskService] for registration in the tool registry. */
|
||||
object TaskTools {
|
||||
/**
|
||||
* [retriever] semantically enriches the `task_context` bundle; [documentResolver],
|
||||
* [artifactResolver] and [sessionResolver] inline DOC/ARTIFACT/SESSION link targets. All
|
||||
* optional — null leaves those links raw and the bundle deterministic.
|
||||
*/
|
||||
fun forService(
|
||||
service: TaskService,
|
||||
retriever: TaskKnowledgeRetriever? = null,
|
||||
documentResolver: TaskDocumentResolver? = null,
|
||||
artifactResolver: TaskArtifactResolver? = null,
|
||||
sessionResolver: TaskSessionResolver? = null,
|
||||
sessionFacts: SessionFactRecorder? = null,
|
||||
sessionWrites: SessionWrites? = null,
|
||||
): List<Tool> =
|
||||
listOf(
|
||||
TaskCreateTool(service),
|
||||
TaskDecomposeTool(service),
|
||||
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
||||
TaskDeleteTool(service),
|
||||
TaskSearchTool(service),
|
||||
TaskReadyTool(service),
|
||||
TaskContextTool(
|
||||
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
|
||||
),
|
||||
)
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
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.tasks.TaskTargetKinds
|
||||
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
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* [sessionFacts] records the session's active task on claim; [sessionWrites], when bound, powers
|
||||
* the cite-before-claim gate (completing a task that declared affected_paths but saw no matching
|
||||
* write this session is blocked, escapable with force).
|
||||
*/
|
||||
class TaskUpdateTool(
|
||||
private val service: TaskService,
|
||||
private val sessionFacts: SessionFactRecorder? = null,
|
||||
private val sessionWrites: SessionWrites? = null,
|
||||
) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_update"
|
||||
override val description: String =
|
||||
"Keep a task's state current as you work it: claim before starting, submit_for_review " +
|
||||
"when ready, complete after review, block/unblock when waiting on something. Also " +
|
||||
"edits title/goal/criteria/paths, adds a work-graph link, or attaches a note. " +
|
||||
"Actions: claim, release, block, unblock, submit_for_review, complete, reopen, cancel."
|
||||
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.") }
|
||||
putJsonObject("force") {
|
||||
put("type", "boolean")
|
||||
put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.")
|
||||
}
|
||||
putJsonObject("force_reason") {
|
||||
put("type", "string")
|
||||
put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = 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")
|
||||
|
||||
val action = request.stringParam("action")
|
||||
forceReasonGate(request)?.let { return it }
|
||||
claimBlock(request, taskId, action)?.let { return it }
|
||||
completeBlock(request, taskId, action)?.let { return it }
|
||||
|
||||
applyFieldEdits(request, taskId)
|
||||
|
||||
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) }
|
||||
|
||||
recordForceNote(request, taskId, action)
|
||||
recordWorkingTask(request, taskId, action)
|
||||
|
||||
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
|
||||
val warning = if (action == "claim") claimWarning(taskId) else ""
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "Updated $id [$status]$warning",
|
||||
metadata = mapOf("taskId" to id, "status" to status),
|
||||
)
|
||||
}
|
||||
|
||||
/** A force override must carry a recorded justification — reject force=true with no force_reason. */
|
||||
private fun forceReasonGate(request: ToolRequest): ToolResult.Failure? =
|
||||
if (request.boolParam("force") && request.stringParam("force_reason") == null) {
|
||||
fail(request, "force=true requires 'force_reason' explaining the override.")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/** A forced claim/complete bypassed a gate — record why on the task, for the audit trail. */
|
||||
private suspend fun recordForceNote(request: ToolRequest, taskId: TaskId, action: String?) {
|
||||
if (!request.boolParam("force")) return
|
||||
val reason = request.stringParam("force_reason") ?: return
|
||||
service.addNote(taskId, TaskNoteAuthor.AGENT, "[force] ${action ?: "update"}: $reason")
|
||||
}
|
||||
|
||||
/** Refuse to claim a task with unmet blockers (escapable with force+reason) — fail before any mutation. */
|
||||
private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
|
||||
if (action != "claim" || request.boolParam("force")) return null
|
||||
val unmet = service.blockers(taskId)
|
||||
if (unmet.isEmpty()) return null
|
||||
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
|
||||
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cite-before-claim: refuse to complete a task that declared affected_paths but saw no matching
|
||||
* write this session — "marked done without doing it." Off when no [sessionWrites] is bound or
|
||||
* the task declared no scope; escapable with force.
|
||||
*/
|
||||
private fun completeBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
|
||||
if (action != "complete" || request.boolParam("force")) return null
|
||||
val writes = sessionWrites ?: return null
|
||||
val scope = service.getTask(taskId)?.state?.affectedPaths.orEmpty()
|
||||
if (scope.isEmpty()) return null
|
||||
if (matchesAnyGlob(writes.pathsWritten(request.sessionId), scope)) return null
|
||||
return fail(
|
||||
request,
|
||||
"Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " +
|
||||
"session. Make the change, or force=true with force_reason.",
|
||||
)
|
||||
}
|
||||
|
||||
/** True if any written path matches any affected_paths glob (workspace-relative; './' tolerated). */
|
||||
private fun matchesAnyGlob(written: Set<String>, globs: List<String>): Boolean {
|
||||
if (written.isEmpty()) return false
|
||||
val fs = FileSystems.getDefault()
|
||||
val matchers = globs.map { fs.getPathMatcher("glob:${it.removePrefix("./")}") }
|
||||
return written.any { w ->
|
||||
val rel = Path.of(w.removePrefix("./"))
|
||||
matchers.any { it.matches(rel) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the session→task working relationship (for SessionContext.activeTask) on a claim, or on
|
||||
* an affected_paths edit by the recorded claimant (to refresh the snapshot scope). No-op without
|
||||
* a recorder bound.
|
||||
*/
|
||||
private suspend fun recordWorkingTask(request: ToolRequest, taskId: TaskId, action: String?) {
|
||||
val recorder = sessionFacts ?: return
|
||||
val task = service.getTask(taskId) ?: return
|
||||
val isClaim = action == "claim"
|
||||
val scopeEditByClaimant = request.parameters.containsKey("affected_paths") &&
|
||||
task.state.claimant == request.sessionId.value
|
||||
if (isClaim || scopeEditByClaimant) {
|
||||
recorder.recordWorkingTask(request.sessionId, taskId, task.state.affectedPaths)
|
||||
}
|
||||
}
|
||||
|
||||
/** When a force-claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */
|
||||
private fun claimWarning(taskId: TaskId): String {
|
||||
val blockers = service.blockers(taskId)
|
||||
if (blockers.isEmpty()) return ""
|
||||
val listed = blockers.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
|
||||
return " — WARNING: claimed despite unmet blockers: $listed"
|
||||
}
|
||||
|
||||
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)
|
||||
// Record the claiming session too, so a task picked up by a different run than
|
||||
// the one that created it links both into its work graph.
|
||||
service.linkOriginSession(taskId, request)
|
||||
}
|
||||
"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 TaskTargetKinds.infer(target)
|
||||
|
||||
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
|
||||
ToolResult.Failure(request.invocationId, reason, recoverable = false)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
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.TaskTargetKind
|
||||
import com.correx.core.tasks.TaskService
|
||||
|
||||
/** 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<String> =
|
||||
(parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList()
|
||||
|
||||
/** A boolean parameter, accepting a real Boolean or the string "true" (case-insensitive). */
|
||||
internal fun ToolRequest.boolParam(name: String): Boolean =
|
||||
parameters[name] == true || stringParam(name)?.toBoolean() == true
|
||||
|
||||
/**
|
||||
* Records the agent's session on the task as a CONTEXT/SESSION link, so the context bundle can
|
||||
* surface the run(s) that created or worked on it (the session resolver inlines status/intent).
|
||||
* No-op for a blank session id; duplicate links are deduped by the reducer, so creating and then
|
||||
* claiming from the same session yields a single edge.
|
||||
*/
|
||||
internal suspend fun TaskService.linkOriginSession(taskId: TaskId, request: ToolRequest) {
|
||||
val session = request.sessionId.value.takeIf { it.isNotBlank() } ?: return
|
||||
link(taskId, session, TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SourceFetchedEvent
|
||||
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.tools.contract.ParamRole
|
||||
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 com.correx.core.tools.registry.ToolRegistry
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
|
||||
class SandboxedToolExecutorResearchSourceTest {
|
||||
|
||||
private class CapturingEventStore : EventStore {
|
||||
val payloads = mutableListOf<EventPayload>()
|
||||
override suspend fun append(event: NewEvent): StoredEvent {
|
||||
payloads += event.payload
|
||||
val seq = payloads.size.toLong()
|
||||
return StoredEvent(event.metadata, seq, seq, event.payload)
|
||||
}
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> = emptyList()
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = emptyList()
|
||||
override fun lastSequence(sessionId: SessionId): Long? = null
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
override suspend fun lastGlobalSequence(): Long = payloads.size.toLong()
|
||||
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
|
||||
override fun allSessionIds(): Set<SessionId> = emptySet()
|
||||
}
|
||||
|
||||
private class SingleToolRegistry(private val tool: Tool) : ToolRegistry {
|
||||
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
|
||||
override fun all(): List<Tool> = listOf(tool)
|
||||
}
|
||||
|
||||
/** Stands in for a research fetch: returns the same metadata shape WebFetchTool emits. */
|
||||
private class FakeFetchTool(private val metadata: Map<String, String>) : Tool, ToolExecutor {
|
||||
override val name: String = "web_fetch"
|
||||
override val description: String = "fake"
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS)
|
||||
override val paramRoles: Map<String, ParamRole> = mapOf("url" to ParamRole.NETWORK_TARGET)
|
||||
override val parametersSchema: JsonObject = JsonObject(emptyMap())
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
override suspend fun execute(request: ToolRequest): ToolResult =
|
||||
ToolResult.Success(request.invocationId, output = "md", metadata = metadata)
|
||||
}
|
||||
|
||||
private fun request() = ToolRequest(
|
||||
ToolInvocationId("inv"), SessionId("s"), StageId("st"), "web_fetch",
|
||||
mapOf("url" to "https://example.com/a"),
|
||||
)
|
||||
|
||||
private fun runFetch(metadata: Map<String, String>): CapturingEventStore {
|
||||
val tool = FakeFetchTool(metadata)
|
||||
val events = CapturingEventStore()
|
||||
val exec = SandboxedToolExecutor(
|
||||
delegate = tool,
|
||||
registry = SingleToolRegistry(tool),
|
||||
eventDispatcher = EventDispatcher(events),
|
||||
workDir = Files.createTempDirectory("sbx-research"),
|
||||
)
|
||||
runBlocking { exec.execute(request()) }
|
||||
return events
|
||||
}
|
||||
|
||||
private fun fetchMetadata(quality: String) = mapOf(
|
||||
"url" to "https://example.com/a",
|
||||
"content_type" to "text/html",
|
||||
"content_sha256" to "deadbeef",
|
||||
"fetched_bytes" to "4096",
|
||||
"extractor_version" to "html-md-1",
|
||||
"quality" to quality,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `ok fetch emits SourceFetchedEvent and no low-quality event`() {
|
||||
val events = runFetch(fetchMetadata("OK"))
|
||||
|
||||
val fetched = events.payloads.filterIsInstance<SourceFetchedEvent>().single()
|
||||
assertEquals("https://example.com/a", fetched.url)
|
||||
assertEquals("deadbeef", fetched.contentSha256)
|
||||
assertEquals("OK", fetched.quality)
|
||||
assertEquals(4096, fetched.byteCount)
|
||||
assertTrue(events.payloads.filterIsInstance<LowQualityExtractionEvent>().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low-quality fetch also emits LowQualityExtractionEvent`() {
|
||||
val events = runFetch(fetchMetadata("LOW_QUALITY"))
|
||||
|
||||
assertEquals(1, events.payloads.filterIsInstance<SourceFetchedEvent>().size)
|
||||
val low = events.payloads.filterIsInstance<LowQualityExtractionEvent>().single()
|
||||
assertEquals("https://example.com/a", low.url)
|
||||
assertEquals("deadbeef", low.contentSha256)
|
||||
assertTrue(low.reason.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-fetch tool result emits no research source events`() {
|
||||
// No url/content_sha256/quality markers → the promotion path must stay silent.
|
||||
val events = runFetch(mapOf("some" to "metadata"))
|
||||
|
||||
assertTrue(events.payloads.filterIsInstance<SourceFetchedEvent>().isEmpty())
|
||||
assertTrue(events.payloads.filterIsInstance<LowQualityExtractionEvent>().isEmpty())
|
||||
}
|
||||
}
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
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.TaskId
|
||||
import com.correx.core.events.types.TaskLinkType
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
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.assertFalse
|
||||
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 decompose = TaskDecomposeTool(service)
|
||||
private val update = TaskUpdateTool(service)
|
||||
private val delete = TaskDeleteTool(service)
|
||||
private val search = TaskSearchTool(service)
|
||||
private val ready = TaskReadyTool(service)
|
||||
private val context = TaskContextTool(TaskContextAssembler(service))
|
||||
|
||||
private var counter = 0
|
||||
private fun request(tool: String, params: Map<String, Any>) = 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)
|
||||
}
|
||||
|
||||
// task_decompose receives nested args as JSON strings (the orchestrator flattens non-primitive
|
||||
// tool arguments), so the tests pass strings to exercise the real parse path — not structured Lists.
|
||||
private val threeTaskGraph = """
|
||||
[
|
||||
{"ref":"scaffold","title":"Scaffold app","goal":"shell + build","affected_paths":["apps/web/**"]},
|
||||
{"ref":"auth","title":"Auth view","goal":"login","depends_on":["scaffold"]},
|
||||
{"ref":"dash","title":"Dashboard","goal":"home","depends_on":["scaffold"]}
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `task_decompose builds a DEPENDS_ON graph with one ready root and a blocked epic`() = runBlocking {
|
||||
val result = decompose.execute(
|
||||
request(
|
||||
"task_decompose",
|
||||
mapOf(
|
||||
"project" to "webui",
|
||||
"parent" to """{"title":"Frontend web UI","goal":"web ui for correx"}""",
|
||||
"tasks" to threeTaskGraph,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val meta = (result as ToolResult.Success).metadata
|
||||
// parent created first (webui-1), then children in order (webui-2..4).
|
||||
assertEquals("webui-2,webui-3,webui-4,webui-1", meta.getValue("taskIds"))
|
||||
assertEquals("webui-2", meta.getValue("readyIds"))
|
||||
// affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg).
|
||||
assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths)
|
||||
// scaffold is the only ready task; the dependents and the epic are blocked.
|
||||
assertEquals(listOf("webui-2"), service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value })
|
||||
assertEquals(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value })
|
||||
assertEquals(3, service.blockers(TaskId("webui-1")).size)
|
||||
// child implements the epic; epic depends on the child.
|
||||
assertTrue(
|
||||
service.getTask(TaskId("webui-2"))!!.state.links.any {
|
||||
it.type == TaskLinkType.IMPLEMENTS && it.targetKind == TaskTargetKind.TASK && it.targetId == "webui-1"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_decompose resolves depends_on by index`() = runBlocking {
|
||||
val result = decompose.execute(
|
||||
request(
|
||||
"task_decompose",
|
||||
mapOf(
|
||||
"project" to "webui",
|
||||
"tasks" to """[{"title":"Scaffold","goal":"g"},{"title":"Wire","goal":"g","depends_on":["0"]}]""",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(result is ToolResult.Success)
|
||||
assertEquals(listOf("webui-1"), service.blockers(TaskId("webui-2")).map { it.taskId.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_decompose rejects a dependency cycle`() = runBlocking {
|
||||
val result = decompose.execute(
|
||||
request(
|
||||
"task_decompose",
|
||||
mapOf(
|
||||
"project" to "webui",
|
||||
"tasks" to """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]""",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
assertTrue((result as ToolResult.Failure).reason.contains("cycle", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_decompose rejects an unresolved dependency reference`() = runBlocking {
|
||||
val result = decompose.execute(
|
||||
request(
|
||||
"task_decompose",
|
||||
mapOf("project" to "webui", "tasks" to """[{"title":"A","goal":"g","depends_on":["ghost"]}]"""),
|
||||
),
|
||||
)
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_decompose rejects a task missing title or goal`() = runBlocking {
|
||||
val result = decompose.execute(
|
||||
request("task_decompose", mapOf("project" to "webui", "tasks" to """[{"title":"A"}]""")),
|
||||
)
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_decompose rejects an empty task list`() = runBlocking {
|
||||
val result = decompose.execute(request("task_decompose", mapOf("project" to "webui", "tasks" to "[]")))
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_decompose blocks a duplicate title and force needs a recorded reason`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "webui", "title" to "Scaffold app", "goal" to "g")))
|
||||
val dup = mapOf("project" to "webui", "tasks" to """[{"title":"Scaffold app","goal":"again"}]""")
|
||||
|
||||
assertTrue(decompose.execute(request("task_decompose", dup)) is ToolResult.Failure)
|
||||
assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) is ToolResult.Failure)
|
||||
|
||||
val forced = decompose.execute(request("task_decompose", dup + ("force" to true) + ("force_reason" to "intentional rebuild")))
|
||||
assertTrue(forced is ToolResult.Success)
|
||||
val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",")
|
||||
assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") })
|
||||
}
|
||||
|
||||
@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)
|
||||
assertTrue(state.links.any { it.targetId == "adr-7" && it.type == TaskLinkType.IMPLEMENTS })
|
||||
assertEquals("starting", state.notes.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_create and claim auto-link the agent session as CONTEXT`() = runBlocking {
|
||||
val id = createTask() // session s-1
|
||||
val taskId = TaskId(id)
|
||||
|
||||
// Created from s-1 → one CONTEXT/SESSION edge.
|
||||
val afterCreate = service.getTask(taskId)!!.state.links.single()
|
||||
assertEquals("s-1", afterCreate.targetId)
|
||||
assertEquals(TaskTargetKind.SESSION, afterCreate.targetKind)
|
||||
assertEquals(TaskLinkType.CONTEXT, afterCreate.type)
|
||||
|
||||
// Claiming from the same session dedupes to the same single edge.
|
||||
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
|
||||
assertEquals(1, service.getTask(taskId)!!.state.links.count { it.targetId == "s-1" })
|
||||
}
|
||||
|
||||
@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_search ranks matches across projects and reports misses`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")))
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys")))
|
||||
|
||||
val hit = search.execute(request("task_search", mapOf("query" to "jwt")))
|
||||
assertTrue(hit is ToolResult.Success)
|
||||
val out = (hit as ToolResult.Success).output
|
||||
assertTrue(out.contains("auth-1"))
|
||||
assertFalse(out.contains("auth-2"))
|
||||
|
||||
val miss = search.execute(request("task_search", mapOf("query" to "zebra")))
|
||||
assertTrue((miss as ToolResult.Success).output.contains("no tasks match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_ready lists unblocked TODO tasks only`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
|
||||
// auth-2 depends on auth-1 (still TODO) → auth-2 not ready.
|
||||
update.execute(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
|
||||
),
|
||||
)
|
||||
|
||||
val out = (ready.execute(request("task_ready", emptyMap())) as ToolResult.Success).output
|
||||
assertTrue(out.contains("auth-1"))
|
||||
assertFalse(out.contains("auth-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g")))
|
||||
|
||||
val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")))
|
||||
assertTrue(dup is ToolResult.Failure)
|
||||
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
|
||||
|
||||
// force without a reason is rejected.
|
||||
val noReason = create.execute(
|
||||
request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)),
|
||||
)
|
||||
assertTrue(noReason is ToolResult.Failure)
|
||||
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
|
||||
|
||||
// force with a reason creates, and records the reason as a note.
|
||||
val forced = create.execute(
|
||||
request(
|
||||
"task_create",
|
||||
mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g",
|
||||
"force" to true, "force_reason" to "separate refresh path for mobile"),
|
||||
),
|
||||
)
|
||||
assertTrue(forced is ToolResult.Success)
|
||||
val id = (forced as ToolResult.Success).metadata.getValue("taskId")
|
||||
val note = service.getTask(TaskId(id))!!.state.notes.single()
|
||||
assertTrue(note.body.contains("[force]"))
|
||||
assertTrue(note.body.contains("separate refresh path for mobile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update blocks claiming over unmet blockers, force overrides with a warning`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
|
||||
update.execute(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
|
||||
),
|
||||
)
|
||||
|
||||
// Plain claim is blocked — auth-1 is unfinished.
|
||||
val blocked = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim")))
|
||||
assertTrue(blocked is ToolResult.Failure)
|
||||
assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
|
||||
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
|
||||
|
||||
// force without a reason is rejected.
|
||||
val noReason = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)))
|
||||
assertTrue(noReason is ToolResult.Failure)
|
||||
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
|
||||
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status)
|
||||
|
||||
// force with a reason claims, records the reason as a note, and warns.
|
||||
val forced = update.execute(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf("id" to "auth-2", "action" to "claim", "force" to true, "force_reason" to "prep work, auth-1 lands soon"),
|
||||
),
|
||||
)
|
||||
assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
|
||||
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
|
||||
assertTrue(service.getTask(TaskId("auth-2"))!!.state.notes.any { it.body.contains("[force]") && it.body.contains("prep work") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claiming a task records the session working it`() = runBlocking {
|
||||
create.execute(
|
||||
request(
|
||||
"task_create",
|
||||
mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
|
||||
),
|
||||
)
|
||||
val captured = mutableListOf<Triple<String, String, List<String>>>()
|
||||
val recorder = SessionFactRecorder { sid, tid, paths -> captured.add(Triple(sid.value, tid.value, paths)) }
|
||||
|
||||
TaskUpdateTool(service, recorder).execute(request("task_update", mapOf("id" to "auth-1", "action" to "claim")))
|
||||
|
||||
assertEquals(1, captured.size)
|
||||
assertEquals("auth-1", captured.single().second)
|
||||
assertEquals(listOf("core/auth/**"), captured.single().third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update blocks completing a task with no writes under its affected_paths`() = runBlocking {
|
||||
create.execute(
|
||||
request(
|
||||
"task_create",
|
||||
mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
|
||||
),
|
||||
)
|
||||
val id = "auth-1"
|
||||
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
|
||||
update.execute(request("task_update", mapOf("id" to id, "action" to "submit_for_review")))
|
||||
|
||||
// No matching write this session → complete is blocked, task stays IN_REVIEW.
|
||||
val noWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { emptySet() })
|
||||
val blocked = noWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
|
||||
assertTrue(blocked is ToolResult.Failure)
|
||||
assertEquals(TaskStatus.IN_REVIEW, service.getTask(TaskId(id))!!.state.status)
|
||||
|
||||
// A write under affected_paths → completes.
|
||||
val withWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { setOf("core/auth/Login.kt") })
|
||||
val ok = withWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
|
||||
assertTrue(ok is ToolResult.Success)
|
||||
assertEquals(TaskStatus.DONE, service.getTask(TaskId(id))!!.state.status)
|
||||
}
|
||||
|
||||
@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<StoredEvent>()
|
||||
private var global = 0L
|
||||
private val perSession = mutableMapOf<SessionId, Long>()
|
||||
|
||||
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<NewEvent>): List<StoredEvent> = events.map { append(it) }
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
read(sessionId).filter { it.sessionSequence >= fromSequence }
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence }
|
||||
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
|
||||
override suspend fun lastGlobalSequence(): Long = global
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
|
||||
}
|
||||
+33
@@ -17,6 +17,12 @@ private const val TERMINAL = "done"
|
||||
|
||||
class ExecutionPlanCompiler(
|
||||
private val registry: ArtifactKindRegistry,
|
||||
// Names of every registered tool. A stage that references a tool the runtime can't resolve
|
||||
// is dropped silently at run time (mapNotNull), so the stage runs toolless — invisible
|
||||
// without a live run. Validating here turns that into a compile-time rejection (which the
|
||||
// freestyle driver feeds back for a retry). Empty = the caller didn't supply the tool
|
||||
// universe, so validation is skipped (preserves the bare `ExecutionPlanCompiler(registry)`).
|
||||
private val knownTools: Set<String> = emptySet(),
|
||||
) {
|
||||
private val mapper = JsonMapper.builder()
|
||||
.addModule(kotlinModule())
|
||||
@@ -27,6 +33,7 @@ class ExecutionPlanCompiler(
|
||||
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
|
||||
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||
validateTools(plan)
|
||||
|
||||
val stageMap = plan.stages.associate { s ->
|
||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||
@@ -34,10 +41,20 @@ class ExecutionPlanCompiler(
|
||||
val kindId = s.kind ?: s.produces
|
||||
val kind = registry.get(kindId)
|
||||
?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'")
|
||||
// Write-guard manifest = static per-stage globs UNION the plan-derived set (BACKLOG
|
||||
// §B-§2). Plan stages carry no separate static `writes` glob list today, so the static
|
||||
// baseline here is empty and the plan-derived paths form the manifest; the union is kept
|
||||
// explicit so a future static source widens rather than replaces. Sorted for a
|
||||
// deterministic, replay-stable manifest.
|
||||
val writeManifest = PlanDerivedManifest.combine(
|
||||
staticManifest = emptyList(),
|
||||
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
||||
).sorted()
|
||||
StageId(s.id) to StageConfig(
|
||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||
allowedTools = s.tools.toSet(),
|
||||
writeManifest = writeManifest,
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
)
|
||||
}
|
||||
@@ -71,6 +88,22 @@ class ExecutionPlanCompiler(
|
||||
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tool a stage names must be a registered tool, else the runtime silently drops it
|
||||
* (resolve → null → mapNotNull) and the stage runs without it. Skipped when [knownTools] is
|
||||
* empty (the caller didn't supply the tool universe).
|
||||
*/
|
||||
private fun validateTools(plan: ExecutionPlanModel) {
|
||||
if (knownTools.isEmpty()) return
|
||||
val offenders = plan.stages.flatMap { s -> s.tools.filter { it !in knownTools }.map { s.id to it } }
|
||||
if (offenders.isEmpty()) return
|
||||
val detail = offenders.joinToString(", ") { (stage, tool) -> "'$tool' in stage '$stage'" }
|
||||
throw WorkflowValidationException(
|
||||
"execution_plan references unknown tool(s): $detail — valid tools: " +
|
||||
knownTools.sorted().joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A field-equals edge can only ever fire if the producing stage's kind schema declares
|
||||
* that field — the kind's schema is also the LLM response format, so an undeclared field
|
||||
|
||||
+6
@@ -15,6 +15,12 @@ data class PlanStage(
|
||||
val kind: String? = null,
|
||||
val needs: List<String> = emptyList(),
|
||||
val tools: List<String> = emptyList(),
|
||||
// Workspace-relative file paths/globs this stage declares it will write (BACKLOG §B-§2).
|
||||
// Drives the plan-derived diff manifest: these are unioned with the static per-stage
|
||||
// write globs to form the enforced write-guard manifest. Absent/empty = no plan-derived
|
||||
// contribution (the static manifest, if any, still applies). The planner emits this under
|
||||
// the JSON key `writes`.
|
||||
val writes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class PlanEdge(
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
/**
|
||||
* Plan-derived diff manifest (BACKLOG §B-§2).
|
||||
*
|
||||
* The write-guard manifest enforced by the plane-2 `ManifestContainmentRule` was originally a
|
||||
* STATIC per-stage `writes = [globs]` set (role-reliability §2). This object DERIVES the
|
||||
* allowed-write set for a stage from the confirmed `impl_plan` artifact instead — the file
|
||||
* paths/globs a [PlanStage] declares it produces/touches — so the guard tracks the actual locked
|
||||
* plan rather than only hand-written globs.
|
||||
*
|
||||
* The derived set is not a replacement: it AUGMENTS the static baseline. [combine] unions the two,
|
||||
* and the resulting set is fed verbatim into `StageConfig.writeManifest`, so plan-derived paths are
|
||||
* matched with the SAME glob/containment semantics the static manifest already uses — no parallel
|
||||
* matcher is introduced.
|
||||
*
|
||||
* ## Extraction rule
|
||||
* Write targets are read from [PlanStage.writes]: the workspace-relative file paths/globs the
|
||||
* plan-stage explicitly declares it will write. `produces`/`kind`/`needs` name artifact *slots*
|
||||
* (logical outputs), not filesystem paths, so they are deliberately NOT treated as write targets.
|
||||
* Derivation is deterministic and lenient: a stage with no [PlanStage.writes] yields an empty set,
|
||||
* blank/whitespace entries are dropped, and duplicates collapse.
|
||||
*/
|
||||
object PlanDerivedManifest {
|
||||
|
||||
/**
|
||||
* The set of workspace-relative paths/globs [planStage] is permitted to write, derived from its
|
||||
* declared [PlanStage.writes]. Lenient: a stage with no declared write targets yields the empty
|
||||
* set (the static manifest, if any, still governs). Deterministic for a given input.
|
||||
*/
|
||||
fun deriveAllowedWrites(planStage: PlanStage): Set<String> =
|
||||
planStage.writes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* Unions the static per-stage write globs with the plan-derived set. A write is allowed if it
|
||||
* matches EITHER source. Blank entries are dropped so the combined set carries only matchable
|
||||
* globs. The static path is never narrowed — the result only ever widens the static baseline by
|
||||
* the plan-derived paths.
|
||||
*/
|
||||
fun combine(staticManifest: Collection<String>, planDerived: Set<String>): Set<String> =
|
||||
(staticManifest + planDerived)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
}
|
||||
+81
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ExecutionPlanCompilerTest {
|
||||
|
||||
@@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest {
|
||||
assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan-declared writes are derived into the stage write manifest`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "implement a feature",
|
||||
"stages": [
|
||||
{
|
||||
"id": "impl",
|
||||
"prompt": "Implement it",
|
||||
"produces": "patch",
|
||||
"needs": [],
|
||||
"tools": ["file_write"],
|
||||
"writes": ["core/feature/**", "docs/feature.md"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "impl", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val graph = compiler.compile(plan, "manifest-workflow")
|
||||
val stage = graph.stages.values.single()
|
||||
assertEquals(setOf("core/feature/**", "docs/feature.md"), stage.writeManifest.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage without declared writes has an empty write manifest`() {
|
||||
val graph = compiler.compile(validPlan, "no-writes-workflow")
|
||||
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
||||
@@ -241,4 +273,53 @@ class ExecutionPlanCompilerTest {
|
||||
fun `malformed JSON throws WorkflowValidationException`() {
|
||||
assertThrows<WorkflowValidationException> { compiler.compile("not-json", "bad-workflow") }
|
||||
}
|
||||
|
||||
// A compiler that knows the tool universe, so plan tool names are validated.
|
||||
private val toolAware = ExecutionPlanCompiler(
|
||||
registry,
|
||||
knownTools = setOf("ShellTool", "file_write", "task_context", "task_update"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `unknown tool name throws WorkflowValidationException naming the offender`() {
|
||||
// The runtime would silently drop a misspelled tool; here it fails to compile instead.
|
||||
val bad = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"task_updates\"]")
|
||||
val ex = assertThrows<WorkflowValidationException> { toolAware.compile(bad, "bad-workflow") }
|
||||
assertEquals(true, ex.message?.contains("task_updates"))
|
||||
assertEquals(true, ex.message?.contains("analyse"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan threading registered task tools compiles`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "implement and review a tracked task",
|
||||
"stages": [
|
||||
{
|
||||
"id": "implement", "prompt": "claim auth-1, build, submit", "produces": "patch",
|
||||
"needs": [], "tools": ["file_write", "task_context", "task_update"]
|
||||
},
|
||||
{
|
||||
"id": "review", "prompt": "review and complete auth-1", "produces": "patch",
|
||||
"needs": ["patch"], "tools": ["task_context", "task_update"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "implement", "to": "review", "condition": { "type": "always_true" } },
|
||||
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val graph = toolAware.compile(plan, "tracked-workflow")
|
||||
val implement = graph.stages.values.first { it.metadata["promptInline"] == "claim auth-1, build, submit" }
|
||||
assertTrue(implement.allowedTools.containsAll(setOf("task_context", "task_update")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool validation is skipped when the tool universe is unknown`() {
|
||||
// The default compiler has no knownTools, so an arbitrary tool name still compiles.
|
||||
val plan = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"anything_goes\"]")
|
||||
val graph = compiler.compile(plan, "unvalidated-workflow")
|
||||
assertEquals(2, graph.stages.size)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -69,5 +69,12 @@ class FreestylePlanningWorkflowTest {
|
||||
assertTrue(architectToDone.condition is ArtifactValidated)
|
||||
|
||||
assertEquals(2, graph.transitions.size)
|
||||
|
||||
// analyst frames the work: search + open one task or decompose into a graph (the architect
|
||||
// threads the named task into the plan).
|
||||
assertEquals(
|
||||
setOf("file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"),
|
||||
graph.stages[StageId("analyst")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanDerivedManifestTest {
|
||||
|
||||
@Test
|
||||
fun `derives the declared write paths of a plan stage`() {
|
||||
val stage = PlanStage(id = "impl", writes = listOf("core/a/B.kt", "docs/notes.md"))
|
||||
assertEquals(setOf("core/a/B.kt", "docs/notes.md"), PlanDerivedManifest.deriveAllowedWrites(stage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage with no declared writes derives an empty set`() {
|
||||
val stage = PlanStage(id = "analyse", produces = "patch", needs = listOf("x"))
|
||||
assertTrue(PlanDerivedManifest.deriveAllowedWrites(stage).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple files are all derived and blank entries dropped`() {
|
||||
val stage = PlanStage(id = "impl", writes = listOf("a.kt", " ", "b.kt", "", "c.kt"))
|
||||
assertEquals(setOf("a.kt", "b.kt", "c.kt"), PlanDerivedManifest.deriveAllowedWrites(stage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combine unions the static globs with the plan-derived set`() {
|
||||
val combined = PlanDerivedManifest.combine(
|
||||
staticManifest = listOf("core/**"),
|
||||
planDerived = setOf("docs/x.md"),
|
||||
)
|
||||
assertEquals(setOf("core/**", "docs/x.md"), combined)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combine drops blank entries and de-duplicates across sources`() {
|
||||
val combined = PlanDerivedManifest.combine(
|
||||
staticManifest = listOf("core/**", " ", "shared.kt"),
|
||||
planDerived = setOf("shared.kt", "docs/x.md", ""),
|
||||
)
|
||||
assertEquals(setOf("core/**", "shared.kt", "docs/x.md"), combined)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined manifest matches via the same glob semantics the static manifest uses`() {
|
||||
// The combined set is fed verbatim into StageConfig.writeManifest, which the plane-2
|
||||
// ManifestContainmentRule matches with FileSystems glob PathMatchers. Assert that a write
|
||||
// allowed only by the plan-derived entry, one allowed only by the static glob, and one in
|
||||
// neither resolve as expected under that exact matcher — no parallel matcher is introduced.
|
||||
val combined = PlanDerivedManifest.combine(
|
||||
staticManifest = listOf("core/**"),
|
||||
planDerived = setOf("docs/x.md"),
|
||||
)
|
||||
val matchers = combined.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||
fun allowed(rel: String) = matchers.any { it.matches(Path.of(rel)) }
|
||||
|
||||
assertTrue(allowed("core/a/B.kt"), "static glob should allow core/a/B.kt")
|
||||
assertTrue(allowed("docs/x.md"), "plan-derived path should allow docs/x.md")
|
||||
assertFalse(allowed("apps/server/Main.kt"), "a path in neither source is denied")
|
||||
}
|
||||
}
|
||||
+31
@@ -7,8 +7,11 @@ import com.correx.core.artifacts.kind.JsonSchemaProperty
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.conditions.ArtifactFieldEquals
|
||||
import com.correx.core.transitions.conditions.FieldOperator
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
@@ -93,4 +96,32 @@ class ReviewLoopWorkflowTest {
|
||||
val changes = graph.transitions.first { it.to == StageId("implement") }.condition as ArtifactFieldEquals
|
||||
assertEquals(FieldOperator.NEQ, changes.operator)
|
||||
}
|
||||
|
||||
private fun repoFile(rel: String): Path? {
|
||||
var dir: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
|
||||
while (dir != null) {
|
||||
val candidate = dir.resolve(rel)
|
||||
if (candidate.exists()) return candidate
|
||||
dir = dir.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shipped review_loop grants the task tools to both stages`() {
|
||||
val path = repoFile("examples/workflows/review_loop.toml")
|
||||
assumeTrue(path != null, "review_loop.toml not found from ${System.getProperty("user.dir")}")
|
||||
val graph = loader.load(path!!)
|
||||
|
||||
// implement claims/submits and may create a task, alongside its file write.
|
||||
assertEquals(
|
||||
setOf("file_write", "task_create", "task_update", "task_context", "task_search"),
|
||||
graph.stages[StageId("implement")]!!.allowedTools,
|
||||
)
|
||||
// review reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
graph.stages[StageId("review")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -71,4 +71,28 @@ class RolePipelineWorkflowTest {
|
||||
.condition as ArtifactFieldEquals
|
||||
assertEquals(FieldOperator.NEQ, loop.operator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task tools are granted to the right stages`() {
|
||||
val path = repoFile("examples/workflows/role_pipeline.toml")
|
||||
assumeTrue(path != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}")
|
||||
val graph = TomlWorkflowLoader(registry).load(path!!)
|
||||
|
||||
// analyst opens + searches (read-only on files); creation is approval-gated (T2).
|
||||
assertEquals(
|
||||
setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"),
|
||||
graph.stages[StageId("analyst")]!!.allowedTools,
|
||||
)
|
||||
// implementer owns the lifecycle: claim/submit + the full file set.
|
||||
assertEquals(
|
||||
setOf("file_read", "file_write", "file_edit", "ShellTool") +
|
||||
setOf("task_create", "task_update", "task_context", "task_search"),
|
||||
graph.stages[StageId("implementer")]!!.allowedTools,
|
||||
)
|
||||
// reviewer reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
graph.stages[StageId("reviewer")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user