From e3762d43aebbaeee84d6eb84c3c796ad292e6b33 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 20 Jun 2026 00:08:44 +0200 Subject: [PATCH 1/4] feat: add invoke() to DederProjectInternals for dynamic task invocation by plugins Add TaskInvokeOutcome case class, CallerType.Plugin enum variant, and invoke(taskName, moduleIds, args) method to DederProjectInternals trait. Implementation in DederProjectInternalsImpl delegates to DederProjectState through a direct reference (wired in ServerMain), using the same execution pipeline as CLI requests (planning, locking, caching, cancellation). Motivation: plugins (primarily web dashboard) can trigger builds dynamically - just pass task name, module IDs, and args like CLI does. --- .../ba/sake/deder/DederProjectInternals.scala | 25 ++++++++++- .../deder/DederProjectInternalsImpl.scala | 43 +++++++++++++++++++ server/src/ba/sake/deder/ServerMain.scala | 1 + 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/deder-common/src/ba/sake/deder/DederProjectInternals.scala b/deder-common/src/ba/sake/deder/DederProjectInternals.scala index d8dd2dd3..83c00c9d 100644 --- a/deder-common/src/ba/sake/deder/DederProjectInternals.scala +++ b/deder-common/src/ba/sake/deder/DederProjectInternals.scala @@ -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, @@ -121,6 +121,21 @@ trait DederProjectInternals: /** Rich status snapshots for all in-flight requests. */ def allRequestStatuses: Seq[RequestStatus] + /** Execute a named task on the given modules and block until completion. + * Goes through the same execution pipeline as CLI requests (planning, locking, caching). + * Returns one outcome per module. + * + * @param taskName e.g. "compile", "test", "run" + * @param moduleIds module IDs to execute on; empty = all modules + * @param args forwarded to tasks as `ctx.args` (e.g. test filter args) + * @return per-module outcomes + */ + def invoke( + taskName: String, + moduleIds: Seq[String], + args: Seq[String] + ): Seq[TaskInvokeOutcome] + /** Purges all registered in-memory caches (Scaffeine caches, internals history, completed * BSP in-flight compilation entries) and suggests a GC to the JVM. * Waits up to 10s for in-flight requests to drain; returns a zeroed result if busy. */ @@ -132,3 +147,11 @@ 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 diff --git a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala index abbbb95c..3d8f2acb 100644 --- a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala +++ b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala @@ -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} @@ -58,6 +59,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 @@ -101,6 +105,45 @@ class DederProjectInternalsImpl private ( override def purgeInMemoryCaches(): PurgeCachesResult = purgeCachesFn() + override def invoke( + taskName: String, + moduleIds: Seq[String], + args: Seq[String] + ): Seq[TaskInvokeOutcome] = { + val state = projectState.getOrElse( + throw new IllegalStateException("Server not initialized — projectState reference not wired yet")) + val requestId = UUID.randomUUID().toString + val noopLogger = ServerNotificationsLogger(_ => ()) + + // 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 + } + } + + val results = state.executeTasks( + requestId, CallerType.Plugin, resolvedIds, taskName, args, + watch = false, noopLogger, useLastGood = false) + + 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) + } + } + private[deder] def clearHistory(): Int = { val size = history.size() history.clear() diff --git a/server/src/ba/sake/deder/ServerMain.scala b/server/src/ba/sake/deder/ServerMain.scala index 11189f8c..d51e4418 100644 --- a/server/src/ba/sake/deder/ServerMain.scala +++ b/server/src/ba/sake/deder/ServerMain.scala @@ -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 From ecd5b53be4de949697e9a4cb64eac5b680fe8ba4 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 20 Jun 2026 13:00:09 +0200 Subject: [PATCH 2/4] feat: add TaskInvokerApi with notification callback and plaintext result summary - New TaskInvokerApi trait in plugin-api: invoke(taskName, moduleIds, args, onNotification) returns TaskInvokeResult with per-module outcomes + rendered plaintext summary - TaskInvokeResult wraps Seq[TaskInvokeOutcome] + renderedSummary: Option[String] - Passed to plugins via PluginInitParams.taskInvoker - DederProjectInternalsImpl implements TaskInvokerApi - Refactored invoke into shared invokeInternal that handles both the simple DederProjectInternals.invoke and the richer TaskInvokerApi.invoke - Cross-module summary renders using Summarizable + PlainTextWritable typeclass (same output as CLI) --- .../ba/sake/deder/DederProjectInternals.scala | 8 +++ .../src/ba/sake/deder/DederPluginApi.scala | 16 +++++ .../deder/DederProjectInternalsImpl.scala | 59 +++++++++++++++++-- .../ba/sake/deder/plugin/PluginLoader.scala | 2 +- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/deder-common/src/ba/sake/deder/DederProjectInternals.scala b/deder-common/src/ba/sake/deder/DederProjectInternals.scala index 83c00c9d..db8c4cbc 100644 --- a/deder-common/src/ba/sake/deder/DederProjectInternals.scala +++ b/deder-common/src/ba/sake/deder/DederProjectInternals.scala @@ -155,3 +155,11 @@ case class TaskInvokeOutcome( 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 diff --git a/plugin-api/src/ba/sake/deder/DederPluginApi.scala b/plugin-api/src/ba/sake/deder/DederPluginApi.scala index 2631f482..d4bfa7c3 100644 --- a/plugin-api/src/ba/sake/deder/DederPluginApi.scala +++ b/plugin-api/src/ba/sake/deder/DederPluginApi.scala @@ -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.). */ @@ -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. diff --git a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala index 3d8f2acb..8cd3c803 100644 --- a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala +++ b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala @@ -10,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, @@ -21,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") @@ -110,10 +111,31 @@ class DederProjectInternalsImpl private ( moduleIds: Seq[String], args: Seq[String] ): Seq[TaskInvokeOutcome] = { + val result = invokeInternal(taskName, moduleIds, args, _ => ()) + result.outcomes + } + + // TaskInvokerApi — richer invoke with notification callback and rendered summary + def invoke( + taskName: String, + moduleIds: Seq[String], + args: Seq[String], + onNotification: ServerNotification => Unit + ): TaskInvokeResult = + invokeInternal(taskName, moduleIds, args, onNotification) + + /** Shared implementation — resolves modules, executes tasks, maps outcomes, renders summary. */ + private def invokeInternal( + 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 noopLogger = ServerNotificationsLogger(_ => ()) + val logger = ServerNotificationsLogger(onNotification) + val execStartNanos = System.nanoTime() // Resolve wildcards: empty moduleIds → all modules val resolvedIds = state.readState(useLastGood = false) match { @@ -129,9 +151,10 @@ class DederProjectInternalsImpl private ( val results = state.executeTasks( requestId, CallerType.Plugin, resolvedIds, taskName, args, - watch = false, noopLogger, useLastGood = false) + watch = false, logger, useLastGood = false) - results.map { + // 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) => @@ -142,6 +165,34 @@ class DederProjectInternalsImpl private ( 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 = { diff --git a/server/src/ba/sake/deder/plugin/PluginLoader.scala b/server/src/ba/sake/deder/plugin/PluginLoader.scala index ac8089e3..047d2316 100644 --- a/server/src/ba/sake/deder/plugin/PluginLoader.scala +++ b/server/src/ba/sake/deder/plugin/PluginLoader.scala @@ -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") From 23f36b3a4295f7c7ee8ae8ab04dd1fde32ad045c Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 20 Jun 2026 13:15:47 +0200 Subject: [PATCH 3/4] cleanup: remove redundant invokeInternal, delegate simple invoke directly to TaskInvokerApi version --- .../sake/deder/DederProjectInternalsImpl.scala | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala index 8cd3c803..3e271fd7 100644 --- a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala +++ b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala @@ -106,14 +106,13 @@ class DederProjectInternalsImpl private ( override def purgeInMemoryCaches(): PurgeCachesResult = purgeCachesFn() + // DederProjectInternals — simple invoke, delegates to TaskInvokerApi version override def invoke( taskName: String, moduleIds: Seq[String], args: Seq[String] - ): Seq[TaskInvokeOutcome] = { - val result = invokeInternal(taskName, moduleIds, args, _ => ()) - result.outcomes - } + ): Seq[TaskInvokeOutcome] = + invoke(taskName, moduleIds, args, _ => ()).outcomes // TaskInvokerApi — richer invoke with notification callback and rendered summary def invoke( @@ -121,15 +120,6 @@ class DederProjectInternalsImpl private ( moduleIds: Seq[String], args: Seq[String], onNotification: ServerNotification => Unit - ): TaskInvokeResult = - invokeInternal(taskName, moduleIds, args, onNotification) - - /** Shared implementation — resolves modules, executes tasks, maps outcomes, renders summary. */ - private def invokeInternal( - 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")) From 0e8b507e7792dedb2a08d3db5b23f78f1d72c09a Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 20 Jun 2026 13:18:15 +0200 Subject: [PATCH 4/4] cleanup: remove duplicate invoke from DederProjectInternals trait, keep only TaskInvokerApi.invoke --- .../src/ba/sake/deder/DederProjectInternals.scala | 15 --------------- .../ba/sake/deder/DederProjectInternalsImpl.scala | 10 +--------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/deder-common/src/ba/sake/deder/DederProjectInternals.scala b/deder-common/src/ba/sake/deder/DederProjectInternals.scala index db8c4cbc..6342e475 100644 --- a/deder-common/src/ba/sake/deder/DederProjectInternals.scala +++ b/deder-common/src/ba/sake/deder/DederProjectInternals.scala @@ -121,21 +121,6 @@ trait DederProjectInternals: /** Rich status snapshots for all in-flight requests. */ def allRequestStatuses: Seq[RequestStatus] - /** Execute a named task on the given modules and block until completion. - * Goes through the same execution pipeline as CLI requests (planning, locking, caching). - * Returns one outcome per module. - * - * @param taskName e.g. "compile", "test", "run" - * @param moduleIds module IDs to execute on; empty = all modules - * @param args forwarded to tasks as `ctx.args` (e.g. test filter args) - * @return per-module outcomes - */ - def invoke( - taskName: String, - moduleIds: Seq[String], - args: Seq[String] - ): Seq[TaskInvokeOutcome] - /** Purges all registered in-memory caches (Scaffeine caches, internals history, completed * BSP in-flight compilation entries) and suggests a GC to the JVM. * Waits up to 10s for in-flight requests to drain; returns a zeroed result if busy. */ diff --git a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala index 3e271fd7..0b33079f 100644 --- a/server/src/ba/sake/deder/DederProjectInternalsImpl.scala +++ b/server/src/ba/sake/deder/DederProjectInternalsImpl.scala @@ -106,15 +106,7 @@ class DederProjectInternalsImpl private ( override def purgeInMemoryCaches(): PurgeCachesResult = purgeCachesFn() - // DederProjectInternals — simple invoke, delegates to TaskInvokerApi version - override def invoke( - taskName: String, - moduleIds: Seq[String], - args: Seq[String] - ): Seq[TaskInvokeOutcome] = - invoke(taskName, moduleIds, args, _ => ()).outcomes - - // TaskInvokerApi — richer invoke with notification callback and rendered summary + // TaskInvokerApi def invoke( taskName: String, moduleIds: Seq[String],