Skip to content

A background service responsible for executing and canceling long-running developer tasks.

License

Notifications You must be signed in to change notification settings

block/dev-daemon

DevDaemon

Maven Central

A background service for executing and managing long-running developer tasks. DevDaemon runs as a local HTTP server, scheduling and controlling jobs via cron or on-demand HTTP requests.

DevDaemon ships as a library (core) that you embed in your own project. A DaemonPlugin SPI lets you wire in company-specific services (feature flags, analytics, roles, VPN checks) while sensible defaults keep it working out of the box.

Learn More

Features

  • On-demand job execution — trigger any registered job via POST /execute/{job-id}
  • Job cancellation — cancel a running job via POST /cancel/{job-id}
  • Cron scheduling — jobs can declare a Quartz cron expression to run periodically
  • Plugin architecture — implement DaemonPlugin and register it via ServiceLoader to override feature flags, analytics, roles, and more
  • System sleep awareness — missed jobs are automatically rescheduled after the machine wakes
  • Lock files — prevents duplicate concurrent runs of the same job

Project Structure

core/                   # The DevDaemon library (published to Maven Central)
  default-configs/      # Built-in job configuration JSON files
  default-jobs/         # Built-in job scripts
  src/                  # Library source code
sample-dev-daemon/      # A minimal sample project that depends on core
  sample-configs/       # Sample job configuration JSON files
  sample-jobs/          # Sample job scripts
  src/                  # Sample application source code

Getting Started

Requirements

  • JDK 17+
  • Gradle (wrapper included)

Build

./gradlew build

Run the Sample

./gradlew :sample-dev-daemon:run

The sample daemon starts on port 2001 by default (configurable via the SAMPLE_DEV_DAEMON_PORT environment variable). The sample-dev-daemon module serves as a reference implementation - use it as a starting point for your own daemon project.

Job Execution

The sample daemon has two jobs defined (test.json and test-cron.json). Both run the same test_script.sh which runs for 10 seconds simulating a long-running asynchronous task.

  • test-cron - this job is scheduled on daemon start and runs automatically every 60 seconds, as defined by its JSON config cronSchedule
  • test can be executed manually with the following command in terminal curl -X POST "http://localhost:2001/execute/test?source=OnManual"

Job Cancellation

While the sample daemon test job is running, you can cancel it with curl -X POST "http://localhost:2001/cancel/test?source=OnManual"

Using the Library

The core library is published to Maven Central: https://central.sonatype.com/artifact/xyz.block.devdaemon/core You can also use sample-dev-daemon as a copy-ready template for your own daemon integration.

Add the core module as a dependency:

dependencies {
    implementation("xyz.block.devdaemon:core:<version>")
}

Then call startDevDaemon() from your application's entry point:

import com.squareup.dev.daemon.startDevDaemon

fun main() {
    startDevDaemon()
}

Without a custom plugin, DevDaemon will start with default (no-op) implementations for all services and listen on port 2964 (configurable via DEV_DAEMON_PORT).

Creating a Plugin

Implement the DaemonPlugin interface to customize behavior:

class MyDaemonPlugin : DaemonPlugin {
    override fun portEnvVar(): String = "MY_DAEMON_PORT"
    override fun defaultPort(): Int = 3000

    override fun modules(): List<Module> = listOf(
        genericModule {
            single<FeatureFlagService> { MyFeatureFlagService() }
            single<AnalyticsService> { MyAnalyticsService() }
        }
    )
}

Register it via the standard ServiceLoader mechanism by creating:

src/main/resources/META-INF/services/com.squareup.dev.daemon.DaemonPlugin

with your fully-qualified class name as the file's content.

Overridable Services

Service Description
FeatureFlagService Provides job definitions and listens for configuration changes
AnalyticsService Reports job execution events
RolesService Determines which roles the current user has
VpnCheckService Checks VPN connectivity before job execution
JobStatusRegistry Tracks and reports job status

Job Configuration

In the sample daemon, jobs are defined as local JSON files in sample-dev-daemon/sample-configs/. In production, it is best to decouple job configurations from the daemon and host them in a remote feature flag provider (e.g., LaunchDarkly) which serves JSON feature flags to a custom FeatureFlagService implementation. Decoupling the job configurations from the daemon allows adding new job configurations and making changes (e.g. updating cron schedule, enable/disable job, etc) to how a job is executed on-the-fly (via FeatureFlagService.registerFeatureFlagChangeListener), without a new daemon release.

Each file describes a single job:

{
  "cronSchedule": null,
  "enabled": "true",
  "executeIn": "",
  "id": "my-job",
  "job": "my_script.sh",
  "jobDirectory": "path/to/jobs",
  "registryRoles": ["dev"]
}
Field Description
id Unique identifier for the job
enabled Whether the job is active
job Script or executable to run
jobDirectory Directory containing the job script
executeIn Working directory for execution. Use an empty string for default behavior, or a remote Git URL of a cloned repository.
cronSchedule Quartz cron expression for periodic scheduling (null for on-demand only)
registryRoles Roles required to run this job

Job Scripts

Each job configuration points to a script or executable via the job and jobDirectory fields. DevDaemon resolves the full path by combining these two values and runs the script as a child process.

Scripts receive any configured arguments plus a --source= flag indicating how the job was triggered (e.g., OnManual). Standard output and standard error are merged and forwarded to the daemon's logger.

A minimal job script looks like this:

#!/bin/bash

echo "Starting my-job..."
# do work here
echo "Done!"

