diff --git a/deder-common/src/ba/sake/deder/DederProjectInternals.scala b/deder-common/src/ba/sake/deder/DederProjectInternals.scala index d8dd2dd3..6342e475 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, @@ -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 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 abbbb95c..0b33079f 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} @@ -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, @@ -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") @@ -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 @@ -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 + } + } + + 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() 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 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")