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
6 changes: 6 additions & 0 deletions extensions/ty-on/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Binary
ty-on

# Test artifacts
*.test
coverage.out
131 changes: 131 additions & 0 deletions extensions/ty-on/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# ty-on

Placement resolver for TaskYou. Decides which machine a task should run on, and
answers "this one, here" or "run it locally".

## Why this is an extension

ty can run a task on another machine. The *policy* for that — which hosts exist,
what they are provisioned for, which one to pick — is specific to whoever owns
the fleet, so it does not belong in ty. A normal ty user never sees any of it.

This extension is the policy half. It touches nothing in ty's core: ty invokes
it, and it answers.

## The contract

ty-on is a binary that reads one JSON request on stdin and writes one JSON
response on stdout. It is invoked once per task, before the executor is spawned.

Request:

```json
{
"event": "task.placement",
"task": {
"id": 5225,
"title": "Some task",
"project": "taskyou",
"repo_path": "/Users/bruno/Projects/workflow",
"executor": "claude"
}
}
```

Response:

```json
{
"target": "ol-agents",
"workdir": "~/projects/engineering",
"reason": "most free memory of 2 hosts serving offerlab (ol-agents 26.5G, mona 11.3G)"
}
```

`target` names a host in the `on` inventory. `workdir` is that project's
checkout path on that host — a remote path, so a leading `~` is left alone for
the remote shell to expand.

**An empty `target` means "run locally"**, and it is the answer to every
question ty-on cannot confidently answer: unknown project, missing inventory, no
reachable host, malformed request, `on` not installed. ty-on never fails a task
and never guesses a host — it exits 0 in all cases.

`reason` is always populated and is shown to the user, so it is written to
explain a surprising placement without further digging.

## Placement rules

The inventory is the same one the [`on`](https://github.com/bborn/on) CLI reads:
`$ON_HOSTS`, else `$XDG_CONFIG_HOME/on/hosts.yaml`, else
`~/.config/on/hosts.yaml`.

```yaml
hosts:
ol-agents:
ssh: ol-agents
workdir: ~/projects
capabilities: [agent, ruby, node]
repos:
offerlab: ~/projects/engineering
```

Given a task's project:

1. Find the hosts whose `repos` map contains that project.
2. **None** → local. The fleet has no checkout to run in.
3. **One** → that host. No probing: this path answers from the file alone, so it
works on a machine that does not have `on` installed at all.
4. **Several** → the one with the most free memory. `on ls` already probes the
fleet in parallel, so ty-on shells out to it rather than reimplementing the
probe. Hosts `on` could not reach are dropped; ties break on host name so the
answer is stable.

`on` is an optional dependency. If it is missing, or fails, or is slow, the task
stays local with a reason saying so.

### Speed

This runs in the task spawn path, so it is built to be fast or to get out of the
way. Rules 1–3 are a single file read. Rule 4 costs one `on ls` (an SSH round
trip per host, in parallel), bounded by `TY_ON_TIMEOUT` — past that budget ty-on
prefers a local placement to a late answer.

## Usage

```console
$ go build -o ty-on ./cmd
$ echo '{"event":"task.placement","task":{"project":"taskyou"}}' | ./ty-on
{"target":"mona","workdir":"~/Projects/taskyou","reason":"only host serving taskyou"}
```

`ty-on --help` prints the same summary; `ty-on --version` prints the version.

## Environment

| Variable | Default | Meaning |
| --- | --- | --- |
| `ON_HOSTS` | — | Host inventory path. Overrides the default lookup, and is passed through to `on ls` so both read the same file. |
| `XDG_CONFIG_HOME` | — | When set and `ON_HOSTS` is not, the inventory is `$XDG_CONFIG_HOME/on/hosts.yaml`. |
| `TY_ON_TIMEOUT` | `3s` | Budget for the `on ls` probe. Unset or unparseable falls back to the default. |

## Development

```console
$ go test ./...
$ golangci-lint run --config ../../.golangci.yml ./...
```

Tests inject a fake prober rather than shelling out, so the suite passes on a
machine with no fleet and no `on` installed. The `on ls` table parser is pinned
against real output.

## Not in scope

ty-on decides *where*; it does not move anything. Syncing the working tree,
creating worktrees, and opening SSH sessions are all `on`'s job, and invoking
this resolver is ty's.

Host `capabilities` are parsed but not yet used for filtering — the rules above
are deliberately the whole policy. Matching an executor against a host's
capabilities is the obvious next lever.
106 changes: 106 additions & 0 deletions extensions/ty-on/cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Command ty-on decides which host a TaskYou task should run on.
//
// It reads one JSON placement request on stdin and writes one JSON response on
// stdout:
//
// $ echo '{"event":"task.placement","task":{"project":"taskyou"}}' | ty-on
// {"target":"mona","workdir":"~/Projects/taskyou","reason":"only host serving taskyou"}
//
// An empty target means "run locally", and is the answer to every question this
// resolver cannot confidently answer. It exits 0 in all cases: it is called in
// the task spawn path and must never fail a task.
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"

"github.com/bborn/workflow/extensions/ty-on/internal/placement"
)

// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"

// maxRequest caps how much stdin we will read. A placement request is a few
// hundred bytes; anything near this is a malformed caller.
const maxRequest = 1 << 20