In the sample daemon, job scripts are placed in the sample-dev-daemon/sample-jobs/ directory. In production, it is best to auto-install scripts to a predefined location (e.g., ~/devdaemon/release-jobs/) on developer machines, independent of the DevDaemon installation, and provide this path as the job configuration's jobDirectory. Place the script in the directory referenced by jobDirectory in the job configuration and ensure it is executable (chmod +x).

At Block, releasing a new daemon version and auto-installing it on developer machines is a lengthy process, while releasing updated job scripts is much quicker. Once the daemon implementation is stable, almost all changes will be in job configurations and scripts. Decoupling the two allows you to iterate on scripts without a new daemon release.

API

Method Endpoint Description
POST /execute/{job-id}?source=OnManual Execute a job by its ID and source parameter to indicate trigger
POST /cancel/{job-id}?source=OnManual Cancel a running job by its ID and source parameter to indicate trigger

Possible Trigger Sources

  • Git Hooks
  • IDE Plugins (mdx-daemon-plugin)
  • Cron
  • OnPostCheckoutGitHook
  • OnPostMergeGitHook
  • OnPostRebaseGitHook
  • OnSyncCompleteWithSuccess
  • OnSyncStart
  • OnIdeBuildStart
  • OnCommandLineBuildStart
  • OnCronSchedule
  • OnManual

Deploy at Scale

Building the macOS App

The app is built by running:

./gradlew packageNativeApp --no-configuration-cache

This performs the following steps:

  1. Builds a fat JAR — Compiles the Kotlin source and bundles all runtime dependencies into a single my-daemon-<version>.jar
  2. Runs jpackage — Uses the JDK 17+ built-in jpackage tool to wrap the fat JAR into a native MyDaemon.app bundle, including an embedded JVM runtime so end users don't need Java installed
  3. Patches Info.plist — Adds LSUIElement = true to the app's Info.plist, which tells macOS to run it as a background process with no Dock icon
  4. Code signs — Signs the .app with codesign using a Developer ID certificate on CI, or ad-hoc signing locally
  5. Zips for distribution — Archives the signed .app into my-daemon.zip

The final artifact is a self-contained macOS app at build/jpackage/MyDaemon.app that can be dropped into any install location and launched — no external JVM required.

This is already set up in :sample-dev-daemon as a reference. Run ./gradlew :sample-dev-daemon:packageNativeApp --no-configuration-cache and find the SampleDevDaemon.app in the sample-dev-daemon/build/jpackage/ directory.

Installation & Launch Configuration

Application Location

The app is expected to be installed at ~/Applications/MyDaemon.app (or your preferred location). The actual binary lives at Contents/MacOS/MyDaemon inside the app bundle.

LaunchAgent Setup

The daemon runs as a macOS LaunchAgent (per-user, not system-wide). Create a plist at:

~/Library/LaunchAgents/com.company.mydaemon.plist

Reference Plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.company.mydaemon</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/sh</string>
        <string>-c</string>
        <string>lsof -ti tcp:2001 | xargs kill -9; sleep 2; ~/Applications/MyDaemon.app/Contents/MacOS/MyDaemon</string>
    </array>

    <key>EnvironmentVariables</key>
    <dict>
        <key>JAVA_HOME</key>
        <string>~/Library/Java/JavaVirtualMachines/azul-17-ARM64/Contents/Home</string>
        <key>ANDROID_HOME</key>
        <string>~/Library/Android/sdk</string>
        <key>ANDROID_SDK_ROOT</key>
        <string>~/Library/Android/sdk</string>
        <key>TERM</key>
        <string>xterm-256color</string>
        <key>LAUNCH_DARKLY_SDK_KEY</key>
        <string>YOUR_LAUNCHDARKLY_SDK_KEY</string>
        <key>MY_DAEMON_PORT</key>
        <string>2001</string>
    </dict>

    <key>KeepAlive</key>
    <true/>
    <key>RunAtLoad</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/tmp/my-daemon-output.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/my-daemon-error.log</string>
</dict>
</plist>
Key Purpose
Label Unique identifier for the agent
ProgramArguments Kills any existing process on the daemon port, then launches the app
EnvironmentVariables Runtime environment — update paths and keys for your setup
KeepAlive macOS automatically restarts the daemon if it exits
RunAtLoad Starts the daemon on login
StandardOutPath / StandardErrorPath Log file locations for debugging

Manual Load / Unload

# Load and start the daemon
launchctl load ~/Library/LaunchAgents/com.company.mydaemon.plist

# Stop and unload the daemon
launchctl unload ~/Library/LaunchAgents/com.company.mydaemon.plist

# Check if running
launchctl list | grep mydaemon

Deploying at Block

At Block, the daemon macOS app, job scripts, and LaunchAgent plist are auto-installed on developer machines by a separate infrastructure team using internal device management tooling. This means developers don't need to manually build, copy, or configure anything — the daemon is silently deployed, kept up to date, and launched automatically on login.

For this to work at scale at your company, you'll want a similar approach: use your organization's device management or software distribution system (e.g., Munki, Jamf, Chef, Puppet, or an internal equivalent) to package and push the .app bundle, job scripts, and LaunchAgent plist to developer machines. This keeps the rollout hands-free and ensures every machine runs the correct version.

License

Copyright 2026 Block, Inc.

Licensed under the Apache License, Version 2.0.
See LICENSE for details.

About

A background service responsible for executing and canceling long-running developer tasks.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors 2

  •  
  •