relay-flow 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/relay-flow.js +50 -0
- package/cmd/relay-flow/main.go +250 -0
- package/go.mod +5 -0
- package/go.sum +3 -0
- package/internal/acli/acli.go +229 -0
- package/internal/config/demo_test.go +17 -0
- package/internal/config/machine.go +71 -0
- package/internal/config/schema.go +193 -0
- package/internal/config/schema_test.go +162 -0
- package/internal/daemon/daemon.go +218 -0
- package/internal/daemon/daemon_test.go +204 -0
- package/internal/discovery/discovery.go +122 -0
- package/internal/discovery/discovery_test.go +62 -0
- package/internal/opencode/opencode.go +26 -0
- package/internal/orcacli/orcacli.go +264 -0
- package/internal/runner/orca/README.md +64 -0
- package/internal/runner/orca/orca.go +243 -0
- package/internal/runner/orca/orca_test.go +201 -0
- package/internal/runner/runner.go +81 -0
- package/internal/runner/runner_test.go +64 -0
- package/internal/server/client.go +126 -0
- package/internal/server/server.go +342 -0
- package/internal/server/server_test.go +195 -0
- package/internal/tasks/jira/README.md +69 -0
- package/internal/tasks/jira/component_test.go +16 -0
- package/internal/tasks/jira/decode.go +24 -0
- package/internal/tasks/jira/jira.go +231 -0
- package/internal/tasks/jira/jira_test.go +259 -0
- package/internal/tasks/jira/jql_test.go +16 -0
- package/internal/tasks/tasks.go +90 -0
- package/internal/tasks/tasks_test.go +91 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raj Popat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# relay-flow
|
|
2
|
+
|
|
3
|
+
Graph-based agent workflow engine. Tickets are tokens moving across **nodes**; each node has an **agent** (an OpenCode agent); the node's edges (`onSuccess`/`onFailure`) decide where the token goes next. The tracker (Jira built-in) is the scoreboard; the runner (Orca built-in) is the playing field. Both are pluggable.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
### Prerequisites
|
|
10
|
+
|
|
11
|
+
| Tool | Why |
|
|
12
|
+
|---|---|
|
|
13
|
+
| [opencode](https://opencode.ai) | Agents run in opencode sessions |
|
|
14
|
+
| [Orca](https://github.com/Necmttn/orca) CLI + app | Worktrees + terminals (the built-in runner) |
|
|
15
|
+
| [acli](https://developer.atlassian.com/cloud/acli) | Jira access (the built-in tracker) |
|
|
16
|
+
| Go 1.21+ | Build the CLI |
|
|
17
|
+
|
|
18
|
+
### Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
# simplest — prebuilt binary into ~/.local/bin:
|
|
22
|
+
curl -fsSL https://raw.githubusercontent.com/rajpopat27/relay-flow/main/install.sh | sh
|
|
23
|
+
|
|
24
|
+
# or once npm unblocks (24h cooldown after unpublish):
|
|
25
|
+
npm install -g relay-flow
|
|
26
|
+
|
|
27
|
+
# or with Go:
|
|
28
|
+
go install github.com/rajpopat27/relay-flow/cmd/relay-flow@v0.1.2
|
|
29
|
+
|
|
30
|
+
# or with Homebrew (after the v0.1.2 release publishes the formula):
|
|
31
|
+
brew install rajpopat27/tap/relay-flow
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Install the opencode plugin (report loopback): copy `plugin/report-status.ts` into your repo's `.opencode/plugin/` directory (auto-loaded by opencode), committed so every ticket worktree inherits it.
|
|
35
|
+
|
|
36
|
+
### Required configuration
|
|
37
|
+
|
|
38
|
+
1. **Machine identity** (per machine, never committed):
|
|
39
|
+
```sh
|
|
40
|
+
relay-flow init --assignee "Jane Doe" # Jira display name or accountId
|
|
41
|
+
```
|
|
42
|
+
Writes `~/.relay-flow/config.yaml` (0600), probe-validated against Jira.
|
|
43
|
+
|
|
44
|
+
2. **Orca repo** — the repo must be registered in Orca (`orca repo add --path .`) with a base ref set (`orca repo set-base-ref --repo id:<id> --ref master`).
|
|
45
|
+
|
|
46
|
+
3. **Jira board transitions** must allow the moves your edges imply (e.g. To Do → In Progress → Testing → In Review → Done).
|
|
47
|
+
|
|
48
|
+
4. **Workflow YAML** at `.workflow/workflow.yaml` (committed, team-shared):
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
name: xyzTaskFlow # camelCase identity: registry key + claim label wf:<name>
|
|
52
|
+
pollIntervalSeconds: 15 # optional, default 15
|
|
53
|
+
|
|
54
|
+
tasks: # ticket-system adapter
|
|
55
|
+
type: jira
|
|
56
|
+
config: # opaque to core; strictly validated by the adapter
|
|
57
|
+
query: project = ABCD # JQL fragment (no issuetype/assignee/ORDER BY)
|
|
58
|
+
issueTypes: [Task]
|
|
59
|
+
assigneeIsAgent: true # or omit → assignee comes from `relay-flow init`
|
|
60
|
+
|
|
61
|
+
runner: # execution backend
|
|
62
|
+
type: orca
|
|
63
|
+
|
|
64
|
+
closeOn: [done] # terminal nodes whose tickets close their terminals
|
|
65
|
+
|
|
66
|
+
nodes:
|
|
67
|
+
coding:
|
|
68
|
+
agent: build # OpenCode agent for this node
|
|
69
|
+
when: "In Progress" # tracker state routing tickets here (unique per file)
|
|
70
|
+
onSuccess: reviewing # outcome edges — required for agent nodes
|
|
71
|
+
onFailure: coding # self-loop allowed → comment only, no transition
|
|
72
|
+
nudgePrompt: "..." # optional; {{ticket}} {{node}} templates; sane default
|
|
73
|
+
reviewing:
|
|
74
|
+
agent: build # the same agent may serve many nodes
|
|
75
|
+
when: "In Review"
|
|
76
|
+
onSuccess: done
|
|
77
|
+
onFailure: coding
|
|
78
|
+
done:
|
|
79
|
+
when: "Done" # no agent → terminal / human-gate node
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Validation is strict and happens at submit: unknown fields, duplicate `when` values, dangling edges, agent nodes missing edges, and every referenced tracker state is probe-validated against the tracker.
|
|
83
|
+
|
|
84
|
+
### Run
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
relay-flow serve # central process (artifacts in ~/.relay-flow/)
|
|
88
|
+
relay-flow submit -f .workflow/workflow.yaml
|
|
89
|
+
relay-flow stop serve
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`relay-flow report` is invoked by the plugin, not by hand.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Architecture
|
|
97
|
+
|
|
98
|
+
### The board-game model
|
|
99
|
+
|
|
100
|
+
- **Nodes** are squares. Each maps to exactly one tracker state via `when`. One node = one state; the same agent may serve many nodes.
|
|
101
|
+
- **Agents** are robots sitting on squares. A ticket landing on an agent's square triggers work in a dedicated terminal/worktree.
|
|
102
|
+
- **Edges** (`onSuccess`/`onFailure`) are the only legal moves. Self-loops comment without transitioning (trackers have no self-transitions).
|
|
103
|
+
- **Terminal nodes** (in `closeOn`) tear down the ticket's terminals. **Agentless nodes** (no `agent:`) are human gates: the daemon claims the ticket (so other workflows skip it) but never spawns, nudges, or closes anything.
|
|
104
|
+
- The **tracker is the single source of truth**. The server holds no database; restart = resubmit, and claim labels (`wf:<name>`) survive to drive recovery.
|
|
105
|
+
|
|
106
|
+
### End-to-end sequence
|
|
107
|
+
|
|
108
|
+
```mermaid
|
|
109
|
+
sequenceDiagram
|
|
110
|
+
participant J as Tracker (Jira)
|
|
111
|
+
participant S as relay-flow serve
|
|
112
|
+
participant R as Runner (Orca)
|
|
113
|
+
participant O as OpenCode + plugin
|
|
114
|
+
|
|
115
|
+
loop every pollIntervalSeconds
|
|
116
|
+
S->>J: List() — one query (query + issuetype + component + assignee)
|
|
117
|
+
J-->>S: tickets (Node via when-map, ClaimedBy via wf:* labels)
|
|
118
|
+
end
|
|
119
|
+
S->>J: Claim(ticket) — add label wf:xyzTaskFlow
|
|
120
|
+
S->>R: Spawn(ticket, node, agent, env RELAY_*)
|
|
121
|
+
R->>R: ensure worktree → terminal key:agent:node → opencode --prompt
|
|
122
|
+
R->>O: agent session starts
|
|
123
|
+
O->>O: works… ends reply with STATUS/SUMMARY
|
|
124
|
+
O->>S: plugin: relay-flow report --workflow --ticket --node --outcome --summary
|
|
125
|
+
S->>J: Report → transition to target node's state + comment
|
|
126
|
+
S-->>O: {action: transitioned | commented | error}
|
|
127
|
+
Note over O: action=error → plugin retries 3× → nudges the session
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### The poll-cycle 3-way switch
|
|
131
|
+
|
|
132
|
+
```mermaid
|
|
133
|
+
flowchart TD
|
|
134
|
+
L[tasks.List] --> T{per ticket}
|
|
135
|
+
T -->|ClaimedBy = other workflow| SKIP1[skip — mutex]
|
|
136
|
+
T -->|Node unmapped| SKIP2[log + skip]
|
|
137
|
+
T -->|node in closeOn| CLOSE[runner.Close — tear down terminals]
|
|
138
|
+
T -->|node agentless| GATE[claim if unclaimed, then leave for the human]
|
|
139
|
+
T -->|ClaimedBy = me, unknown in memory| BOUNCE[go bounce]
|
|
140
|
+
T -->|unclaimed| DISP[go dispatch]
|
|
141
|
+
|
|
142
|
+
DISP --> C1[tasks.Claim] --> S1[runner.Spawn fresh session]
|
|
143
|
+
BOUNCE --> F{runner.Find by title}
|
|
144
|
+
F -->|session alive| N1[Nudge once per node visit]
|
|
145
|
+
F -->|gone| S2[Spawn fresh — claim already held]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Claimed tickets are never touched by other workflows: the label is the cross-workflow mutex. "Claimed by me but unknown in memory" happens after a server restart — the bounce path re-finds the terminal by title (`<key>:<agent>:<node>`) and nudges it in place, preserving the agent's context instead of burning tokens on a fresh session.
|
|
149
|
+
|
|
150
|
+
### Components
|
|
151
|
+
|
|
152
|
+
```mermaid
|
|
153
|
+
flowchart LR
|
|
154
|
+
subgraph CLI[relay-flow CLI]
|
|
155
|
+
M[cmd/relay-flow<br/>serve · submit · report · init]
|
|
156
|
+
end
|
|
157
|
+
subgraph SRV[server — one process, N workflows]
|
|
158
|
+
H[/submit · /report · /shutdown/]
|
|
159
|
+
D1[daemon: poll loop] --> T1[tasks iface]
|
|
160
|
+
D1 --> R1[runner iface]
|
|
161
|
+
end
|
|
162
|
+
subgraph ADAPTERS[adapters — registry pattern]
|
|
163
|
+
J[tasks/jira<br/>acli] -.-> T1
|
|
164
|
+
O[runner/orca<br/>worktrees + terminals] -.-> R1
|
|
165
|
+
end
|
|
166
|
+
M -->|unix socket ~/.relay-flow/server.sock| H
|
|
167
|
+
H --> D1
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
| Component | Location | Role |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| opencode plugin | `plugin/report-status.ts` | Parses STATUS/SUMMARY deterministically, calls `relay-flow report` (thin socket client), retries/nudges |
|
|
173
|
+
| CLI | `cmd/relay-flow/` | `serve` hosts workflows; `submit` registers one; `report` is a one-shot client |
|
|
174
|
+
| daemon | `internal/daemon/` | Poll loop, 3-way switch, dispatch/bounce goroutines |
|
|
175
|
+
| server | `internal/server/` | Socket lifecycle, submit validation, report routing |
|
|
176
|
+
| config | `internal/config/` | Workflow YAML schema + graph validation |
|
|
177
|
+
| Jira adapter | `internal/tasks/jira/` | [readme](internal/tasks/jira/README.md) — query/claim/report over acli |
|
|
178
|
+
| Orca adapter | `internal/runner/orca/` | [readme](internal/runner/orca/README.md) — worktrees, terminals, prompts |
|
|
179
|
+
|
|
180
|
+
### Key invariants
|
|
181
|
+
|
|
182
|
+
- **Fail fast**: everything validates at submit — YAML structure, graph shape, tracker states, assignee, adapters' configs.
|
|
183
|
+
- **No fallback paths**: report goes through the server or not at all; server down = system down (the plugin's 3× retry + nudge covers transient gaps).
|
|
184
|
+
- **Labels are never removed**: `wf:<name>` is the crash-recovery anchor.
|
|
185
|
+
- **Terminals outlive their node visit** — a bounce reuses the session; only `closeOn` nodes close them.
|
|
186
|
+
- **Parallelism**: one long-lived poll goroutine per workflow; short-lived dispatch/bounce goroutines per ticket; tickets at the same node run in parallel terminals with isolated worktrees.
|
|
187
|
+
|
|
188
|
+
### Extending
|
|
189
|
+
|
|
190
|
+
New tracker (beads, Linear, GitHub): implement `tasks.Tasks` + register — see [tasks/jira README](internal/tasks/jira/README.md#writing-a-new-tasks-adapter-beads-linear-github-).
|
|
191
|
+
New execution backend (tmux, …): implement `runner.Runner` + register — see [runner/orca README](internal/runner/orca/README.md#writing-a-new-runner-tmux-).
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
cd cli
|
|
197
|
+
go test ./... -race
|
|
198
|
+
go install ./...
|
|
199
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// relay-flow npm wrapper: downloads the prebuilt Go binary for this
|
|
3
|
+
// platform from GitHub Releases (tag v<version>), falling back to
|
|
4
|
+
// `go build` from the packaged source when no release asset matches
|
|
5
|
+
// or the network is unavailable. Cached in os.tmpdir()/relay-flow/bin.
|
|
6
|
+
const { spawnSync } = require("node:child_process")
|
|
7
|
+
const fs = require("node:fs")
|
|
8
|
+
const os = require("node:os")
|
|
9
|
+
const path = require("node:path")
|
|
10
|
+
|
|
11
|
+
const pkg = require(path.join(__dirname, "..", "package.json"))
|
|
12
|
+
const version = pkg.version
|
|
13
|
+
const root = path.dirname(__dirname)
|
|
14
|
+
const binDir = path.join(os.tmpdir(), "relay-flow", "bin")
|
|
15
|
+
const isWin = process.platform === "win32"
|
|
16
|
+
const bin = path.join(binDir, isWin ? "relay-flow.exe" : "relay-flow")
|
|
17
|
+
|
|
18
|
+
const goos = { linux: "linux", darwin: "darwin", win32: "windows" }[process.platform]
|
|
19
|
+
const goarch = { x64: "amd64", arm64: "arm64" }[process.arch]
|
|
20
|
+
|
|
21
|
+
function buildFromSource() {
|
|
22
|
+
const build = spawnSync("go", ["build", "-o", bin, path.join(root, "cmd", "relay-flow")], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
stdio: "inherit",
|
|
25
|
+
})
|
|
26
|
+
if (build.status !== 0) process.exit(build.status ?? 1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(bin)) {
|
|
30
|
+
fs.mkdirSync(binDir, { recursive: true })
|
|
31
|
+
let got = false
|
|
32
|
+
if (goos && goarch && !isWin) {
|
|
33
|
+
const asset = `relay-flow_${goos}_${goarch}.tar.gz`
|
|
34
|
+
const url = `https://github.com/rajpopat27/relay-flow/releases/download/v${version}/${asset}`
|
|
35
|
+
const archive = path.join(binDir, asset)
|
|
36
|
+
const dl = spawnSync("curl", ["-fsSL", "-o", archive, url], { stdio: "inherit" })
|
|
37
|
+
if (dl.status === 0) {
|
|
38
|
+
const untar = spawnSync("tar", ["-xzf", archive, "-C", binDir], { stdio: "inherit" })
|
|
39
|
+
fs.rmSync(archive, { force: true })
|
|
40
|
+
if (untar.status === 0 && fs.existsSync(bin)) {
|
|
41
|
+
fs.chmodSync(bin, 0o755)
|
|
42
|
+
got = true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!got) buildFromSource()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const run = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" })
|
|
50
|
+
process.exit(run.status ?? 0)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Command relay-flow automates tracker ↔ runner agent workflows.
|
|
2
|
+
// `serve` is a central process hosting any number of workflows submitted via
|
|
3
|
+
// `submit`. `report` is a one-shot socket client (invoked by the opencode
|
|
4
|
+
// plugin) that asks the server to record an agent outcome.
|
|
5
|
+
package main
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"flag"
|
|
10
|
+
"fmt"
|
|
11
|
+
"io"
|
|
12
|
+
"log"
|
|
13
|
+
"net"
|
|
14
|
+
"os"
|
|
15
|
+
"os/exec"
|
|
16
|
+
"os/signal"
|
|
17
|
+
"path/filepath"
|
|
18
|
+
|
|
19
|
+
"syscall"
|
|
20
|
+
|
|
21
|
+
"github.com/rajpopat27/relay-flow/internal/acli"
|
|
22
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
23
|
+
"github.com/rajpopat27/relay-flow/internal/discovery"
|
|
24
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
func main() {
|
|
28
|
+
if len(os.Args) < 2 {
|
|
29
|
+
usage()
|
|
30
|
+
os.Exit(1)
|
|
31
|
+
}
|
|
32
|
+
switch os.Args[1] {
|
|
33
|
+
case "init":
|
|
34
|
+
cmdInit(os.Args[2:])
|
|
35
|
+
case "stop":
|
|
36
|
+
cmdStop(os.Args[2:])
|
|
37
|
+
case "serve":
|
|
38
|
+
cmdServe(os.Args[2:])
|
|
39
|
+
case "submit":
|
|
40
|
+
cmdSubmit(os.Args[2:])
|
|
41
|
+
case "report":
|
|
42
|
+
cmdReport(os.Args[2:])
|
|
43
|
+
default:
|
|
44
|
+
usage()
|
|
45
|
+
os.Exit(1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func usage() {
|
|
50
|
+
fmt.Fprintln(os.Stderr, "usage: relay-flow init --assignee \"<your Jira display name or accountId>\"")
|
|
51
|
+
fmt.Fprintln(os.Stderr, " relay-flow stop serve")
|
|
52
|
+
fmt.Fprintln(os.Stderr, " relay-flow serve [--dry-run] [--foreground]")
|
|
53
|
+
fmt.Fprintln(os.Stderr, " relay-flow submit [-f <yaml>] (workflow name comes from the YAML's name field)")
|
|
54
|
+
fmt.Fprintln(os.Stderr, " relay-flow report --workflow <name> --ticket <key> --node <node> --outcome <success|failure> --summary <text>")
|
|
55
|
+
fmt.Fprintln(os.Stderr, " server artifacts (lock/sock/log) are always under ~/.relay-flow/")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// daemonize re-execs the binary detached with childArgs, log file attached
|
|
59
|
+
// as stdout+stderr. No supervisor, no IPC: the child IS the worker. Returns
|
|
60
|
+
// after spawning.
|
|
61
|
+
func daemonize(logPath string, childArgs ...string) {
|
|
62
|
+
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
63
|
+
if err != nil {
|
|
64
|
+
log.Fatalf("open log file: %v", err)
|
|
65
|
+
}
|
|
66
|
+
self, err := os.Executable()
|
|
67
|
+
if err != nil {
|
|
68
|
+
log.Fatalf("resolve executable: %v", err)
|
|
69
|
+
}
|
|
70
|
+
cmd := exec.Command(self, childArgs...)
|
|
71
|
+
cmd.Stdout, cmd.Stderr = f, f
|
|
72
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
73
|
+
cmd.Env = append(os.Environ(), "RELAY_FLOW_DAEMONIZED=1")
|
|
74
|
+
if err := cmd.Start(); err != nil {
|
|
75
|
+
log.Fatalf("daemonize: %v", err)
|
|
76
|
+
}
|
|
77
|
+
fmt.Printf("started (pid %d), logging to %s\n", cmd.Process.Pid, logPath)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// cmdInit writes the machine config (~/.relay-flow/config.yaml) with this machine
|
|
81
|
+
// user's tracker identity, probe-validated against the tracker.
|
|
82
|
+
func cmdInit(args []string) {
|
|
83
|
+
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
|
84
|
+
assignee := fs.String("assignee", "", "your tracker display name or accountId")
|
|
85
|
+
fs.Parse(args)
|
|
86
|
+
if *assignee == "" {
|
|
87
|
+
log.Fatalf("usage: relay-flow init --assignee \"<your tracker display name or accountId>\"")
|
|
88
|
+
}
|
|
89
|
+
if err := acli.New().ValidateAssignee(*assignee); err != nil {
|
|
90
|
+
log.Fatalf("%v", err)
|
|
91
|
+
}
|
|
92
|
+
if err := (&config.MachineConfig{Assignee: *assignee}).Save(); err != nil {
|
|
93
|
+
log.Fatalf("%v", err)
|
|
94
|
+
}
|
|
95
|
+
p, _ := config.MachineConfigPath()
|
|
96
|
+
fmt.Printf("machine config written to %s (assignee=%q)\n", p, *assignee)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func cmdStop(args []string) {
|
|
100
|
+
// `stop serve` asks the central server to shut down over its socket;
|
|
101
|
+
// process exit releases the flock, so no pid file exists to clean up.
|
|
102
|
+
if len(args) != 1 || args[0] != "serve" {
|
|
103
|
+
log.Fatalf("usage: relay-flow stop serve")
|
|
104
|
+
}
|
|
105
|
+
client, err := server.NewClient()
|
|
106
|
+
if err != nil {
|
|
107
|
+
log.Fatalf("%v", err)
|
|
108
|
+
}
|
|
109
|
+
if err := client.Shutdown(); err != nil {
|
|
110
|
+
log.Fatalf("%v", err)
|
|
111
|
+
}
|
|
112
|
+
fmt.Println("server stopped")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// cmdServe runs the central server: one process hosting any number of
|
|
116
|
+
// workflows submitted over its unix socket. Daemonizes by default.
|
|
117
|
+
func cmdServe(args []string) {
|
|
118
|
+
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
|
119
|
+
dryRun := fs.Bool("dry-run", false, "log every runner command instead of executing it")
|
|
120
|
+
foreground := fs.Bool("foreground", false, "stay in the foreground")
|
|
121
|
+
fs.Parse(args)
|
|
122
|
+
|
|
123
|
+
home, err := os.UserHomeDir()
|
|
124
|
+
if err != nil {
|
|
125
|
+
log.Fatalf("%v", err)
|
|
126
|
+
}
|
|
127
|
+
logPath := filepath.Join(home, ".relay-flow", "server.log")
|
|
128
|
+
|
|
129
|
+
if !*foreground {
|
|
130
|
+
// Acquire the single-instance lock in the PARENT, before spawning:
|
|
131
|
+
// this is where the user is watching, so "already running" must
|
|
132
|
+
// fail here, not silently in the detached child.
|
|
133
|
+
release, err := discovery.AcquireServerLock()
|
|
134
|
+
if err != nil {
|
|
135
|
+
log.Fatalf("%v", err)
|
|
136
|
+
}
|
|
137
|
+
release()
|
|
138
|
+
childArgs := []string{"serve", "--foreground"}
|
|
139
|
+
if *dryRun {
|
|
140
|
+
childArgs = append(childArgs, "--dry-run")
|
|
141
|
+
}
|
|
142
|
+
daemonize(logPath, childArgs...)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Tee logs to server.log when run interactively; daemonized child
|
|
147
|
+
// already has stderr attached to the log file.
|
|
148
|
+
if os.Getenv("RELAY_FLOW_DAEMONIZED") == "" {
|
|
149
|
+
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
|
|
150
|
+
log.SetOutput(io.MultiWriter(os.Stderr, f))
|
|
151
|
+
defer f.Close()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Single instance enforcement via flock: the kernel holds the lock for
|
|
156
|
+
// this process's life and releases it on ANY exit (clean, crash,
|
|
157
|
+
// kill -9), so there is never stale state to clean up. No pid file.
|
|
158
|
+
releaseLock, err := discovery.AcquireServerLock()
|
|
159
|
+
if err != nil {
|
|
160
|
+
log.Fatalf("%v", err)
|
|
161
|
+
}
|
|
162
|
+
defer releaseLock()
|
|
163
|
+
|
|
164
|
+
sockPath, err := discovery.SocketPath()
|
|
165
|
+
if err != nil {
|
|
166
|
+
log.Fatalf("%v", err)
|
|
167
|
+
}
|
|
168
|
+
os.Remove(sockPath) // stale socket from a crashed server
|
|
169
|
+
ln, err := net.Listen("unix", sockPath)
|
|
170
|
+
if err != nil {
|
|
171
|
+
log.Fatalf("listen %s: %v", sockPath, err)
|
|
172
|
+
}
|
|
173
|
+
defer os.Remove(sockPath)
|
|
174
|
+
|
|
175
|
+
srv := server.New(*dryRun, server.ProdDeps(*dryRun))
|
|
176
|
+
log.Printf("relay-flow serve: socket=%s dry-run=%v", sockPath, *dryRun)
|
|
177
|
+
|
|
178
|
+
sig := make(chan os.Signal, 1)
|
|
179
|
+
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
180
|
+
go func() {
|
|
181
|
+
<-sig
|
|
182
|
+
log.Printf("shutting down")
|
|
183
|
+
srv.Shutdown()
|
|
184
|
+
}()
|
|
185
|
+
if err := srv.Serve(ln); err != nil {
|
|
186
|
+
log.Fatalf("serve: %v", err)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// cmdReport is invoked by the opencode plugin once per session.idle.
|
|
191
|
+
// Thin socket client: the server resolves the outcome edge and calls the
|
|
192
|
+
// tasks adapter — no config load, no tracker calls, no fallback. The
|
|
193
|
+
// agent terminal is deliberately NOT closed here: it stays alive for
|
|
194
|
+
// bounce nudges; only closeOn nodes close terminals.
|
|
195
|
+
func cmdReport(args []string) {
|
|
196
|
+
fs := flag.NewFlagSet("report", flag.ExitOnError)
|
|
197
|
+
workflow := fs.String("workflow", "", "workflow name")
|
|
198
|
+
ticket := fs.String("ticket", "", "ticket key")
|
|
199
|
+
node := fs.String("node", "", "node name")
|
|
200
|
+
outcome := fs.String("outcome", "", "success or failure")
|
|
201
|
+
summary := fs.String("summary", "", "agent's summary of what it did")
|
|
202
|
+
fs.Parse(args)
|
|
203
|
+
if *workflow == "" || *ticket == "" || *node == "" || *outcome == "" || *summary == "" {
|
|
204
|
+
log.Fatalf("usage: relay-flow report --workflow <name> --ticket <key> --node <node> --outcome <success|failure> --summary <text>")
|
|
205
|
+
}
|
|
206
|
+
client, err := server.NewClient()
|
|
207
|
+
if err != nil {
|
|
208
|
+
log.Fatalf("%v", err)
|
|
209
|
+
}
|
|
210
|
+
result, err := client.Report(*workflow, *ticket, *node, *outcome, *summary)
|
|
211
|
+
if err != nil {
|
|
212
|
+
log.Fatalf("report %s: %v", *ticket, err)
|
|
213
|
+
}
|
|
214
|
+
// JSON on stdout (log goes to stderr) so the plugin can parse it.
|
|
215
|
+
out, _ := json.Marshal(map[string]string{"action": result.Action, "detail": result.Detail})
|
|
216
|
+
fmt.Println(string(out))
|
|
217
|
+
log.Printf("report %s: workflow=%s node=%s action=%s detail=%q", *ticket, *workflow, *node, result.Action, result.Detail)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// cmdSubmit reads a workflow YAML and sends it to the running server. cwd
|
|
221
|
+
// must be inside the repo the workflow governs (repo resolved client-side).
|
|
222
|
+
// The workflow's identity is the `name` field inside the YAML.
|
|
223
|
+
func cmdSubmit(args []string) {
|
|
224
|
+
fs := flag.NewFlagSet("submit", flag.ExitOnError)
|
|
225
|
+
file := fs.String("f", "", "path to workflow YAML (default .workflow/workflow.yaml)")
|
|
226
|
+
fs.Parse(args)
|
|
227
|
+
if fs.NArg() != 0 {
|
|
228
|
+
log.Fatalf("usage: relay-flow submit [-f <yaml>] (workflow name comes from the YAML's `name` field)")
|
|
229
|
+
}
|
|
230
|
+
path := *file
|
|
231
|
+
if path == "" {
|
|
232
|
+
path = filepath.Join(".workflow", "workflow.yaml")
|
|
233
|
+
}
|
|
234
|
+
yamlBytes, err := os.ReadFile(path)
|
|
235
|
+
if err != nil {
|
|
236
|
+
log.Fatalf("read %s: %v", path, err)
|
|
237
|
+
}
|
|
238
|
+
cwd, err := os.Getwd()
|
|
239
|
+
if err != nil {
|
|
240
|
+
log.Fatalf("%v", err)
|
|
241
|
+
}
|
|
242
|
+
client, err := server.NewClient()
|
|
243
|
+
if err != nil {
|
|
244
|
+
log.Fatalf("%v", err)
|
|
245
|
+
}
|
|
246
|
+
if err := client.Submit(cwd, yamlBytes); err != nil {
|
|
247
|
+
log.Fatalf("submit: %v", err)
|
|
248
|
+
}
|
|
249
|
+
fmt.Println("submitted")
|
|
250
|
+
}
|
package/go.mod
ADDED
package/go.sum
ADDED