const usage = `ty-on — placement resolver for TaskYou

Reads one JSON placement request on stdin, writes one JSON response on stdout.

echo '{"event":"task.placement","task":{"project":"taskyou"}}' | ty-on

Reads the same host inventory as the "on" CLI: $ON_HOSTS, else
$XDG_CONFIG_HOME/on/hosts.yaml, else ~/.config/on/hosts.yaml.

Flags:
-h, --help show this help
-v, --version print the version

Environment:
ON_HOSTS host inventory path
TY_ON_TIMEOUT budget for the "on ls" probe (default 3s)
`

func main() {
for _, arg := range os.Args[1:] {
switch arg {
case "-h", "--help", "help":
fmt.Print(usage)
return
case "-v", "--version", "version":
fmt.Println(version)
return
}
}

emit(resolve(context.Background(), os.Stdin))
}

// resolve turns whatever is on stdin into a placement response. Every failure
// mode becomes a local placement carrying an explanation.
func resolve(ctx context.Context, stdin io.Reader) placement.Response {
body, err := io.ReadAll(io.LimitReader(stdin, maxRequest))
if err != nil {
return placement.Local("placement request could not be read: %v", err)
}
if len(body) == 0 {
return placement.Local("empty placement request")
}

var req placement.Request
if err := json.Unmarshal(body, &req); err != nil {
return placement.Local("placement request is not valid JSON: %v", err)
}

return placement.Resolver{Timeout: timeout()}.Resolve(ctx, req)
}

// timeout reads the probe budget from TY_ON_TIMEOUT, falling back to the
// default when it is unset or nonsense.
func timeout() time.Duration {
raw := os.Getenv("TY_ON_TIMEOUT")
if raw == "" {
return placement.DefaultTimeout
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return placement.DefaultTimeout
}
return d
}

func emit(resp placement.Response) {
out, err := json.Marshal(resp)
if err != nil {
// Response is three strings; this cannot fail in practice, but a
// hand-written fallback still beats writing nothing at all.
out = []byte(`{"target":"","workdir":"","reason":"placement response could not be encoded"}`)
}
fmt.Fprintf(os.Stdout, "%s\n", out)
}
115 changes: 115 additions & 0 deletions extensions/ty-on/cmd/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"

"github.com/bborn/workflow/extensions/ty-on/internal/placement"
)

// noInventory points the resolver at a path that does not exist, so these tests
// exercise the stdin/stdout contract without depending on a real fleet.
func noInventory(t *testing.T) {
t.Helper()
t.Setenv("ON_HOSTS", filepath.Join(t.TempDir(), "absent.yaml"))
t.Setenv("PATH", t.TempDir())
}

func TestResolveReadsTheRequestContract(t *testing.T) {
tests := []struct {
name string
stdin string
// wantReason is a substring the reason must contain.
wantReason string
}{
{
name: "a well-formed request is understood",
stdin: `{"event":"task.placement","task":{"id":5225,"title":"Some task","project":"taskyou","repo_path":"/Users/bruno/Projects/workflow","executor":"claude"}}`,
wantReason: "no host inventory at",
},
{
name: "malformed JSON falls back to local",
stdin: `{"event":"task.placement",`,
wantReason: "placement request is not valid JSON",
},
{
name: "a JSON scalar falls back to local",
stdin: `"nope"`,
wantReason: "placement request is not valid JSON",
},
{
name: "empty stdin falls back to local",
stdin: "",
wantReason: "empty placement request",
},
{
name: "a request with no task falls back to local",
stdin: `{"event":"task.placement"}`,
wantReason: "task has no project",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
noInventory(t)

got := resolve(context.Background(), strings.NewReader(tc.stdin))

if got.Target != "" {
t.Errorf("target = %q, want a local placement", got.Target)
}
if !strings.Contains(got.Reason, tc.wantReason) {
t.Errorf("reason = %q, want it to contain %q", got.Reason, tc.wantReason)
}
})
}
}

// Whatever happens, the response must be one JSON object carrying all three
// fields — core parses it unconditionally.
func TestResolveAlwaysEncodesTheFullResponse(t *testing.T) {
noInventory(t)

out, err := json.Marshal(resolve(context.Background(), strings.NewReader("garbage")))
if err != nil {
t.Fatalf("marshal response: %v", err)
}

var fields map[string]any
if err := json.Unmarshal(out, &fields); err != nil {
t.Fatalf("response is not a JSON object: %v (%s)", err, out)
}
for _, key := range []string{"target", "workdir", "reason"} {
if _, ok := fields[key]; !ok {
t.Errorf("response is missing %q: %s", key, out)
}
}
if fields["reason"] == "" {
t.Errorf("reason is empty: %s", out)
}
}

func TestTimeout(t *testing.T) {
tests := []struct {
raw string
want time.Duration
}{
{"", placement.DefaultTimeout},
{"750ms", 750 * time.Millisecond},
{"10s", 10 * time.Second},
{"nonsense", placement.DefaultTimeout},
{"0s", placement.DefaultTimeout},
{"-5s", placement.DefaultTimeout},
}

for _, tc := range tests {
t.Setenv("TY_ON_TIMEOUT", tc.raw)
if got := timeout(); got != tc.want {
t.Errorf("TY_ON_TIMEOUT=%q: timeout() = %s, want %s", tc.raw, got, tc.want)
}
}
}
5 changes: 5 additions & 0 deletions extensions/ty-on/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/bborn/workflow/extensions/ty-on

go 1.24.4

require gopkg.in/yaml.v3 v3.0.1
4 changes: 4 additions & 0 deletions extensions/ty-on/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading