DeskAutomate - file-watch triggered automation Automation Desk product line, Vertical variant ============================================== Drop a PDF in ~/Inbox and it gets renamed, filed, and logged automatically. DeskAutomate is the event-triggered member of the Automation Desk line. The other tools in the line run an action when YOU tell them to. DeskAutomate is a long-running watcher: it fires rules when a file APPEARS or CHANGES, with no human in the loop. MacroDeck / ActionForge run a defined action on demand DeployForge / WinImageKit run a dependency graph on demand DeskAutomate runs rules when the filesystem changes HOW WATCHING WORKS (READ THIS FIRST) ------------------------------------ Watching is POLL-BASED. This is deliberate and it is a real limitation. DeskAutomate is built with the Go standard library only - no third-party dependencies at all. There is no fsnotify, no inotify (Linux), no FSEvents (macOS), and no ReadDirectoryChangesW (Windows) in this build. Instead, every --interval the tool takes a snapshot of each watched directory recording, for each regular file, the triple: (path, size, modification time) It diffs that snapshot against what it saw before: path not seen before -> event "created" path seen before, size or mtime moved -> event "modified" path seen before, size and mtime same -> nothing happens What this means in practice: * Detection latency is up to one poll interval. Default is 500ms. * A file that is created and deleted between two polls is never seen. * A file that is modified twice between two polls fires once. * A change that alters neither size nor mtime is invisible. Content is not hashed. * Polling costs one directory read per watched directory per interval. This is cheap for an inbox with hundreds of files and wasteful for a tree with hundreds of thousands. Native filesystem events are on the roadmap. INSTALL ------- Pre-built binaries are in dist/: dist/deskautomate-linux-amd64 dist/deskautomate-darwin-amd64 dist/deskautomate-darwin-arm64 dist/deskautomate-windows-amd64.exe Copy the one for your platform somewhere on your PATH and mark it executable (chmod +x). Or build from source - it needs nothing but a Go toolchain and no network access: go build -o deskautomate . COMMANDS -------- deskautomate rules --rules Validate a rules file and print what it will do. Exits 1 and lists every problem it found if the file is not usable. deskautomate watch --rules [flags] Poll the watched directories and fire matching rules. --interval poll interval, e.g. 500ms, 2s (default 500ms) --once do a single poll pass, then exit --apply actually perform the actions (default: dry run) --log JSONL log, one line per fired rule (default deskautomate.jsonl) --state handled-file state (default .state.json) deskautomate help (also -h and --help) The rules file may also be given positionally: deskautomate watch rules.json --once --apply Flags may appear after positional arguments; they are reordered before parsing, the same as in every other Techlosoft CLI. SAFETY: DRY RUN IS THE DEFAULT ------------------------------ Nothing is moved, copied, or executed unless you pass --apply. Without it the tool prints exactly what it WOULD do, marks each log line "dry_run": true, and touches nothing on disk. A dry run also does not write the state file, so it never "uses up" an event. Run it dry, read the output, then run the same command again with --apply and every pending event still fires. DeskAutomate never hard-deletes user data: * A move or copy NEVER overwrites an existing file at the destination. A colliding name gets a "-1", "-2", ... suffix (report.csv -> report-1.csv). * The only removal that ever happens is the source inode of a move, and only after the copy to the destination has been written and fsync'd - and only on cross-device moves, where os.Rename cannot be used. * Nothing is ever deleted from the destination directory. RULES FILE ---------- JSON. Either {"rules": [ ... ]} or a bare array of rule objects. {"rules": [ {"name": "file-pdfs", "watch_dir": "/home/me/Inbox", "match": "*.pdf", "action": "move", "dest": "/home/me/Documents/PDF", "debounce_ms": 750}, {"name": "archive-csv", "watch_dir": "/home/me/Inbox", "match": "*.csv", "action": "copy", "dest": "/home/me/Archive"}, {"name": "index-logs", "watch_dir": "/var/log/app", "match": "*.log", "action": "run", "command": "/usr/local/bin/index.sh"} ]} Fields: name Required. Unique. Used in log lines and in the state file, so renaming a rule makes it re-fire for files it already handled. watch_dir Required. A single directory. NOT recursive - subdirectories are not descended into and directories themselves never match. match Required. A shell glob in the style of Go's path/filepath.Match, tested against the file's BASE NAME only: *.pdf, invoice-*.csv, report-202?.txt. It is not a regular expression and ** is not a thing. A malformed glob is a validation error. action Required. One of "move", "copy", "run". dest Required for move and copy. A directory; it is created if it does not exist. It must NOT be inside watch_dir - that would make the rule re-trigger on its own output, so it is rejected at validation time. command Required for run. See below. debounce_ms Optional, default 0. See below. Only regular files are considered. Directories, symlinks, sockets, and FIFOs in a watched directory are skipped. THE "run" ACTION ---------------- The command string is split on whitespace, and the MATCHED FILE'S PATH IS APPENDED AS THE FINAL ARGUMENT. So this rule: "command": "/usr/local/bin/index.sh --quiet" runs, for a matched file /home/me/Inbox/app.log: /usr/local/bin/index.sh --quiet /home/me/Inbox/app.log The child process also receives two environment variables: DESKAUTOMATE_FILE the absolute path of the matched file DESKAUTOMATE_RULE the name of the rule that fired Because the command is split on whitespace, a program path or a fixed argument that CONTAINS A SPACE will not work. Put it in a shell script and point the rule at the script. There is no shell involved: no pipes, no redirection, no globbing, no variable expansion in the command string. Combined stdout+stderr is captured, truncated at 2000 characters, and stored in the log line along with the exit status. A nonzero exit is a failure. A command that runs longer than 60 seconds is killed and logged as a timeout. NOT FIRING TWICE: THE STATE FILE -------------------------------- DeskAutomate remembers what it has already handled, keyed by (rule name, file path, size, mtime) and writes it to a state file - by default .state.json, right next to the log. Because that state is on disk and not just in memory, repeated runs are idempotent ACROSS SEPARATE PROCESS RUNS: running deskautomate watch --rules rules.json --once --apply --log run.jsonl twice in a row with no new files does nothing the second time and appends no log lines. This holds for copy and run rules, where the file is still sitting in the watch directory when the second run scans it. Change the file - append to it, rewrite it - and its size or mtime changes, the recorded triple no longer matches, and the rule fires again as a "modified" event. A rule that FAILS is not marked handled, so it is retried on the next pass. Fix the cause and it will fire. State entries for files that no longer exist are pruned at the end of each pass, so the file does not grow forever. The state file is written atomically (temp file plus rename). Deleting it makes every currently present matching file look new again. DEBOUNCE: FILES THAT ARE STILL BEING WRITTEN -------------------------------------------- A large file that is being downloaded or copied into the watch directory is visible to the poller long before it is complete. Acting on it immediately would file half a PDF. Set "debounce_ms" and a matched file must hold the SAME size and mtime for at least that long before its rule fires. Every time the poller sees the file change, the settle timer restarts from zero. The rule fires on the first poll that finds the file unchanged for the full debounce window. poll 1 image.iso 6 bytes -> new, timer starts, nothing fires poll 2 image.iso 12 bytes -> changed, timer RESTARTS, nothing fires poll 3 image.iso 12 bytes -> unchanged for 160ms of 400ms, waits poll 4 image.iso 12 bytes -> unchanged for 400ms+, FIRES Set debounce_ms to 0 (the default) to fire on the very first poll that sees the file. Note that debounce timers live in the state file, so they only advance across separate --once runs when you pass --apply; a dry run does not persist state. Debounce is a heuristic, not a lock. A writer that stalls for longer than the debounce window will have its partial file acted on. There is no way in the Go standard library, portably, to ask whether another process still holds the file open. THE LOG ------- Every fired rule appends exactly one JSON object, one per line (JSONL), to the --log file. Dry-run passes log too, marked "dry_run": true. Files that are waiting on a debounce timer, and files that were already handled, do not log. {"time":"2026-08-10T03:43:18.98Z","rule":"index-logs","action":"run", "event":"created","path":"/tmp/Inbox/app.log","size":16, "command":"/tmp/record.sh /tmp/Inbox/app.log","exit_code":0, "output":"indexed app.log","dry_run":false,"ok":true,"duration_ms":4} Keys: time, rule, action, event ("created" or "modified"), path, size, and for move/copy dest, and for run command / exit_code / output, plus dry_run, ok, error (present only on failure), and duration_ms. The log is append-only and is never rotated or truncated by the tool. Point --log at a path your own log rotation handles if it will run for a long time. FAULT ISOLATION --------------- One bad rule does not stop the pass. A command that exits nonzero, an unwritable destination, a file that vanished between the scan and the action - each is caught, written to the log with "ok": false and an "error" reason, printed to stderr, and then the watcher moves on to the next rule and the next file. Healthy rules in the same pass still fire. EXIT CODES ---------- 0 success 1 usage error, or the rules file is missing or invalid 2 the pass completed but at least one action failed WHAT IS NOT IMPLEMENTED ----------------------- Stated plainly so nothing here is a surprise: * NO native filesystem events. Polling only, as described at the top. * NO recursive watching. One directory per rule, no subdirectories. * NO content hashing. Change detection is size plus mtime. A modification that preserves both is invisible. * NO regular expressions and no ** in match patterns; base-name globs only. * NO shell for run actions. No pipes, redirection, or quoting; the command is split on whitespace and the file path is appended. * NO renaming or templating. A move keeps the original file name (with a numeric suffix only to avoid a collision). There is no "rename to {date}-{name}" facility yet. * NO deletion action, by design. * NO daemon or service integration. Running "deskautomate watch" without --once runs in the foreground until Ctrl-C or SIGTERM. Use systemd, launchd, a Windows service wrapper, cron with --once, or a terminal multiplexer. * NO GUI. Rules are hand-written JSON. * NO retry backoff. A failing rule is retried on every subsequent pass, at full poll rate, until it succeeds or you fix it. * NO remote or network destinations. Local filesystem paths only. * NO concurrency. Rules and files are processed one at a time, in order. A slow run command blocks the rest of that pass. ROADMAP ------- * Native filesystem events - inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows - with polling kept as the fallback for network mounts, where native events are unreliable. * Running as a real background service or daemon: install/uninstall subcommands that register a systemd unit, a launchd agent, or a Windows service, with log rotation and restart-on-failure. * A GUI rule builder: pick a folder, pick a pattern, pick an action, see a live preview of which existing files would match, and write the rules file out. * Recursive watch directories with include/exclude patterns. * Rename templates for move and copy ({date}, {ext}, {counter}). * Per-rule retry limits with backoff and a quarantine folder for files whose rule keeps failing. TROUBLESHOOTING --------------- Nothing fires, and the pass says "already handled" The state file remembers those files. Delete the state file (default .state.json) to make every present matching file look new again. Nothing fires, and the pass says "0 matched" The glob is tested against the base name only, and it is case sensitive. "*.PDF" does not match "invoice.pdf". Check with: deskautomate rules --rules , which also tells you whether the watch_dir exists. It moved the file but nothing else happened Two rules matching the same file both fire, in file order, but a move relocates the file out from under the rules that come after it. Put copy and run rules before move rules in the rules file. The rule fires over and over The destination is inside the watch directory, so the tool keeps finding its own output. Validation rejects this, so this can only happen through a symlink; use a destination outside the watched tree. Half-written files are being processed Raise debounce_ms, or have the producer write to a temp name and rename into the watch directory when complete - a rename is atomic and the file appears at full size.