Store workflow definitions as table rows and drop YAML

This commit is contained in:
Ian Gulliver
2026-10-20 21:30:39 -07:00
parent 5823af6ff9
commit 32ebf08b66
17 changed files with 624 additions and 454 deletions
@@ -1,6 +1,6 @@
# Workflow files
# Workflow definitions
A workflow's file is YAML: an optional `description`, then `steps`, which run in order. The workflow's name is not in the file. Unknown fields are refused.
A workflow's definition is an object: an optional `description`, then `steps`, which run in order. The workflow's name is not in it. `save_workflow` takes it as its `definition` argument and `read_workflow` returns it, both as JSON. Unknown fields are refused.
Any workflow can run on a brief, each stage as its own task, and any workflow whose stages are single agents can be a project's review workflow (set with `set_review_workflow`), which reviews its tasks' snapshots: each stage is then one reviewer agent on its `model`, given the task's change and its stage's `prompt` (what to look for), and a stage after others also gets their reviews. A project can't review with a workflow that has a team stage (`agents`).
@@ -22,26 +22,19 @@ A stage gets the outputs of the stages that end the step before it: the last sta
Where a stage's task branches from follows from the shape. A stage after exactly one stage starts from the commit that stage finished at. A stage after a `parallel` starts from the default branch and is told the branches it can merge. A stage with nothing before it starts where the workflow was started. A workflow is finished once every stage has finished; when it ends with a `parallel`, each branch's last stage keeps its task to merge, and the run's other stage tasks are discarded. A review is delivered to the task's agent, every stage's findings in one message, once every stage has reviewed. An `order` or `parallel` can't be empty.
```yaml
description: Build a change, review and test it in parallel, then fix what they found.
steps:
- stage: Build
model: claude-opus-5-5
prompt: Implement the brief.
- parallel:
- stage: Review
model: gpt-6.1-sol
prompt: Review the Build stage's work and list the problems.
- stage: Test
model: gemini-4-argon-high
prompt: Exercise the change and report what fails.
- stage: Fix
agents:
- name: fixer
model: claude-opus-5-5
prompt: Fix every problem the reports list.
- name: checker
model: gpt-6.1-sol
prompt: Check each fix as the fixer lands it.
prompt: Merge the branches you are given and address both reports.
```json
{
"description": "Build a change, review and test it in parallel, then fix what they found.",
"steps": [
{"stage": "Build", "model": "claude-opus-5-5", "prompt": "Implement the brief."},
{"parallel": [
{"stage": "Review", "model": "gpt-6.1-sol", "prompt": "Review the Build stage's work and list the problems."},
{"stage": "Test", "model": "gemini-4-argon-high", "prompt": "Exercise the change and report what fails."}
]},
{"stage": "Fix", "prompt": "Merge the branches you are given and address both reports.", "agents": [
{"name": "fixer", "model": "claude-opus-5-5", "prompt": "Fix every problem the reports list."},
{"name": "checker", "model": "gpt-6.1-sol", "prompt": "Check each fix as the fixer lands it."}
]}
]
}
```
+12 -12
View File
@@ -122,7 +122,7 @@ func (m *Manager) reviewsSettled(ctx context.Context, reviews []api.Review) (boo
if r.State != api.ReviewStateDone {
continue
}
def, err := m.store.ReviewDefinition(ctx, r.Id)
_, def, err := m.store.ReviewDefinition(ctx, r.Id)
if err != nil {
return false, err
}
@@ -192,12 +192,12 @@ func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snap
now := time.Now().UTC()
if len(changes.Files) == 0 {
slog.Info("review skipped, snapshot has no diff", "task", task.Id, "snapshot", snap.N)
return m.store.CreateReview(ctx, api.Review{Id: store.NewID(), TaskId: task.Id, SnapshotN: snap.N, State: api.ReviewStateSkipped, CreatedAt: now, FinishedAt: &now}, nil)
return m.store.CreateReview(ctx, api.Review{Id: store.NewID(), TaskId: task.Id, SnapshotN: snap.N, State: api.ReviewStateSkipped, CreatedAt: now, FinishedAt: &now}, "")
}
def, err := m.store.GetWorkflow(ctx, project.ReviewWorkflow)
frozen, err := m.store.FreezeWorkflow(ctx, project.ReviewWorkflow)
if err == nil {
err = reviewProblem(def)
err = reviewProblem(frozen.Definition)
}
if err != nil {
return m.store.CreateReview(ctx, api.Review{
@@ -209,11 +209,11 @@ func (m *Manager) startReviews(ctx context.Context, task api.Task, snap api.Snap
Error: fmt.Sprintf("project %s reviews with workflow %q, which can't run: %v", project.Name, project.ReviewWorkflow, err),
CreatedAt: now,
FinishedAt: &now,
}, nil)
}, "")
}
for _, stage := range workflow.Starts(def) {
err = m.startReviewer(ctx, task, project, snap, def, stage, nil)
for _, stage := range workflow.Starts(frozen.Definition) {
err = m.startReviewer(ctx, task, project, snap, frozen.DefinitionID, frozen.Definition, stage, nil)
if err != nil {
slog.Error("start review stage", "task", task.Id, "snapshot", snap.N, "stage", stage, "err", err)
}
@@ -228,7 +228,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool {
m.reviewMu.Lock()
defer m.reviewMu.Unlock()
def, err := m.store.ReviewDefinition(ctx, r.Id)
defID, def, err := m.store.ReviewDefinition(ctx, r.Id)
if err != nil {
slog.Error("load review definition", "review", r.Id, "err", err)
return false
@@ -274,7 +274,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool {
if len(inputs) != len(workflow.After(def, next)) {
continue
}
err = m.startReviewer(ctx, task, project, snap, def, next, inputs)
err = m.startReviewer(ctx, task, project, snap, defID, def, next, inputs)
if err != nil {
slog.Error("start review stage", "task", task.Id, "snapshot", r.SnapshotN, "stage", next, "err", err)
continue
@@ -284,7 +284,7 @@ func (m *Manager) advanceReviews(ctx context.Context, r api.Review) bool {
return started
}
func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, def api.WorkflowDefinition, stage string, inputs []api.Review) (err error) {
func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.Project, snap api.Snapshot, defID string, def api.WorkflowDefinition, stage string, inputs []api.Review) (err error) {
model := workflow.Get(def, stage).Model
vendor := workflow.VendorOf(model)
reviewID := store.NewID()
@@ -305,7 +305,7 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.
Error: "the reviewer didn't start: " + err.Error(),
CreatedAt: now,
FinishedAt: &now,
}, &def)
}, defID)
if rerr != nil {
slog.Error("record review that didn't start", "task", task.Id, "stage", stage, "err", rerr)
return
@@ -356,7 +356,7 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, project api.
Model: model,
State: api.ReviewStateRunning,
CreatedAt: time.Now().UTC(),
}, &def)
}, defID)
if err != nil {
m.endReviewer(ctx, sess.Id, project, reviewID)
return err
+64 -28
View File
@@ -15,12 +15,12 @@ import (
"g.fc.run/ai/byre/internal/workflow"
)
func (m *Manager) SaveWorkflow(ctx context.Context, name, from string, file []byte, actor string) (api.WorkflowSummary, error) {
func (m *Manager) SaveWorkflow(ctx context.Context, name, from string, definition []byte, actor string) (api.WorkflowSummary, error) {
name, err := workflow.CleanName(name)
if err != nil {
return api.WorkflowSummary{}, err
}
def, err := workflow.Parse(file)
def, err := workflow.Parse(definition)
if err != nil {
return api.WorkflowSummary{}, err
}
@@ -58,24 +58,12 @@ func reviewProblem(def api.WorkflowDefinition) error {
return nil
}
func (m *Manager) WorkflowFile(ctx context.Context, name string) ([]byte, error) {
def, err := m.store.GetWorkflow(ctx, name)
if err != nil {
return nil, err
}
return workflow.Format(def)
}
func (m *Manager) TaskWorkflow(ctx context.Context, taskID string) (api.TaskWorkflow, error) {
frozen, err := m.store.TaskWorkflow(ctx, taskID)
if err != nil {
return api.TaskWorkflow{}, err
}
file, err := workflow.Format(frozen.Definition)
if err != nil {
return api.TaskWorkflow{}, err
}
return api.TaskWorkflow{Name: frozen.Name, Definition: string(file)}, nil
return api.TaskWorkflow{Name: frozen.Name, Definition: frozen.Definition}, nil
}
func (m *Manager) WorkflowRun(ctx context.Context, taskID string) (api.WorkflowRun, error) {
@@ -719,7 +707,48 @@ func stageGuidance(frozen store.FrozenWorkflow, stage string) string {
"%s. Until you call it, the workflow waits on this stage.", stage, frozen.Name, then)
}
const workflowFileHelp = "The file's YAML format is the read_knowledge article workflow-files."
const workflowHelp = "The definition's fields are the read_knowledge article workflow-definitions."
const workflowDefinitionSchema = `{
"type": "object",
"properties": {
"description": {"type": "string"},
"steps": {"type": "array", "items": {"$ref": "#/$defs/step"}}
},
"required": ["steps"],
"additionalProperties": false
}`
const workflowSchemaDefs = `"$defs": {
"step": {
"type": "object",
"description": "Exactly one of stage (with model or agents, and prompt), order or parallel.",
"properties": {
"stage": {"type": "string"},
"model": {"type": "string"},
"prompt": {"type": "string"},
"agents": {"type": "array", "items": {"$ref": "#/$defs/agent"}},
"order": {"type": "array", "items": {"$ref": "#/$defs/step"}},
"parallel": {"type": "array", "items": {"$ref": "#/$defs/step"}}
},
"additionalProperties": false
},
"agent": {
"type": "object",
"properties": {
"name": {"type": "string"},
"model": {"type": "string"},
"prompt": {"type": "string"}
},
"required": ["name", "model", "prompt"],
"additionalProperties": false
}
}`
func formatDefinition(def api.WorkflowDefinition) (string, error) {
b, err := json.MarshalIndent(def, "", " ")
return string(b), err
}
func (m *Manager) workflowTools(tool func(name, description, schema string, run func(ctx context.Context, user api.User, raw json.RawMessage) (string, error)) mcp.Tool) []mcp.Tool {
return []mcp.Tool{
@@ -746,7 +775,7 @@ func (m *Manager) workflowTools(tool func(name, description, schema string, run
}),
tool("read_workflow",
"Read a workflow's file: with name, the workflow on the server; with task_id, the copy that task froze when it was created. "+workflowFileHelp,
"Read a workflow's definition: with name, the workflow on the server; with task_id, the copy that task froze when it was created. "+workflowHelp,
`{"type": "object", "properties": {
"name": {"type": "string"},
"task_id": {"type": "string"}
@@ -764,33 +793,40 @@ func (m *Manager) workflowTools(tool func(name, description, schema string, run
return "", fmt.Errorf("%w: give exactly one of name and task_id", ErrInvalid)
}
if args.TaskID != "" {
tw, err := m.TaskWorkflow(ctx, args.TaskID)
frozen, err := m.store.TaskWorkflow(ctx, args.TaskID)
if err != nil {
return "", err
}
return fmt.Sprintf("Task %s froze workflow %q:\n\n%s", args.TaskID, tw.Name, tw.Definition), nil
text, err := formatDefinition(frozen.Definition)
if err != nil {
return "", err
}
return fmt.Sprintf("Task %s froze workflow %q:\n\n%s", args.TaskID, frozen.Name, text), nil
}
file, err := m.WorkflowFile(ctx, args.Name)
return string(file), err
def, err := m.store.GetWorkflow(ctx, args.Name)
if err != nil {
return "", err
}
return formatDefinition(def)
}),
tool("save_workflow",
"Create or replace a workflow on the server from its file. Tasks that already froze it keep their copy. "+
"The name is one line of 1 to 100 characters, shown to the human on buttons and pickers. "+workflowFileHelp,
"Create or replace a workflow on the server from its definition. Tasks that already froze it keep their copy. "+
"The name is one line of 1 to 100 characters, shown to the human on buttons and pickers. "+workflowHelp,
`{"type": "object", "properties": {
"name": {"type": "string"},
"definition": {"type": "string", "description": "The workflow's file, as YAML."}
}, "required": ["name", "definition"]}`,
"definition": `+workflowDefinitionSchema+`
}, "required": ["name", "definition"], `+workflowSchemaDefs+`}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Name string `json:"name"`
Definition string `json:"definition"`
Name string `json:"name"`
Definition json.RawMessage `json:"definition"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
w, err := m.SaveWorkflow(ctx, args.Name, "", []byte(args.Definition), leadActor(user.Login))
w, err := m.SaveWorkflow(ctx, args.Name, "", args.Definition, leadActor(user.Login))
if err != nil {
return "", err
}