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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. 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
+ }