relay-flow 0.3.8-alpha → 0.3.10-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 +27 -19
- package/cmd/relay-flow/commands_test.go +440 -21
- package/cmd/relay-flow/main.go +203 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/internal/execution/goworkflows/activities.go +11 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +30 -1
- package/internal/execution/goworkflows/interpreter.go +18 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +563 -1
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/interpreter.go +2 -1
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +1 -0
- package/internal/harness/opencode/opencode.go +5 -0
- package/internal/harness/opencode/opencode_test.go +17 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +6 -0
- package/internal/harness/pi/task_env_test.go +16 -0
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +228 -4
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +66 -0
- package/internal/workflow/report.go +3 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- package/package.json +1 -1
|
@@ -257,24 +257,69 @@ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.T
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
|
|
260
|
+
_, err := p.updateStateCAS(ctx, id, nil, state, lastErr, finished)
|
|
261
|
+
return err
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// beginCancellation atomically claims cancellation of a nonterminal run. A
|
|
265
|
+
// run already in canceling keeps its original reason; completed/canceled runs
|
|
266
|
+
// are never moved backwards. The returned row is read after the conditional
|
|
267
|
+
// update so concurrent cancel/complete callers observe the winner.
|
|
268
|
+
func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
|
|
269
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
270
|
+
UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?
|
|
271
|
+
WHERE id = ? AND state NOT IN ('completed', 'canceled', 'canceling')`,
|
|
272
|
+
string(run.StateCanceling), reason, time.Now().UTC(), string(id))
|
|
273
|
+
if err != nil {
|
|
274
|
+
return run.Run{}, err
|
|
275
|
+
}
|
|
276
|
+
return p.get(ctx, id)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func (p *RunProjection) updateStateCAS(ctx context.Context, id run.ID, expected *run.State, state run.State, lastErr string, finished *time.Time) (bool, error) {
|
|
260
280
|
terminal := state == run.StateCompleted || state == run.StateCanceled
|
|
261
281
|
now := time.Now().UTC()
|
|
262
|
-
|
|
282
|
+
query := `
|
|
263
283
|
UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
|
|
264
284
|
retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
|
|
265
285
|
retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
|
|
266
286
|
next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
|
|
267
|
-
WHERE id =
|
|
268
|
-
|
|
287
|
+
WHERE id = ?`
|
|
288
|
+
args := []any{
|
|
289
|
+
string(state), lastErr, now, finished, terminal, terminal, terminal,
|
|
290
|
+
string(id),
|
|
291
|
+
}
|
|
292
|
+
if expected == nil {
|
|
293
|
+
// Ordinary workflow projection updates are fenced after cancellation or
|
|
294
|
+
// any terminal state has won. Explicit compare-and-set callers below
|
|
295
|
+
// are allowed to reconcile a known canceling row to its inspected
|
|
296
|
+
// terminal engine result.
|
|
297
|
+
query += ` AND NOT (
|
|
298
|
+
(state = 'canceling' AND ? NOT IN ('canceling', 'canceled'))
|
|
299
|
+
OR (state IN ('completed', 'canceled') AND state <> ?)
|
|
300
|
+
)`
|
|
301
|
+
args = append(args, string(state), string(state))
|
|
302
|
+
} else {
|
|
303
|
+
query += ` AND state = ? AND NOT (state IN ('completed', 'canceled') AND state <> ?)`
|
|
304
|
+
args = append(args, string(*expected), string(state))
|
|
305
|
+
}
|
|
306
|
+
result, err := p.DB.ExecContext(ctx, query, args...)
|
|
269
307
|
if err != nil {
|
|
270
|
-
return err
|
|
308
|
+
return false, err
|
|
309
|
+
}
|
|
310
|
+
updated, err := result.RowsAffected()
|
|
311
|
+
if err != nil {
|
|
312
|
+
return false, err
|
|
313
|
+
}
|
|
314
|
+
if updated != 1 {
|
|
315
|
+
return false, nil
|
|
271
316
|
}
|
|
272
317
|
// Keep the derived timeline useful during retries and cancellation without
|
|
273
318
|
// making it an execution authority. Identify one active row first; a
|
|
274
319
|
// repeated terminal update with no active row is a no-op for the timeline.
|
|
275
320
|
stepStatus := stepStatusForRunState(state)
|
|
276
321
|
if stepStatus == "" {
|
|
277
|
-
return nil
|
|
322
|
+
return true, nil
|
|
278
323
|
}
|
|
279
324
|
var sequence int64
|
|
280
325
|
var startedAt sql.NullTime
|
|
@@ -283,14 +328,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
283
328
|
WHERE run_id = ? AND status IN ('running', 'waiting', 'blocked')
|
|
284
329
|
ORDER BY sequence DESC LIMIT 1`, string(id)).Scan(&sequence, &startedAt)
|
|
285
330
|
if errors.Is(lookupErr, sql.ErrNoRows) {
|
|
286
|
-
return nil
|
|
331
|
+
return true, nil
|
|
287
332
|
}
|
|
288
333
|
if lookupErr != nil {
|
|
289
334
|
// relay_run_steps is display/cache data. The authoritative relay_runs
|
|
290
335
|
// state update above has already succeeded and must not be retried just
|
|
291
336
|
// because this optional projection is unavailable.
|
|
292
337
|
slog.Warn("step projection state lookup unavailable", "runID", string(id), "state", state, "error", lookupErr)
|
|
293
|
-
return nil
|
|
338
|
+
return true, nil
|
|
294
339
|
}
|
|
295
340
|
var stepFinished any
|
|
296
341
|
if terminal {
|
|
@@ -300,7 +345,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
300
345
|
stepFinished = now
|
|
301
346
|
}
|
|
302
347
|
}
|
|
303
|
-
result, err
|
|
348
|
+
result, err = p.DB.ExecContext(ctx, `
|
|
304
349
|
UPDATE relay_run_steps SET status = ?, message = CASE WHEN ? <> '' THEN ? ELSE message END,
|
|
305
350
|
finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END
|
|
306
351
|
WHERE run_id = ? AND sequence = ? AND status IN ('running', 'waiting', 'blocked')`,
|
|
@@ -308,11 +353,11 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
308
353
|
string(id), sequence)
|
|
309
354
|
if err != nil {
|
|
310
355
|
slog.Warn("step projection state update unavailable", "runID", string(id), "state", state, "error", err)
|
|
311
|
-
return nil
|
|
356
|
+
return true, nil
|
|
312
357
|
}
|
|
313
|
-
updated, err
|
|
358
|
+
updated, err = result.RowsAffected()
|
|
314
359
|
if err != nil || updated != 1 {
|
|
315
|
-
return nil
|
|
360
|
+
return true, nil
|
|
316
361
|
}
|
|
317
362
|
if terminal && startedAt.Valid {
|
|
318
363
|
endAt := now
|
|
@@ -327,7 +372,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
327
372
|
slog.Warn("step projection duration update unavailable", "runID", string(id), "state", state, "error", durationErr)
|
|
328
373
|
}
|
|
329
374
|
}
|
|
330
|
-
return nil
|
|
375
|
+
return true, nil
|
|
331
376
|
}
|
|
332
377
|
|
|
333
378
|
func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
@@ -352,12 +397,20 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
|
|
|
352
397
|
}
|
|
353
398
|
defer tx.Rollback()
|
|
354
399
|
now := time.Now().UTC()
|
|
355
|
-
|
|
400
|
+
result, err := tx.ExecContext(ctx, `
|
|
356
401
|
UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
|
|
357
|
-
WHERE id =
|
|
358
|
-
string(state), node, string(visit), now, string(id))
|
|
402
|
+
WHERE id = ? AND state NOT IN ('canceling', 'completed', 'canceled')`,
|
|
403
|
+
string(state), node, string(visit), now, string(id))
|
|
404
|
+
if err != nil {
|
|
359
405
|
return err
|
|
360
406
|
}
|
|
407
|
+
updated, err := result.RowsAffected()
|
|
408
|
+
if err != nil {
|
|
409
|
+
return err
|
|
410
|
+
}
|
|
411
|
+
if updated != 1 {
|
|
412
|
+
return tx.Commit()
|
|
413
|
+
}
|
|
361
414
|
// A revisit changes only the latest visit ID. Reusable terminal/session
|
|
362
415
|
// identities remain attached to this run/node row.
|
|
363
416
|
if _, err := tx.ExecContext(ctx, `
|
|
@@ -907,6 +960,18 @@ func (p *RunProjection) UpdateState(ctx context.Context, id run.ID, state run.St
|
|
|
907
960
|
return p.updateState(ctx, id, state, lastErr, finished)
|
|
908
961
|
}
|
|
909
962
|
|
|
963
|
+
// BeginCancellation atomically moves a nonterminal run to canceling and
|
|
964
|
+
// returns the persisted row. Existing canceling rows retain their reason.
|
|
965
|
+
func (p *RunProjection) BeginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
|
|
966
|
+
return p.beginCancellation(ctx, id, reason)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// UpdateStateIf applies a lifecycle transition only while the row has the
|
|
970
|
+
// expected state. It returns false when another transition won the race.
|
|
971
|
+
func (p *RunProjection) UpdateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
|
|
972
|
+
return p.updateStateCAS(ctx, id, &expected, state, lastErr, finished)
|
|
973
|
+
}
|
|
974
|
+
|
|
910
975
|
// UpdateRetry updates or clears active retry metadata.
|
|
911
976
|
func (p *RunProjection) UpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
912
977
|
return p.updateRetry(ctx, id, status)
|
|
@@ -402,7 +402,8 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
402
402
|
spec := harness.LaunchSpec{
|
|
403
403
|
RunID: start.ID, NodeVisitID: visit, RepoName: start.Repo, RepoPath: start.RepoPath,
|
|
404
404
|
Workflow: wf.Name, Ticket: start.Ticket.Key, Node: current, NodeType: node.Type,
|
|
405
|
-
|
|
405
|
+
AutoReject: node.AutoReject,
|
|
406
|
+
Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
|
|
406
407
|
PromptData: harness.PromptData{
|
|
407
408
|
TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
|
|
408
409
|
Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
|
|
@@ -6,10 +6,12 @@ import (
|
|
|
6
6
|
"fmt"
|
|
7
7
|
"log/slog"
|
|
8
8
|
"sort"
|
|
9
|
-
"strings"
|
|
10
9
|
"time"
|
|
11
10
|
|
|
12
11
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/router"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
15
|
enumspb "go.temporal.io/api/enums/v1"
|
|
14
16
|
"go.temporal.io/api/serviceerror"
|
|
15
17
|
workflowpb "go.temporal.io/api/workflow/v1"
|
|
@@ -145,52 +147,82 @@ func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string
|
|
|
145
147
|
slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
146
148
|
continue
|
|
147
149
|
}
|
|
150
|
+
capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
|
|
151
|
+
if !ok {
|
|
152
|
+
slog.Warn("Temporal recovery skipping repository without ownership capabilities", "repo", rp.Name,
|
|
153
|
+
"outcome", "ownership-capability-missing")
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
148
156
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
149
157
|
if err != nil {
|
|
150
158
|
return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
|
|
151
159
|
}
|
|
152
160
|
for _, ticket := range tickets {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
|
|
169
|
-
if visible[string(expectedID)] {
|
|
170
|
-
continue
|
|
161
|
+
// Projection reconciliation only considers claimed parents. Resolve
|
|
162
|
+
// through the same router as normal polling so ambiguous, unknown,
|
|
163
|
+
// and owner-mismatched claims cannot restore an execution.
|
|
164
|
+
if len(ticket.WorkflowClaims) != 1 {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
wf, err := router.ResolveWorkflow(rp, ticket)
|
|
168
|
+
if err != nil {
|
|
169
|
+
var ownerMismatch *router.ClaimOwnerMismatchError
|
|
170
|
+
if errors.As(err, &ownerMismatch) {
|
|
171
|
+
slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
172
|
+
"workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch")
|
|
173
|
+
} else {
|
|
174
|
+
slog.Warn("Temporal recovery skipped claimed ticket", "repo", rp.Name, "ticket", ticket.Key, "outcome", "invalid-claim", "error", err)
|
|
171
175
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
workflowName := wf.Name
|
|
179
|
+
var matched *repo.WorkflowBinding
|
|
180
|
+
for _, binding := range rp.Bindings() {
|
|
181
|
+
if binding.Workflow != nil && binding.Workflow.Name == workflowName {
|
|
182
|
+
candidate := binding
|
|
183
|
+
matched = &candidate
|
|
184
|
+
break
|
|
183
185
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
}
|
|
187
|
+
if matched == nil {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
// Existing Temporal executions are restored only after the same
|
|
191
|
+
// ownership gate used by normal claimed routing. Lifecycle
|
|
192
|
+
// filters remain intentionally bypassed.
|
|
193
|
+
if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), workflowName, matched.Workflow.TaskConfig); err != nil {
|
|
194
|
+
if errors.Is(err, task.ErrOwnershipMismatch) {
|
|
195
|
+
slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
196
|
+
"workflow", workflowName, "outcome", "claim-owner-mismatch", "error", err)
|
|
186
197
|
continue
|
|
187
198
|
}
|
|
188
|
-
|
|
199
|
+
return fmt.Errorf("poll repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
|
|
200
|
+
}
|
|
201
|
+
expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
|
|
202
|
+
if visible[string(expectedID)] {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
|
|
206
|
+
if err != nil {
|
|
207
|
+
var notFound *serviceerror.NotFound
|
|
208
|
+
if errors.As(err, ¬Found) {
|
|
209
|
+
// This is the documented claim-before-run gap. Record it
|
|
210
|
+
// for operators, but leave creation to normal polling after
|
|
211
|
+
// recovery; this path must never start a replacement.
|
|
212
|
+
slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
|
|
189
213
|
continue
|
|
190
214
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
215
|
+
return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
|
|
216
|
+
}
|
|
217
|
+
if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
|
|
218
|
+
info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
|
|
225
|
+
return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
|
|
194
226
|
}
|
|
195
227
|
}
|
|
196
228
|
}
|
|
@@ -17,6 +17,7 @@ import (
|
|
|
17
17
|
"os/exec"
|
|
18
18
|
"path/filepath"
|
|
19
19
|
"regexp"
|
|
20
|
+
"strconv"
|
|
20
21
|
"strings"
|
|
21
22
|
|
|
22
23
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
@@ -206,6 +207,10 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
|
|
|
206
207
|
} {
|
|
207
208
|
env[key] = value
|
|
208
209
|
}
|
|
210
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
211
|
+
// HITL policy is launch metadata, not part of the report wire object.
|
|
212
|
+
env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
|
|
213
|
+
}
|
|
209
214
|
args := []string{}
|
|
210
215
|
if spec.ResumeID != "" {
|
|
211
216
|
args = append(args, "--session", spec.ResumeID)
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.3.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.10-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -57,6 +57,22 @@ func TestBuildCommandArgv(t *testing.T) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
|
|
61
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
62
|
+
cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
|
|
63
|
+
NodeType: workflow.NodeHITL,
|
|
64
|
+
AutoReject: true,
|
|
65
|
+
Agent: "reviewer",
|
|
66
|
+
Prompt: "review the ticket",
|
|
67
|
+
})
|
|
68
|
+
if err != nil {
|
|
69
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
70
|
+
}
|
|
71
|
+
if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
|
|
72
|
+
t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
61
77
|
raw := config.RawValues{
|
|
62
78
|
"initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
|
|
@@ -13,11 +13,13 @@ import (
|
|
|
13
13
|
"os/exec"
|
|
14
14
|
"path/filepath"
|
|
15
15
|
"regexp"
|
|
16
|
+
"strconv"
|
|
16
17
|
"strings"
|
|
17
18
|
|
|
18
19
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
19
20
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
20
21
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
22
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
const (
|
|
@@ -214,6 +216,10 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
214
216
|
} {
|
|
215
217
|
env[key] = value
|
|
216
218
|
}
|
|
219
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
220
|
+
// HITL policy is launch metadata, not part of the report wire object.
|
|
221
|
+
env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
|
|
222
|
+
}
|
|
217
223
|
args := []string{"--name", spec.Title}
|
|
218
224
|
if spec.Agent != "default" {
|
|
219
225
|
args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
|
|
@@ -2,8 +2,24 @@ package pi
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
5
7
|
)
|
|
6
8
|
|
|
9
|
+
func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
|
|
10
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
11
|
+
spec := launchSpec(t)
|
|
12
|
+
spec.NodeType = workflow.NodeHITL
|
|
13
|
+
spec.AutoReject = true
|
|
14
|
+
cmd, err := (&Harness{}).BuildCommand(spec)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
17
|
+
}
|
|
18
|
+
if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
|
|
19
|
+
t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
// TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
|
|
8
24
|
// workspace environment reaches the runner command so agent task commands
|
|
9
25
|
// address the same workspace as relay-flow, and that relay-flow's own
|
|
@@ -50,6 +50,12 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
|
|
|
50
50
|
slog.Warn("recover: skip repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
51
51
|
continue
|
|
52
52
|
}
|
|
53
|
+
capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
|
|
54
|
+
if !ok {
|
|
55
|
+
slog.Warn("recover: skip repository without ownership capabilities", "repo", rp.Name,
|
|
56
|
+
"outcome", "ownership-capability-missing")
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
53
59
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
54
60
|
if err != nil {
|
|
55
61
|
return fmt.Errorf("repo %q poll: %w", rp.Name, err)
|
|
@@ -65,9 +71,32 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
|
|
|
65
71
|
continue
|
|
66
72
|
}
|
|
67
73
|
if err != nil {
|
|
68
|
-
|
|
74
|
+
var ownerMismatch *router.ClaimOwnerMismatchError
|
|
75
|
+
var invalid *router.InvalidClaimError
|
|
76
|
+
if errors.As(err, &ownerMismatch) {
|
|
77
|
+
slog.Info("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
78
|
+
"workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch", "error", err)
|
|
79
|
+
} else if errors.As(err, &invalid) {
|
|
80
|
+
slog.Warn("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
81
|
+
"workflow", invalid.Workflow, "outcome", "invalid-claim", "error", err)
|
|
82
|
+
} else {
|
|
83
|
+
slog.Warn("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
84
|
+
"outcome", "error", "error", err)
|
|
85
|
+
}
|
|
69
86
|
continue
|
|
70
87
|
}
|
|
88
|
+
// Router ownership uses the poll snapshot. Re-read the current
|
|
89
|
+
// provider-owned ticket before any recovery mutation so a
|
|
90
|
+
// poll-to-recovery reassignment cannot close terminals or reset
|
|
91
|
+
// mailboxes for the wrong owner.
|
|
92
|
+
if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), wf.Name, wf.TaskConfig); err != nil {
|
|
93
|
+
if errors.Is(err, task.ErrOwnershipMismatch) {
|
|
94
|
+
slog.Info("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
95
|
+
"workflow", wf.Name, "outcome", "claim-owner-mismatch", "error", err)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
return fmt.Errorf("repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
|
|
99
|
+
}
|
|
71
100
|
// Skip canceled parents: the cancellation marker is the
|
|
72
101
|
// task-system recovery record.
|
|
73
102
|
runID := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package repo
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"errors"
|
|
5
6
|
"strings"
|
|
6
7
|
"testing"
|
|
@@ -15,6 +16,21 @@ type bindingTaskSystem struct {
|
|
|
15
16
|
compileErr map[string]error
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
type ownershipBindingTaskSystem struct{ bindingTaskSystem }
|
|
20
|
+
|
|
21
|
+
type legacyBindingTaskSystem struct{ task.System }
|
|
22
|
+
|
|
23
|
+
func (legacyBindingTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
24
|
+
return func(task.Ticket) bool { return true }, nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (s ownershipBindingTaskSystem) CompileOwnershipFilter(values config.RawValues) (func(task.Ticket) bool, error) {
|
|
28
|
+
owner, _ := values["owner"].(string)
|
|
29
|
+
return func(ticket task.Ticket) bool {
|
|
30
|
+
return owner == "" || ticket.Fields["assignee"] == owner
|
|
31
|
+
}, nil
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Ticket) bool, error) {
|
|
19
35
|
if name, ok := values["name"].(string); ok {
|
|
20
36
|
if err := s.compileErr[name]; err != nil {
|
|
@@ -25,6 +41,53 @@ func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Tic
|
|
|
25
41
|
return func(task.Ticket) bool { return true }, nil
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
func (s bindingTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
45
|
+
return func(task.Ticket) bool { return true }, nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (s bindingTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func (s bindingTaskSystem) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflow string, _ config.RawValues) error {
|
|
53
|
+
return s.Claim(ctx, ticket, workflow)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestBindWorkflowsRejectsMissingOwnershipCapabilities(t *testing.T) {
|
|
57
|
+
registered := &Repo{Name: "payments", TaskSystem: legacyBindingTaskSystem{}}
|
|
58
|
+
registry := NewRegistry()
|
|
59
|
+
registry.Replace(registered)
|
|
60
|
+
wf := &workflow.Workflow{Name: "legacyFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
61
|
+
if err := registry.BindWorkflows([]*workflow.Workflow{wf}); err == nil || !strings.Contains(err.Error(), "ownership capabilities") {
|
|
62
|
+
t.Fatalf("BindWorkflows error = %v, want missing-capability failure", err)
|
|
63
|
+
}
|
|
64
|
+
if bindings := registered.Bindings(); len(bindings) != 0 {
|
|
65
|
+
t.Fatalf("bindings = %#v, want none after capability rejection", bindings)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func TestBindWorkflowsPublishesSeparateOwnershipMatcher(t *testing.T) {
|
|
70
|
+
system := ownershipBindingTaskSystem{bindingTaskSystem: bindingTaskSystem{}}
|
|
71
|
+
registered := &Repo{Name: "payments", TaskSystem: system}
|
|
72
|
+
registry := NewRegistry()
|
|
73
|
+
registry.Replace(registered)
|
|
74
|
+
wf := &workflow.Workflow{
|
|
75
|
+
Name: "ownerFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy,
|
|
76
|
+
TaskConfig: config.RawValues{"owner": "alice"},
|
|
77
|
+
}
|
|
78
|
+
if err := registry.BindWorkflows([]*workflow.Workflow{wf}); err != nil {
|
|
79
|
+
t.Fatal(err)
|
|
80
|
+
}
|
|
81
|
+
bindings := registered.Bindings()
|
|
82
|
+
if len(bindings) != 1 || bindings[0].Ownership == nil {
|
|
83
|
+
t.Fatalf("bindings = %#v, want a published ownership matcher", bindings)
|
|
84
|
+
}
|
|
85
|
+
if !bindings[0].Ownership(task.Ticket{Fields: map[string]any{"assignee": "alice"}}) ||
|
|
86
|
+
bindings[0].Ownership(task.Ticket{Fields: map[string]any{"assignee": "bob"}}) {
|
|
87
|
+
t.Fatal("published ownership matcher did not enforce the adapter predicate")
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
28
91
|
func TestBindWorkflowsIsolatedPublishesHealthyWorkflows(t *testing.T) {
|
|
29
92
|
system := bindingTaskSystem{compileErr: map[string]error{"bad": errors.New("invalid filter")}}
|
|
30
93
|
registered := &Repo{Name: "payments", TaskSystem: system}
|
package/internal/repo/repo.go
CHANGED
|
@@ -16,8 +16,11 @@ import (
|
|
|
16
16
|
// repo. Repo.Workflows is a derived in-memory index rebuilt at startup and
|
|
17
17
|
// after workflow submission/removal; Workflow.Repos is the source of truth.
|
|
18
18
|
type WorkflowBinding struct {
|
|
19
|
-
Workflow
|
|
20
|
-
Match
|
|
19
|
+
Workflow *workflow.Workflow
|
|
20
|
+
Match func(task.Ticket) bool
|
|
21
|
+
// Ownership is compiled from the adapter-owned durable claim-owner
|
|
22
|
+
// predicate. It is mandatory for published automatic-routing bindings.
|
|
23
|
+
Ownership func(task.Ticket) bool
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
type Info struct {
|
|
@@ -124,8 +127,9 @@ func (r *Registry) BindWorkflowsIsolated(workflows []*workflow.Workflow) []Bindi
|
|
|
124
127
|
|
|
125
128
|
func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) ([]BindingIssue, error) {
|
|
126
129
|
type binding struct {
|
|
127
|
-
wf
|
|
128
|
-
match
|
|
130
|
+
wf *workflow.Workflow
|
|
131
|
+
match func(task.Ticket) bool
|
|
132
|
+
ownership func(task.Ticket) bool
|
|
129
133
|
}
|
|
130
134
|
byRepo := map[string][]binding{}
|
|
131
135
|
issues := []BindingIssue{}
|
|
@@ -171,7 +175,44 @@ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) (
|
|
|
171
175
|
failed = true
|
|
172
176
|
break
|
|
173
177
|
}
|
|
174
|
-
|
|
178
|
+
// Ownership is a separate matcher so a claimed ticket can bypass
|
|
179
|
+
// lifecycle filters while still remaining isolated to the adapter's
|
|
180
|
+
// effective assignee/owner. All automatic-routing adapters must
|
|
181
|
+
// expose the complete capability set; there is no unrestricted Claim
|
|
182
|
+
// fallback for a missing capability.
|
|
183
|
+
capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
|
|
184
|
+
if !ok {
|
|
185
|
+
err = fmt.Errorf("workflow %q repo %q: task system lacks required ownership capabilities", wf.Name, repoName)
|
|
186
|
+
if !isolate {
|
|
187
|
+
return nil, err
|
|
188
|
+
}
|
|
189
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
190
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
191
|
+
failed = true
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
ownership, err := capabilities.CompileOwnershipFilter(wf.TaskConfig)
|
|
195
|
+
if err != nil {
|
|
196
|
+
err = fmt.Errorf("workflow %q repo %q: compile ownership filter: %w", wf.Name, repoName, err)
|
|
197
|
+
if !isolate {
|
|
198
|
+
return nil, err
|
|
199
|
+
}
|
|
200
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
201
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
202
|
+
failed = true
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
if ownership == nil {
|
|
206
|
+
err = fmt.Errorf("workflow %q repo %q: ownership compiler returned a nil matcher", wf.Name, repoName)
|
|
207
|
+
if !isolate {
|
|
208
|
+
return nil, err
|
|
209
|
+
}
|
|
210
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
211
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
212
|
+
failed = true
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match, ownership: ownership})
|
|
175
216
|
}
|
|
176
217
|
if failed {
|
|
177
218
|
// A workflow is all-or-nothing across its referenced repositories;
|
|
@@ -197,7 +238,7 @@ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) (
|
|
|
197
238
|
binds := byRepo[name]
|
|
198
239
|
next := make([]WorkflowBinding, 0, len(binds))
|
|
199
240
|
for _, b := range binds {
|
|
200
|
-
next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match})
|
|
241
|
+
next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match, Ownership: b.ownership})
|
|
201
242
|
}
|
|
202
243
|
sort.Slice(next, func(i, j int) bool { return next[i].Workflow.Name < next[j].Workflow.Name })
|
|
203
244
|
rp.bindingsMu.Lock()
|