refactor(detekt): extract magic-number constants, wrap long lines, drop legit suppressions
Mechanical detekt cleanup across apps/core/infrastructure (behavior-preserving): - MagicNumber 62->10: named constants + removed 'legit' MagicNumber suppressions (Tier level from ordinal, ReplayInferenceProvider CHARS_PER_TOKEN, ConfigLoader DEFAULT_* consts, capability scores, HTTP ranges, column widths, etc.) - MaxLineLength cut to ~0 in production modules (line wraps, no logic change) - Dropped one dead parameter (ConfigLoader.parseArray lineNum) Structural findings (ReturnCount/LongMethod/Complexity/LargeClass) left for a deliberate decomposition pass. Full build + detekt gate green.
This commit is contained in:
+4
-3
@@ -39,9 +39,10 @@ class FileWriteTool(
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_write"
|
||||
override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " +
|
||||
"Writing to a nested path auto-creates any missing parent directories — there is no separate " +
|
||||
"mkdir step. Do not write shell commands into a file's content."
|
||||
override val description: String =
|
||||
"Write content to a file at the specified relative path (creates or overwrites). " +
|
||||
"Writing to a nested path auto-creates any missing parent directories — there is no separate " +
|
||||
"mkdir step. Do not write shell commands into a file's content."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
|
||||
+8
-3
@@ -179,7 +179,9 @@ class ListDirTool(
|
||||
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
|
||||
val notes = buildList {
|
||||
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
|
||||
if (ignoredCount > 0) add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
if (ignoredCount > 0) {
|
||||
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
}
|
||||
}
|
||||
return buildString {
|
||||
appendLine("<path>$root</path>")
|
||||
@@ -203,14 +205,14 @@ class ListDirTool(
|
||||
.filter { it != targetName && isSimilarName(it, targetName) }
|
||||
.toList()
|
||||
.sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it })
|
||||
.take(3)
|
||||
.take(MAX_SIMILAR_SUGGESTIONS)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun isSimilarName(a: String, b: String): Boolean {
|
||||
val prefix = commonPrefixLen(a, b)
|
||||
return prefix >= 2 || levenshtein(a, b) <= 3
|
||||
return prefix >= SIMILAR_NAME_MIN_COMMON_PREFIX || levenshtein(a, b) <= SIMILAR_NAME_MAX_LEVENSHTEIN
|
||||
}
|
||||
|
||||
private fun commonPrefixLen(a: String, b: String): Int {
|
||||
@@ -238,6 +240,9 @@ class ListDirTool(
|
||||
|
||||
private companion object {
|
||||
const val MAX_ENTRIES = 400
|
||||
const val MAX_SIMILAR_SUGGESTIONS = 3
|
||||
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
|
||||
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-9
@@ -35,6 +35,7 @@ private const val MAX_RESULTS = 200
|
||||
// symbol search wants, and reading it line-by-line stalls the walk. ponytail: fixed byte cap; make it
|
||||
// a param if a real use case needs to grep large generated files.
|
||||
private const val GREP_MAX_FILE_BYTES = 2_000_000L
|
||||
private const val GREP_LINE_PREVIEW_LENGTH = 200
|
||||
|
||||
/**
|
||||
* `.gitignore`-aware path search by glob pattern — the affordance a model should reach for to answer
|
||||
@@ -58,7 +59,10 @@ class GlobTool(
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("pattern") {
|
||||
put("type", "string")
|
||||
put("description", "Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.")
|
||||
put(
|
||||
"description",
|
||||
"Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.",
|
||||
)
|
||||
}
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
@@ -80,13 +84,27 @@ class GlobTool(
|
||||
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
|
||||
}
|
||||
val pattern = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() }
|
||||
?: return@withContext ToolResult.Failure(request.invocationId, "glob requires a non-empty 'pattern'", recoverable = true)
|
||||
?: return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"glob requires a non-empty 'pattern'",
|
||||
recoverable = true,
|
||||
)
|
||||
val base = resolveSearchPath(request, workingDir)
|
||||
if (!Files.isDirectory(base)) {
|
||||
return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true)
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Not a directory: ${request.parameters["path"] ?: "."}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
val matcher = runCatching { FileSystems.getDefault().getPathMatcher("glob:$pattern") }
|
||||
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid glob pattern: ${it.message}", recoverable = true) }
|
||||
.getOrElse {
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Invalid glob pattern: ${it.message}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
|
||||
val hits = ArrayList<String>()
|
||||
var truncated = false
|
||||
@@ -147,16 +165,36 @@ class GrepTool(
|
||||
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
|
||||
}
|
||||
val patternStr = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() }
|
||||
?: return@withContext ToolResult.Failure(request.invocationId, "grep requires a non-empty 'pattern'", recoverable = true)
|
||||
?: return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"grep requires a non-empty 'pattern'",
|
||||
recoverable = true,
|
||||
)
|
||||
val regex = runCatching { Regex(patternStr) }
|
||||
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid regex: ${it.message}", recoverable = true) }
|
||||
.getOrElse {
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Invalid regex: ${it.message}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
val base = resolveSearchPath(request, workingDir)
|
||||
if (!Files.isDirectory(base)) {
|
||||
return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true)
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Not a directory: ${request.parameters["path"] ?: "."}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
val fileFilter = (request.parameters["glob"] as? String)?.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||
.getOrElse { e -> return@withContext ToolResult.Failure(request.invocationId, "Invalid glob: ${e.message}", recoverable = true) }
|
||||
.getOrElse { e ->
|
||||
return@withContext ToolResult.Failure(
|
||||
request.invocationId,
|
||||
"Invalid glob: ${e.message}",
|
||||
recoverable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val hits = ArrayList<String>()
|
||||
@@ -169,7 +207,7 @@ class GrepTool(
|
||||
if (text != null && !text.contains('\u0000')) {
|
||||
text.lineSequence().forEachIndexed { idx, line ->
|
||||
if (!truncated && regex.containsMatchIn(line)) {
|
||||
hits += "$rel:${idx + 1}: ${line.trim().take(200)}"
|
||||
hits += "$rel:${idx + 1}: ${line.trim().take(GREP_LINE_PREVIEW_LENGTH)}"
|
||||
if (hits.size >= MAX_RESULTS) truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@ class WorkspaceSearchToolsTest {
|
||||
fun `grep respects an optional file glob and prunes gitignore`(): Unit = runBlocking {
|
||||
val root = fixture()
|
||||
val tool = GrepTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))) as ToolResult.Success).output
|
||||
val req = request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))
|
||||
val out = (tool.execute(req) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/hooks/useAuth.ts:"), out)
|
||||
assertTrue(out.contains("src/main.ts:"), out)
|
||||
assertFalse(out.contains("node_modules"), out) // gitignored
|
||||
|
||||
+12
-3
@@ -119,10 +119,19 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
}
|
||||
return null
|
||||
}
|
||||
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
|
||||
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.")
|
||||
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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-11
@@ -75,7 +75,10 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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(
|
||||
"description",
|
||||
"refs (or 0-based indices) of tasks in THIS batch that must finish first.",
|
||||
)
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) })
|
||||
@@ -123,7 +126,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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 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)
|
||||
@@ -140,7 +145,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
/** 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) }
|
||||
deps.forEach { j ->
|
||||
service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
|
||||
}
|
||||
}
|
||||
if (parentTask != null) {
|
||||
children.forEach { child ->
|
||||
@@ -156,15 +163,26 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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")
|
||||
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? {
|
||||
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 (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.")
|
||||
@@ -175,8 +193,14 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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.")
|
||||
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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +218,8 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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."
|
||||
"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
|
||||
@@ -230,7 +255,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
(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()
|
||||
(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) =
|
||||
@@ -253,7 +280,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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.")
|
||||
?: 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)
|
||||
}
|
||||
|
||||
+7
-2
@@ -35,7 +35,10 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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).") }
|
||||
putJsonObject("limit") {
|
||||
put("type", "integer")
|
||||
put("description", "Max results (default $DEFAULT_LIMIT).")
|
||||
}
|
||||
}
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
@@ -50,7 +53,9 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
val output = if (ready.isEmpty()) {
|
||||
"no ready tasks"
|
||||
} else {
|
||||
ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
|
||||
ready.joinToString("\n") {
|
||||
"${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd()
|
||||
}
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
|
||||
+11
-3
@@ -35,12 +35,18 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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("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).") }
|
||||
putJsonObject("limit") {
|
||||
put("type", "integer")
|
||||
put("description", "Max results (default $DEFAULT_LIMIT).")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("query")) })
|
||||
}
|
||||
@@ -61,7 +67,9 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
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() }
|
||||
hits.joinToString("\n") {
|
||||
"${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd()
|
||||
}
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
|
||||
+20
-5
@@ -69,23 +69,35 @@ class TaskUpdateTool(
|
||||
}
|
||||
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_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.")
|
||||
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.")
|
||||
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(
|
||||
"description",
|
||||
"Required when force=true: why the override is justified. Recorded as a note on the task.",
|
||||
)
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
@@ -159,7 +171,10 @@ class TaskUpdateTool(
|
||||
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.")
|
||||
return fail(
|
||||
request,
|
||||
"Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -88,7 +88,7 @@ class WebFetchTool(
|
||||
val contentType = response.headers[HttpHeaders.ContentType]
|
||||
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
|
||||
when {
|
||||
response.status.value in 300..399 ->
|
||||
response.status.value in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX ->
|
||||
fail(
|
||||
invocationId,
|
||||
"URL redirected (HTTP ${response.status.value}) to " +
|
||||
@@ -159,5 +159,7 @@ class WebFetchTool(
|
||||
companion object {
|
||||
const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake
|
||||
private const val READ_CHUNK = 8_192
|
||||
private const val HTTP_REDIRECT_MIN = 300
|
||||
private const val HTTP_REDIRECT_MAX = 399
|
||||
}
|
||||
}
|
||||
|
||||
+39
-10
@@ -94,7 +94,10 @@ class TaskToolsTest {
|
||||
// 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.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.
|
||||
@@ -122,12 +125,14 @@ class TaskToolsTest {
|
||||
|
||||
@Test
|
||||
fun `task_decompose rejects a dependency cycle`() = runBlocking {
|
||||
val cyclicTasks = """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},""" +
|
||||
"""{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]"""
|
||||
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"]}]""",
|
||||
"tasks" to cyclicTasks,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -168,7 +173,8 @@ class TaskToolsTest {
|
||||
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")))
|
||||
val forcedArgs = dup + ("force" to true) + ("force_reason" to "intentional rebuild")
|
||||
val forced = decompose.execute(request("task_decompose", forcedArgs))
|
||||
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]") })
|
||||
@@ -254,8 +260,15 @@ class TaskToolsTest {
|
||||
|
||||
@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")))
|
||||
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)
|
||||
@@ -288,13 +301,18 @@ class TaskToolsTest {
|
||||
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")))
|
||||
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)),
|
||||
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"))
|
||||
@@ -332,7 +350,9 @@ class TaskToolsTest {
|
||||
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)))
|
||||
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)
|
||||
@@ -341,12 +361,21 @@ class TaskToolsTest {
|
||||
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"),
|
||||
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") })
|
||||
assertTrue(
|
||||
service.getTask(TaskId("auth-2"))!!.state.notes.any {
|
||||
it.body.contains("[force]") && it.body.contains("prep work")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user