Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion deder-common/src/ba/sake/deder/DederProjectInternals.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import java.time.{Duration, Instant}
import ba.sake.tupson.JsonRW

enum CallerType:
case Cli, Bsp
case Cli, Bsp, Plugin

case class LiveRequest(
requestId: String,
Expand Down Expand Up @@ -132,3 +132,19 @@ case class PurgeCachesResult(
historyEntriesRemoved: Int,
gcSuggested: Boolean
)

/** Per-module outcome of a dynamic task invocation via [[DederProjectInternals.invoke]]. */
case class TaskInvokeOutcome(
moduleId: String,
success: Boolean,
error: Option[String],
fromCache: Boolean
) derives JsonRW

/** Aggregated result of a dynamic task invocation, including per-module outcomes
* and a plaintext cross-module summary (same as CLI output).
*/
case class TaskInvokeResult(
outcomes: Seq[TaskInvokeOutcome],
renderedSummary: Option[String]
) derives JsonRW
16 changes: 16 additions & 0 deletions plugin-api/src/ba/sake/deder/DederPluginApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ trait DederPluginApi {
* Access to the stable Scala Native linking tasks that plugins may depend on.
* @param internals
* Access to server introspection (request metrics, uptime, etc.).
* @param taskInvoker
* Dynamic task invocation — trigger builds by name at runtime (like `deder exec`).
* Blocking call with notification callback and plaintext result summary.
* @param project
* The full parsed DederProject config (modules, server properties, repositories, etc.).
*/
Expand All @@ -43,9 +46,22 @@ case class PluginInitParams(
sjsTasks: ScalaJsTasksApi,
snTasks: ScalaNativeTasksApi,
internals: DederProjectInternals,
taskInvoker: TaskInvokerApi,
project: DederProject
)

/** Dynamic task invocation API for plugins. Plugins can trigger builds by task name at runtime,
* the same way `deder exec` does from the CLI. Blocking call — returns when all tasks complete.
*/
trait TaskInvokerApi {
def invoke(
taskName: String,
moduleIds: Seq[String],
args: Seq[String],
onNotification: ServerNotification => Unit
): TaskInvokeResult
}

/** Typed access to all built-in task references that plugins may depend on. This includes core
* configuration/build tasks, run/test/repl entrypoints, publishing, GraalVM native-image,
* and internal implementation tasks.
Expand Down
78 changes: 77 additions & 1 deletion server/src/ba/sake/deder/DederProjectInternalsImpl.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ba.sake.deder

import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedDeque
import java.util.concurrent.atomic.{AtomicInteger, AtomicLong, AtomicReference}
Expand All @@ -9,6 +10,7 @@ import scala.jdk.CollectionConverters.*
import io.opentelemetry.api.metrics.{LongCounter, LongHistogram, Meter}
import io.opentelemetry.api.common.{Attributes, AttributeKey}
import com.typesafe.scalalogging.StrictLogging
import ba.sake.tupson.JsonRW

class DederProjectInternalsImpl private (
private val startTime: Instant,
Expand All @@ -20,7 +22,7 @@ class DederProjectInternalsImpl private (
private val totalErrCount: AtomicLong,
private val meter: Meter,
private val cacheStatsRegistry: CacheStatsRegistry
) extends DederProjectInternals, StrictLogging:
) extends DederProjectInternals, TaskInvokerApi, StrictLogging:

logger.info(s"DederProjectInternals initialized")

Expand Down Expand Up @@ -58,6 +60,9 @@ class DederProjectInternalsImpl private (
// Delegated purge function — wired after construction by ServerMain
@volatile private[deder] var purgeCachesFn: () => PurgeCachesResult = () => PurgeCachesResult(0, 0, 0, false)

// Direct reference to projectState — wired after construction by ServerMain
@volatile private[deder] var projectState: Option[DederProjectState] = None

override def currentRequests: Seq[LiveRequest] =
currentReqs.values().asScala.toSeq

Expand Down Expand Up @@ -101,6 +106,77 @@ class DederProjectInternalsImpl private (
override def purgeInMemoryCaches(): PurgeCachesResult =
purgeCachesFn()

// TaskInvokerApi
def invoke(
taskName: String,
moduleIds: Seq[String],
args: Seq[String],
onNotification: ServerNotification => Unit
): TaskInvokeResult = {
val state = projectState.getOrElse(
throw new IllegalStateException("Server not initialized — projectState reference not wired yet"))
val requestId = UUID.randomUUID().toString
val logger = ServerNotificationsLogger(onNotification)
val execStartNanos = System.nanoTime()

// Resolve wildcards: empty moduleIds → all modules
val resolvedIds = state.readState(useLastGood = false) match {
case Left(_) => Seq.empty
case Right(s) =>
val allIds = s.tasksResolver.allModules.map(_.id)
if moduleIds.isEmpty then allIds
else WildcardUtils.getMatchesOrRecommendations(allIds, moduleIds) match {
case Left(_) => Seq.empty
case Right(ids) => ids
}
}
Comment on lines +122 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silent failure when module resolution fails.

When readState returns Left (parse/load error) or WildcardUtils.getMatchesOrRecommendations returns Left (no matches), resolvedIds becomes Seq.empty. This silently produces an empty TaskInvokeResult with no indication to the plugin caller that module resolution failed.

Consider propagating the error or returning a distinguished failure outcome so plugins can diagnose misconfigurations.

Possible approach
     val resolvedIds = state.readState(useLastGood = false) match {
-      case Left(_) => Seq.empty
+      case Left(err) =>
+        return TaskInvokeResult(Seq.empty, Some(s"Failed to read project state: $err"))
       case Right(s) =>
         val allIds = s.tasksResolver.allModules.map(_.id)
         if moduleIds.isEmpty then allIds
         else WildcardUtils.getMatchesOrRecommendations(allIds, moduleIds) match {
-          case Left(_)    => Seq.empty
+          case Left(suggestions) =>
+            val hint = if suggestions.nonEmpty then s" Did you mean: ${suggestions.mkString(", ")}?" else ""
+            return TaskInvokeResult(Seq.empty, Some(s"No modules matched: ${moduleIds.mkString(", ")}.$hint"))
           case Right(ids) => ids
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/ba/sake/deder/DederProjectInternalsImpl.scala` around lines 140 -
150, The module resolution logic in the wildcard matching section silently
converts both readState and WildcardUtils.getMatchesOrRecommendations errors
into empty sequences, hiding failures from callers. Instead of returning
Seq.empty for both the Left case in readState and the Left case in
WildcardUtils.getMatchesOrRecommendations, propagate these errors through the
function's return type (likely TaskInvokeResult) so that plugins can receive
distinguished failure outcomes indicating why module resolution failed. This
requires changing the return type from a simple resolved Ids sequence to a
Result type that can communicate both success and failure states to the caller.


val results = state.executeTasks(
requestId, CallerType.Plugin, resolvedIds, taskName, args,
watch = false, logger, useLastGood = false)

// Map to public outcomes
val outcomes = results.map {
case TaskExecResult.Success(ti, _, _, fromCache) =>
TaskInvokeOutcome(ti.moduleId, success = true, None, fromCache)
case TaskExecResult.Failure(ti, error) =>
TaskInvokeOutcome(ti.moduleId, success = false, Some(error), fromCache = false)
case TaskExecResult.Skipped(ti, because) =>
TaskInvokeOutcome(ti.moduleId, success = false,
Some(s"skipped — ${because.taskInstance.moduleId} failed"), fromCache = false)
case TaskExecResult.Cancelled(ti, message) =>
TaskInvokeOutcome(ti.moduleId, success = false, Some(message), fromCache = false)
}

// Render cross-module plaintext summary (same as CLI output)
val totalDuration = java.time.Duration.ofNanos(System.nanoTime() - execStartNanos)
val renderedSummary = if results.nonEmpty then {
val successes = results.collect { case s: TaskExecResult.Success => s }
val failures = results.collect {
case f: TaskExecResult.Failure => ModuleFailure(f.taskInstance.moduleId, f.error, None)
case s: TaskExecResult.Skipped =>
ModuleFailure(s.taskInstance.moduleId,
s"skipped — ${s.because.taskInstance.moduleId} failed",
Some(s.because.taskInstance.moduleId))
case c: TaskExecResult.Cancelled => ModuleFailure(c.taskInstance.moduleId, c.message, None)
}
if successes.nonEmpty then {
val task = successes.head.taskInstance.task
val moduleResults = successes.sortBy(_.taskInstance.moduleId).map(r => r.taskInstance.moduleId -> r.value)
val summary = task.summarizeValueUnsafe(moduleResults, failures, totalDuration)
given PlainTextWritable[Any] = task.summarizable.plainTextW.asInstanceOf[PlainTextWritable[Any]]
Some(OutputFormat.render[Any](summary, OutputFormat.PlainText)(
using task.summarizable.jsonRW.asInstanceOf[JsonRW[Any]],
task.summarizable.plainTextW.asInstanceOf[PlainTextWritable[Any]],
task.summarizable.dotW.asInstanceOf[DotWritable[Any]],
task.summarizable.mermaidW.asInstanceOf[MermaidWritable[Any]]
))
} else None
} else None

TaskInvokeResult(outcomes, renderedSummary)
}

private[deder] def clearHistory(): Int = {
val size = history.size()
history.clear()
Expand Down
1 change: 1 addition & 0 deletions server/src/ba/sake/deder/ServerMain.scala
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ object ServerMain extends StrictLogging {
)
internals.cancelFn = projectState.cancelRequest
internals.purgeCachesFn = () => projectState.purgeInMemoryCachesImpl()
internals.projectState = Some(projectState)

debounceThread = Thread.ofVirtual().name("watch-debounce").start(() => {
var running = true
Expand Down
2 changes: 1 addition & 1 deletion server/src/ba/sake/deder/plugin/PluginLoader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ class PluginLoader(
case Some(plugin) =>
logger.debug(s"Loaded plugin '$pluginId'")
logger.debug(s"Plugin config Pkl text: $configText")
val params = PluginInitParams(configText, coreTasksApi, scalaJsTasksApi, scalaNativeTasksApi, internals, dederProject)
val params = PluginInitParams(configText, coreTasksApi, scalaJsTasksApi, scalaNativeTasksApi, internals, internals.asInstanceOf[TaskInvokerApi], dederProject)
plugin.init(params) match {
case Left(err) =>
logger.warn(s"Failed to init plugin '$pluginId': $err")
Expand Down
Loading