relay-flow 0.0.1 → 0.2.0-alpha
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/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// Package goworkflows is the durable execution engine: one generic
|
|
2
|
+
// TicketWorkflow interpreter over go-workflows with a SQLite backend, the
|
|
3
|
+
// relay_runs projection, typed activity retry loops, and signal-based report
|
|
4
|
+
// handling. All go-workflows types stay inside this package.
|
|
5
|
+
package goworkflows
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"context"
|
|
9
|
+
"database/sql"
|
|
10
|
+
"errors"
|
|
11
|
+
"fmt"
|
|
12
|
+
"log/slog"
|
|
13
|
+
"os"
|
|
14
|
+
"sync"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"github.com/cschleiden/go-workflows/backend"
|
|
18
|
+
"github.com/cschleiden/go-workflows/backend/converter"
|
|
19
|
+
"github.com/cschleiden/go-workflows/backend/history"
|
|
20
|
+
"github.com/cschleiden/go-workflows/backend/sqlite"
|
|
21
|
+
"github.com/cschleiden/go-workflows/client"
|
|
22
|
+
"github.com/cschleiden/go-workflows/worker"
|
|
23
|
+
goworkflow "github.com/cschleiden/go-workflows/workflow"
|
|
24
|
+
"github.com/google/uuid"
|
|
25
|
+
|
|
26
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
27
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
28
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
29
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
30
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Dependencies carries the replaceable boundaries used by activities.
|
|
34
|
+
type Dependencies struct {
|
|
35
|
+
Repos *repo.Registry
|
|
36
|
+
Runner runner.Runner
|
|
37
|
+
Harness harness.Harness
|
|
38
|
+
|
|
39
|
+
// RetentionDays bounds completed/canceled run retention; zero uses the
|
|
40
|
+
// machine default of 30 days.
|
|
41
|
+
RetentionDays int
|
|
42
|
+
// Runtime is copied into every new run's immutable durable snapshot. Nil
|
|
43
|
+
// applies machine defaults (terminals true, sessions true).
|
|
44
|
+
Runtime *run.RuntimePolicy
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Engine is the durable executor. It implements run.Executor and
|
|
48
|
+
// run.RunQueries.
|
|
49
|
+
type Engine struct {
|
|
50
|
+
backend backend.Backend
|
|
51
|
+
db *sql.DB
|
|
52
|
+
client *client.Client
|
|
53
|
+
wfWorker *worker.Worker
|
|
54
|
+
actWorker *worker.Worker
|
|
55
|
+
activities *Activities
|
|
56
|
+
runs *RunProjection
|
|
57
|
+
retention time.Duration
|
|
58
|
+
runtime run.RuntimePolicy
|
|
59
|
+
|
|
60
|
+
mu sync.RWMutex
|
|
61
|
+
snapshots map[run.ID]*workflow.Workflow // in-memory cache; history is authoritative
|
|
62
|
+
|
|
63
|
+
workerCtx context.Context
|
|
64
|
+
workerCancel context.CancelFunc
|
|
65
|
+
shutdownOnce sync.Once
|
|
66
|
+
workerName string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// InitDatabase creates the SQLite database at path (mode 0600) with the
|
|
70
|
+
// relay_runs projection schema and closes it. Used by `relay-flow init`;
|
|
71
|
+
// serve uses New to open the full engine.
|
|
72
|
+
func InitDatabase(path string) error {
|
|
73
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
|
|
74
|
+
if err != nil {
|
|
75
|
+
return fmt.Errorf("open %s: %w", path, err)
|
|
76
|
+
}
|
|
77
|
+
defer db.Close()
|
|
78
|
+
if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
|
|
79
|
+
return fmt.Errorf("open %s: %w", path, err)
|
|
80
|
+
}
|
|
81
|
+
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
|
82
|
+
return fmt.Errorf("open %s: %w", path, err)
|
|
83
|
+
}
|
|
84
|
+
if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
|
|
85
|
+
return fmt.Errorf("chmod %s: %w", path, err)
|
|
86
|
+
}
|
|
87
|
+
proj := &RunProjection{DB: db}
|
|
88
|
+
if err := proj.migrate(); err != nil {
|
|
89
|
+
return fmt.Errorf("migrate relay_runs: %w", err)
|
|
90
|
+
}
|
|
91
|
+
return nil
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// New opens the SQLite database at path (created with mode 0600 when
|
|
95
|
+
// missing), migrates the relay_runs projection, and constructs the engine.
|
|
96
|
+
// A corrupt database file fails here.
|
|
97
|
+
func New(path string, deps Dependencies) (*Engine, error) {
|
|
98
|
+
if deps.Repos == nil || deps.Runner == nil || deps.Harness == nil {
|
|
99
|
+
return nil, fmt.Errorf("goworkflows: Repos, Runner, and Harness dependencies are required")
|
|
100
|
+
}
|
|
101
|
+
if deps.Runtime != nil && deps.Runtime.KeepTerminalsAlive && !deps.Runtime.KeepSessionsAlive {
|
|
102
|
+
return nil, fmt.Errorf("goworkflows: keepTerminalsAlive requires keepSessionsAlive")
|
|
103
|
+
}
|
|
104
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
|
|
105
|
+
if err != nil {
|
|
106
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
107
|
+
}
|
|
108
|
+
// Fail fast on an unusable/corrupt database file.
|
|
109
|
+
if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
|
|
110
|
+
db.Close()
|
|
111
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
112
|
+
}
|
|
113
|
+
// SQLite allows one writer; a single connection acts as the mutex.
|
|
114
|
+
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
|
115
|
+
db.Close()
|
|
116
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
117
|
+
}
|
|
118
|
+
if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil {
|
|
119
|
+
db.Close()
|
|
120
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
121
|
+
}
|
|
122
|
+
db.SetMaxOpenConns(1)
|
|
123
|
+
if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
|
|
124
|
+
db.Close()
|
|
125
|
+
return nil, fmt.Errorf("chmod %s: %w", path, err)
|
|
126
|
+
}
|
|
127
|
+
proj := &RunProjection{DB: db}
|
|
128
|
+
if err := proj.migrate(); err != nil {
|
|
129
|
+
db.Close()
|
|
130
|
+
return nil, fmt.Errorf("migrate relay_runs: %w", err)
|
|
131
|
+
}
|
|
132
|
+
activities := &Activities{
|
|
133
|
+
Repos: deps.Repos,
|
|
134
|
+
Runner: deps.Runner,
|
|
135
|
+
Harness: deps.Harness,
|
|
136
|
+
Runs: proj,
|
|
137
|
+
}
|
|
138
|
+
retention := 30 * 24 * time.Hour
|
|
139
|
+
if deps.RetentionDays > 0 {
|
|
140
|
+
retention = time.Duration(deps.RetentionDays) * 24 * time.Hour
|
|
141
|
+
}
|
|
142
|
+
runtimePolicy := run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true}
|
|
143
|
+
if deps.Runtime != nil {
|
|
144
|
+
runtimePolicy = *deps.Runtime
|
|
145
|
+
}
|
|
146
|
+
return &Engine{
|
|
147
|
+
db: db,
|
|
148
|
+
activities: activities,
|
|
149
|
+
runs: proj,
|
|
150
|
+
retention: retention,
|
|
151
|
+
runtime: runtimePolicy,
|
|
152
|
+
snapshots: map[run.ID]*workflow.Workflow{},
|
|
153
|
+
}, nil
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Start opens the go-workflows SQLite backend on the same database, starts
|
|
157
|
+
// one workflow worker (max 10 parallel workflow tasks) and one activity
|
|
158
|
+
// worker (max 20 parallel activities), runs the startup retention sweep, and
|
|
159
|
+
// resumes pending engine tasks automatically.
|
|
160
|
+
func (e *Engine) Start(ctx context.Context) error {
|
|
161
|
+
e.workerName = "relay-flow-" + uuid.NewString()
|
|
162
|
+
e.backend = sqlite.NewSqliteBackendWithDB(e.db, sqlite.WithApplyMigrations(true),
|
|
163
|
+
sqlite.WithBackendOptions(backend.WithWorkerName(e.workerName)))
|
|
164
|
+
e.client = client.New(e.backend)
|
|
165
|
+
|
|
166
|
+
wfOpts := worker.DefaultOptions.WorkflowWorkerOptions
|
|
167
|
+
wfOpts.MaxParallelWorkflowTasks = 10
|
|
168
|
+
e.wfWorker = worker.NewWorkflowWorker(e.backend, &wfOpts)
|
|
169
|
+
actOpts := worker.DefaultOptions.ActivityWorkerOptions
|
|
170
|
+
actOpts.MaxParallelActivityTasks = 20
|
|
171
|
+
e.actWorker = worker.NewActivityWorker(e.backend, &actOpts)
|
|
172
|
+
if err := e.wfWorker.RegisterWorkflow(e.activities.TicketWorkflow); err != nil {
|
|
173
|
+
return fmt.Errorf("register TicketWorkflow: %w", err)
|
|
174
|
+
}
|
|
175
|
+
if err := e.registerActivities(); err != nil {
|
|
176
|
+
return err
|
|
177
|
+
}
|
|
178
|
+
e.workerCtx, e.workerCancel = context.WithCancel(context.Background())
|
|
179
|
+
if err := e.wfWorker.Start(e.workerCtx); err != nil {
|
|
180
|
+
return fmt.Errorf("start workflow worker: %w", err)
|
|
181
|
+
}
|
|
182
|
+
if err := e.actWorker.Start(e.workerCtx); err != nil {
|
|
183
|
+
return fmt.Errorf("start activity worker: %w", err)
|
|
184
|
+
}
|
|
185
|
+
// Startup retention sweep (pre-poller window): remove old terminal
|
|
186
|
+
// projection rows and their engine histories; nonterminal runs stay.
|
|
187
|
+
cutoff := time.Now().Add(-e.retention)
|
|
188
|
+
ids, err := e.runs.sweepRetention(ctx, cutoff)
|
|
189
|
+
if err != nil {
|
|
190
|
+
return fmt.Errorf("retention sweep: %w", err)
|
|
191
|
+
}
|
|
192
|
+
if len(ids) > 0 {
|
|
193
|
+
if err := e.client.RemoveWorkflowInstances(ctx, backend.RemoveFinishedBefore(cutoff)); err != nil {
|
|
194
|
+
return fmt.Errorf("remove finished engine histories: %w", err)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return nil
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func (e *Engine) registerActivities() error {
|
|
201
|
+
a := e.activities
|
|
202
|
+
for _, act := range []goworkflow.Activity{
|
|
203
|
+
a.EnsureMailboxes,
|
|
204
|
+
a.ValidateAgents,
|
|
205
|
+
a.ApplyTaskConfig,
|
|
206
|
+
a.EnsureEnvironment,
|
|
207
|
+
a.LoadNodeRuntime,
|
|
208
|
+
a.EnsureNodeRuntime,
|
|
209
|
+
a.CloseTerminals,
|
|
210
|
+
a.CleanupRun,
|
|
211
|
+
a.CheckpointNodeRuntime,
|
|
212
|
+
a.FinalizeNodeRuntimes,
|
|
213
|
+
a.Comment,
|
|
214
|
+
a.CompleteMailbox,
|
|
215
|
+
a.ProjectionUpdateNodeRuntimeVisit,
|
|
216
|
+
a.ProjectionRecordProcessedReport,
|
|
217
|
+
a.ProjectionUpdateNode,
|
|
218
|
+
a.ProjectionUpdateState,
|
|
219
|
+
a.ProjectionUpdateRetry,
|
|
220
|
+
} {
|
|
221
|
+
if err := e.actWorker.RegisterActivity(act); err != nil {
|
|
222
|
+
return fmt.Errorf("register activity: %w", err)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return nil
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Shutdown cancels worker polling, waits a bounded time for active tasks,
|
|
229
|
+
// and closes SQLite. It is safe to call more than once.
|
|
230
|
+
func (e *Engine) Shutdown(ctx context.Context) error {
|
|
231
|
+
var err error
|
|
232
|
+
e.shutdownOnce.Do(func() {
|
|
233
|
+
if e.workerCancel != nil {
|
|
234
|
+
e.workerCancel()
|
|
235
|
+
}
|
|
236
|
+
// wfWorker/actWorker are nil until Start; tolerate Shutdown before
|
|
237
|
+
// Start so fail-fast startup validation can release the database.
|
|
238
|
+
if e.wfWorker != nil && e.actWorker != nil {
|
|
239
|
+
done := make(chan struct{}, 2)
|
|
240
|
+
go func() { _ = e.wfWorker.WaitForCompletion(); done <- struct{}{} }()
|
|
241
|
+
go func() { _ = e.actWorker.WaitForCompletion(); done <- struct{}{} }()
|
|
242
|
+
for i := 0; i < 2; i++ {
|
|
243
|
+
select {
|
|
244
|
+
case <-ctx.Done():
|
|
245
|
+
i = 2
|
|
246
|
+
case <-done:
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Release this worker's workflow-task leases: a stopped worker must
|
|
251
|
+
// not hold instances hostage until the lock timeout. Leases are
|
|
252
|
+
// crash-recovery primitives; the next engine re-locks on pickup.
|
|
253
|
+
if e.workerName != "" {
|
|
254
|
+
_, _ = e.db.Exec(`UPDATE instances SET locked_until = NULL, sticky_until = NULL, worker = NULL WHERE worker = ?`, e.workerName)
|
|
255
|
+
}
|
|
256
|
+
err = e.db.Close()
|
|
257
|
+
})
|
|
258
|
+
return err
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// EnsureRun creates the durable run when missing (created=true). For an
|
|
262
|
+
// existing active run it reconciles the current node terminal by stable
|
|
263
|
+
// title and sends the reconcile signal only when that terminal is missing
|
|
264
|
+
// or unusable. Repeated polls are harmless.
|
|
265
|
+
func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
|
|
266
|
+
r, err := e.runs.get(ctx, start.ID)
|
|
267
|
+
if errors.Is(err, errRunNotFound) {
|
|
268
|
+
start.Runtime = e.runtime
|
|
269
|
+
if err := e.runs.insertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
270
|
+
return false, fmt.Errorf("insert run %s: %w", start.ID, err)
|
|
271
|
+
}
|
|
272
|
+
_, err = e.client.CreateWorkflowInstance(ctx,
|
|
273
|
+
client.WorkflowInstanceOptions{InstanceID: string(start.ID)},
|
|
274
|
+
e.activities.TicketWorkflow, start)
|
|
275
|
+
if err != nil {
|
|
276
|
+
if errors.Is(err, backend.ErrInstanceAlreadyExists) {
|
|
277
|
+
return false, nil
|
|
278
|
+
}
|
|
279
|
+
return false, fmt.Errorf("create workflow instance %s: %w", start.ID, err)
|
|
280
|
+
}
|
|
281
|
+
e.mu.Lock()
|
|
282
|
+
wf := start.Workflow
|
|
283
|
+
e.snapshots[start.ID] = &wf
|
|
284
|
+
e.mu.Unlock()
|
|
285
|
+
// 9.3 run-lifecycle logging: one info line on run creation.
|
|
286
|
+
slog.Info("run created",
|
|
287
|
+
"ticket", start.Ticket.Key, "runID", string(start.ID),
|
|
288
|
+
"repo", start.Repo, "workflow", wf.Name)
|
|
289
|
+
return true, nil
|
|
290
|
+
}
|
|
291
|
+
if err != nil {
|
|
292
|
+
return false, err
|
|
293
|
+
}
|
|
294
|
+
// Existing run: reconcile only an active run at a work node.
|
|
295
|
+
if r.State == run.StateCompleted || r.State == run.StateCanceled || r.State == run.StateCanceling {
|
|
296
|
+
return false, nil
|
|
297
|
+
}
|
|
298
|
+
if r.CurrentNode == "" || r.CurrentNodeVisitID == "" {
|
|
299
|
+
return false, nil
|
|
300
|
+
}
|
|
301
|
+
runtime, err := e.runs.getNodeRuntime(ctx, r.ID, r.CurrentNode)
|
|
302
|
+
if err != nil && !errors.Is(err, errNodeRuntimeNotFound) {
|
|
303
|
+
return false, fmt.Errorf("load runtime for %s/%s: %w", r.ID, r.CurrentNode, err)
|
|
304
|
+
}
|
|
305
|
+
ok := false
|
|
306
|
+
if runtime.TerminalID != "" {
|
|
307
|
+
_, ok, _ = e.activities.Runner.InspectTerminal(ctx, runner.Terminal{
|
|
308
|
+
ID: runtime.TerminalID, Title: r.Ticket.Key + ":" + r.CurrentNode,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
if ok {
|
|
312
|
+
return false, nil // live usable terminal: no reconcile, no relaunch
|
|
313
|
+
}
|
|
314
|
+
if err := e.client.SignalWorkflow(ctx, string(r.ID), reconcileSignal, struct{}{}); err != nil {
|
|
315
|
+
return false, fmt.Errorf("signal reconcile for %s: %w", r.ID, err)
|
|
316
|
+
}
|
|
317
|
+
return false, nil
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// SubmitReport drops processed report IDs immediately. New reports are
|
|
321
|
+
// validated and acknowledged only after their workflow signal is durable.
|
|
322
|
+
//
|
|
323
|
+
// 9.4 report-path logging: one info line per event on the report path —
|
|
324
|
+
// received, duplicate ack, validation failure, signal persisted, ack sent.
|
|
325
|
+
// Attrs always carry ticket/runID/node/nodeVisitID when known.
|
|
326
|
+
func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.ReportAck, error) {
|
|
327
|
+
r, err := e.runs.get(ctx, req.RunID)
|
|
328
|
+
if err != nil {
|
|
329
|
+
return run.ReportAck{}, fmt.Errorf("resolve run %s: %w", req.RunID, err)
|
|
330
|
+
}
|
|
331
|
+
attrs := []any{
|
|
332
|
+
"ticket", r.Ticket.Key, "runID", string(req.RunID),
|
|
333
|
+
"repo", r.Repo, "workflow", r.Workflow,
|
|
334
|
+
"node", req.Node, "reportID", req.ReportID,
|
|
335
|
+
}
|
|
336
|
+
processed, err := e.runs.hasProcessedReport(ctx, req.RunID, req.ReportID)
|
|
337
|
+
if err != nil {
|
|
338
|
+
return run.ReportAck{}, fmt.Errorf("check report %s: %w", req.ReportID, err)
|
|
339
|
+
}
|
|
340
|
+
if processed {
|
|
341
|
+
slog.Info("report duplicate ack", append(attrs, "state", string(r.State))...)
|
|
342
|
+
return run.ReportAck{Accepted: true, Duplicate: true}, nil
|
|
343
|
+
}
|
|
344
|
+
slog.Info("report received", append(attrs,
|
|
345
|
+
"status", string(req.Report.Status), "nextStep", req.Report.NextStep)...)
|
|
346
|
+
|
|
347
|
+
current := r.CurrentNodeVisitID != "" && req.Node == r.CurrentNode &&
|
|
348
|
+
r.State != run.StateCompleted && r.State != run.StateCanceled
|
|
349
|
+
if !current {
|
|
350
|
+
slog.Info("report duplicate ack", append(attrs, "state", string(r.State))...)
|
|
351
|
+
return run.ReportAck{Accepted: true, Duplicate: true}, nil
|
|
352
|
+
}
|
|
353
|
+
wf, err := e.workflowOf(ctx, req.RunID)
|
|
354
|
+
if err != nil {
|
|
355
|
+
return run.ReportAck{}, err
|
|
356
|
+
}
|
|
357
|
+
if err := wf.ValidateReport(req.Node, req.Report); err != nil {
|
|
358
|
+
slog.Info("report validation failed", append(attrs, "reason", err.Error())...)
|
|
359
|
+
return run.ReportAck{Accepted: false}, err
|
|
360
|
+
}
|
|
361
|
+
signal := reportSignal{
|
|
362
|
+
ReportID: req.ReportID, Node: req.Node,
|
|
363
|
+
NodeVisitID: r.CurrentNodeVisitID, Report: req.Report,
|
|
364
|
+
}
|
|
365
|
+
if err := e.client.SignalWorkflow(ctx, string(req.RunID), reportSignalName, signal); err != nil {
|
|
366
|
+
return run.ReportAck{}, fmt.Errorf("signal report %s for %s: %w", req.ReportID, req.RunID, err)
|
|
367
|
+
}
|
|
368
|
+
attrs = append(attrs, "nodeVisitID", string(r.CurrentNodeVisitID))
|
|
369
|
+
// 9.3 transition effect + 9.4 report path: durable signal persisted
|
|
370
|
+
// (ack only after persistence per the report contract). One info line
|
|
371
|
+
// on the first accepted signal; duplicate/stale acks above skip this.
|
|
372
|
+
slog.Info("report persisted", append(attrs, "node", r.CurrentNode)...)
|
|
373
|
+
slog.Info("report ack sent", append(attrs, "node", r.CurrentNode)...)
|
|
374
|
+
return run.ReportAck{Accepted: true}, nil
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// HasProcessedReport supports the server's payload-independent duplicate
|
|
378
|
+
// short circuit.
|
|
379
|
+
func (e *Engine) HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
|
|
380
|
+
return e.runs.hasProcessedReport(ctx, id, reportID)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// RegisterNodeSession persists the OpenCode session for its stable run/node.
|
|
384
|
+
func (e *Engine) RegisterNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (run.NodeRuntimeRegistrationAck, error) {
|
|
385
|
+
accepted, err := e.runs.registerNodeSession(ctx, registration)
|
|
386
|
+
if err != nil {
|
|
387
|
+
return run.NodeRuntimeRegistrationAck{}, err
|
|
388
|
+
}
|
|
389
|
+
return run.NodeRuntimeRegistrationAck{Accepted: accepted}, nil
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// GetNodeRuntime returns one persisted per-node runtime binding.
|
|
393
|
+
func (e *Engine) GetNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
394
|
+
return e.runs.getNodeRuntime(ctx, id, node)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// workflowOf returns the run's immutable workflow snapshot: the in-memory
|
|
398
|
+
// cache when present, otherwise decoded from the instance's started-event
|
|
399
|
+
// inputs in durable history.
|
|
400
|
+
func (e *Engine) workflowOf(ctx context.Context, id run.ID) (*workflow.Workflow, error) {
|
|
401
|
+
e.mu.RLock()
|
|
402
|
+
wf, ok := e.snapshots[id]
|
|
403
|
+
e.mu.RUnlock()
|
|
404
|
+
if ok {
|
|
405
|
+
return wf, nil
|
|
406
|
+
}
|
|
407
|
+
inst, err := e.instance(ctx, id)
|
|
408
|
+
if err != nil {
|
|
409
|
+
return nil, err
|
|
410
|
+
}
|
|
411
|
+
events, err := e.backend.GetWorkflowInstanceHistory(ctx, inst, nil)
|
|
412
|
+
if err != nil {
|
|
413
|
+
return nil, fmt.Errorf("load history for %s: %w", id, err)
|
|
414
|
+
}
|
|
415
|
+
for _, ev := range events {
|
|
416
|
+
if ev.Type != history.EventType_WorkflowExecutionStarted {
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
attr, ok := ev.Attributes.(*history.ExecutionStartedAttributes)
|
|
420
|
+
if !ok || len(attr.Inputs) == 0 {
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
var start run.Start
|
|
424
|
+
if err := converter.DefaultConverter.From(attr.Inputs[0], &start); err != nil {
|
|
425
|
+
return nil, fmt.Errorf("decode snapshot for %s: %w", id, err)
|
|
426
|
+
}
|
|
427
|
+
w := start.Workflow
|
|
428
|
+
e.mu.Lock()
|
|
429
|
+
e.snapshots[id] = &w
|
|
430
|
+
e.mu.Unlock()
|
|
431
|
+
return &w, nil
|
|
432
|
+
}
|
|
433
|
+
return nil, fmt.Errorf("no workflow snapshot in history for run %s", id)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// CancelRun cancels the workflow instance; cleanup runs on a disconnected
|
|
437
|
+
// workflow context and cannot interrupt an already-running activity.
|
|
438
|
+
func (e *Engine) CancelRun(ctx context.Context, id run.ID, reason string) error {
|
|
439
|
+
if _, err := e.runs.get(ctx, id); err != nil {
|
|
440
|
+
return fmt.Errorf("resolve run %s: %w", id, err)
|
|
441
|
+
}
|
|
442
|
+
if err := e.runs.updateState(ctx, id, run.StateCanceling, reason, nil); err != nil {
|
|
443
|
+
return err
|
|
444
|
+
}
|
|
445
|
+
inst, err := e.instance(ctx, id)
|
|
446
|
+
if err != nil {
|
|
447
|
+
return fmt.Errorf("cancel %s: %w", id, err)
|
|
448
|
+
}
|
|
449
|
+
if err := e.client.CancelWorkflowInstance(ctx, inst); err != nil {
|
|
450
|
+
return fmt.Errorf("cancel %s: %w", id, err)
|
|
451
|
+
}
|
|
452
|
+
return nil
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// instance resolves the current execution for the durable run ID.
|
|
456
|
+
func (e *Engine) instance(ctx context.Context, id run.ID) (*goworkflow.Instance, error) {
|
|
457
|
+
var execID string
|
|
458
|
+
err := e.db.QueryRowContext(ctx,
|
|
459
|
+
`SELECT execution_id FROM instances WHERE id = ? AND state = ? ORDER BY rowid DESC LIMIT 1`,
|
|
460
|
+
string(id), 0).Scan(&execID)
|
|
461
|
+
if err != nil {
|
|
462
|
+
return nil, fmt.Errorf("workflow instance %s not found: %w", id, err)
|
|
463
|
+
}
|
|
464
|
+
return &goworkflow.Instance{InstanceID: string(id), ExecutionID: execID}, nil
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- run.RunQueries ---
|
|
468
|
+
|
|
469
|
+
func (e *Engine) GetRun(ctx context.Context, id run.ID) (run.Run, error) {
|
|
470
|
+
return e.runs.get(ctx, id)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
func (e *Engine) FindRunByTicket(ctx context.Context, ticket string) (run.Run, error) {
|
|
474
|
+
return e.runs.findByTicket(ctx, ticket)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
func (e *Engine) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
|
|
478
|
+
return e.runs.list(ctx, filter)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
func (e *Engine) HasActiveWorkflow(ctx context.Context, wf string) (bool, error) {
|
|
482
|
+
return e.runs.hasActive(ctx, "workflow", wf)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
func (e *Engine) HasActiveRepo(ctx context.Context, repo string) (bool, error) {
|
|
486
|
+
return e.runs.hasActive(ctx, "repo", repo)
|
|
487
|
+
}
|