Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ Two examples already run at this load. The [deep-research engine](https://agentf
| No timeout limits (hours/days) | Control plane allows unlimited duration |
| Execution polling | `GET /api/v1/executions/{id}` |
| Restart/replay | [`POST /api/v1/executions/{id}/restart`](docs/api/EXECUTION_RESTART.md) |
| Run naming, labels, links | [`POST /api/v1/runs/{id}/metadata`](docs/api/RUN_METADATA.md) |
| Batch status checks | `POST /api/v1/executions/batch-status` |
| Progress updates mid-execution | Intermediate payloads during long tasks |
| Auto retries + exponential backoff | Transparent - control plane handles |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package agentic

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/Agent-Field/agentfield/control-plane/pkg/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

type runOverviewMetadataStorage struct {
*handlerTestStorage
run *types.WorkflowRun
}

func (s *runOverviewMetadataStorage) GetWorkflowRun(context.Context, string) (*types.WorkflowRun, error) {
return s.run, nil
}

func TestRunOverviewRunMetadataPresenceAndEnvelopeLocation(t *testing.T) {
gin.SetMode(gin.TestMode)
execution := []*types.Execution{{ExecutionID: "exec", RunID: "run-1", AgentNodeID: "node", Status: "succeeded"}}
for _, test := range []struct {
name string
store *runOverviewMetadataStorage
present bool
}{
{"absent", &runOverviewMetadataStorage{handlerTestStorage: &handlerTestStorage{mockStatusStorage: &mockStatusStorage{}}}, false},
{"present", &runOverviewMetadataStorage{handlerTestStorage: &handlerTestStorage{mockStatusStorage: &mockStatusStorage{}}, run: &types.WorkflowRun{RunID: "run-1", Metadata: json.RawMessage(`{"run":{"display_name":"Release","labels":["smoke"]}}`)}}, true},
} {
t.Run(test.name, func(t *testing.T) {
test.store.On("QueryExecutionRecords", mock.Anything, mock.Anything).Return(execution, nil)
router := gin.New()
router.GET("/runs/:run_id", RunOverviewHandler(test.store))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/runs/run-1", nil))
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var envelope map[string]interface{}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
_, topLevel := envelope["run_metadata"]
require.False(t, topLevel)
data := envelope["data"].(map[string]interface{})
metadata, exists := data["run_metadata"]
require.Equal(t, test.present, exists)
if test.present {
require.Equal(t, "Release", metadata.(map[string]interface{})["display_name"])
}
})
}
}
17 changes: 15 additions & 2 deletions control-plane/internal/handlers/agentic/run_overview.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package agentic

import (
"context"
"net/http"

"github.com/Agent-Field/agentfield/control-plane/internal/storage"
"github.com/Agent-Field/agentfield/control-plane/pkg/types"
"github.com/gin-gonic/gin"
)

type workflowRunGetter interface {
GetWorkflowRun(context.Context, string) (*types.WorkflowRun, error)
}

// RunOverviewHandler returns everything about a workflow run in one call.
func RunOverviewHandler(store storage.StorageProvider) gin.HandlerFunc {
return func(c *gin.Context) {
Expand Down Expand Up @@ -55,7 +60,7 @@ func RunOverviewHandler(store storage.StorageProvider) gin.HandlerFunc {
agents = append(agents, a)
}

respondOK(c, gin.H{
payload := gin.H{
"run_id": runID,
"executions": executions,
"agents": agents,
Expand All @@ -65,6 +70,14 @@ func RunOverviewHandler(store storage.StorageProvider) gin.HandlerFunc {
"unique_agents": len(agents),
},
"notes": allNotes,
})
}
if getter, ok := store.(workflowRunGetter); ok {
if run, err := getter.GetWorkflowRun(ctx, runID); err == nil && run != nil {
if metadata := types.ParseRunMetadata(run.Metadata); metadata != nil {
payload["run_metadata"] = metadata
}
}
}
respondOK(c, payload)
}
}
3 changes: 3 additions & 0 deletions control-plane/internal/handlers/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type ExecuteRequest struct {
Input map[string]interface{} `json:"input"`
Context map[string]interface{} `json:"context,omitempty"`
Webhook *WebhookRequest `json:"webhook,omitempty"`
// RunMetadata names, labels or links the run started by this request. It is
// excluded from replay dedupe and ignored on child executions.
RunMetadata *RunMetadataInput `json:"run_metadata,omitempty"`
}

// WebhookRequest represents webhook registration parameters supplied by the client.
Expand Down
79 changes: 71 additions & 8 deletions control-plane/internal/handlers/execute_prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ func (c *executionController) prepareExecutionForTargetWithAdmission(ctx context
if req.Input == nil {
req.Input = map[string]interface{}{}
}
if req.RunMetadata != nil && headers.parentExecutionID == nil {
if _, err := applyRunMetadataInput(types.RunMetadata{}, *req.RunMetadata); err != nil {
return nil, fmt.Errorf("invalid run_metadata: %w", err)
}
if _, err := normalizeRunMetadataActor(pointerValue(headers.actorID)); err != nil {
return nil, fmt.Errorf("invalid run_metadata: %w", err)
}
}

var (
sanitizedWebhook *normalizedWebhookConfig
Expand Down Expand Up @@ -167,14 +175,7 @@ func (c *executionController) prepareExecutionForTargetWithAdmission(ctx context
executionID := utils.GenerateExecutionID()
now := time.Now().UTC()

clientPayload := map[string]interface{}{
"input": req.Input,
}
if len(req.Context) > 0 {
clientPayload["context"] = req.Context
}

storedPayload, err := json.Marshal(clientPayload)
storedPayload, err := json.Marshal(buildClientPayload(req))
if err != nil {
return nil, fmt.Errorf("encode execution payload: %w", err)
}
Expand Down Expand Up @@ -222,6 +223,9 @@ func (c *executionController) prepareExecutionForTargetWithAdmission(ctx context
if err := c.store.CreateExecutionRecord(ctx, exec); err != nil {
return nil, fmt.Errorf("create execution record: %w", err)
}
if headers.parentExecutionID == nil && req.RunMetadata != nil {
c.persistExecuteRunMetadata(ctx, runID, *req.RunMetadata, headers.actorID)
}

var webhookRegistered bool
if sanitizedWebhook != nil && webhookError == nil {
Expand Down Expand Up @@ -276,6 +280,65 @@ func (c *executionController) prepareExecutionForTargetWithAdmission(ctx context
}, nil
}

// buildClientPayload builds the blob persisted as executions.input_payload,
// which canonicalReplayPayload then hashes into the replay dedupe key.
//
// Only input and context belong in it. run_metadata is deliberately excluded:
// it names the run for humans and has no bearing on what the reasoner is asked
// to compute, so two executes that differ only in run_metadata must still
// replay-match each other. Adding a field here changes the dedupe key for every
// caller and silently turns existing replay hits into misses.
func buildClientPayload(req ExecuteRequest) map[string]interface{} {
payload := map[string]interface{}{
"input": req.Input,
}
if len(req.Context) > 0 {
payload["context"] = req.Context
}
return payload
}

// persistExecuteRunMetadata stores the run_metadata a root execute carried, by
// merging it into the run's "run" namespace. Best effort on purpose: an execute
// must not fail because a display name could not be recorded, so a failure is
// logged and swallowed — the same contract persistRestartRunMetadata uses for
// the lineage seed. Callers must have already checked that this is a root
// execute; only the run root establishes run identity.
func (c *executionController) persistExecuteRunMetadata(ctx context.Context, runID string, input RunMetadataInput, actorID *string) {
writer, ok := c.store.(workflowRunMetadataWriter)
if !ok {
return
}
actor, err := normalizeRunMetadataActor(pointerValue(actorID))
if err != nil {
logger.Logger.Warn().Err(err).Str("run_id", runID).Msg("failed to persist execute run metadata")
return
}
if err := writer.UpdateWorkflowRunMetadata(ctx, runID, func(namespaces map[string]json.RawMessage) error {
current := types.RunMetadata{}
if raw := namespaces[types.RunMetadataNamespace]; raw != nil {
_ = json.Unmarshal(raw, &current)
}
merged, err := applyRunMetadataInput(current, input)
if err != nil {
return err
}
merged.SetBy = actor
merged.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
namespaces[types.RunMetadataNamespace], err = json.Marshal(merged)
return err
}); err != nil {
logger.Logger.Warn().Err(err).Str("run_id", runID).Msg("failed to persist execute run metadata")
}
}

func pointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}

// findReplayHit returns a previously-succeeded child output to reuse for the
// current app.call, or nil to run it normally. Only child executions (those with
// a parent) are eligible — the restarted root always re-runs.
Expand Down
48 changes: 20 additions & 28 deletions control-plane/internal/handlers/execute_restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type restartExecutionResponse struct {
}

type workflowRunMetadataStore interface {
StoreWorkflowRun(ctx context.Context, run *types.WorkflowRun) error
UpdateWorkflowRunMetadata(context.Context, string, func(map[string]json.RawMessage) error) error
}

// RestartExecutionHandler starts a new execution/run from an existing workflow
Expand Down Expand Up @@ -151,6 +151,7 @@ func (c *executionController) handleRestart(ctx *gin.Context) {
}

target := fmt.Sprintf("%s.%s", restartExec.NodeID, restartExec.ReasonerID)
// Restarts mint a new run identity and deliberately leave RunMetadata nil.
plan, err := c.prepareExecutionForTarget(reqCtx, target, ExecuteRequest{
Input: input,
Context: contextPayload,
Expand Down Expand Up @@ -247,40 +248,31 @@ func (c *executionController) persistRestartRunMetadata(ctx context.Context, pla
if !ok {
return
}
now := time.Now().UTC()
metadata := map[string]interface{}{
"lineage": map[string]interface{}{
"kind": kind,
"source_run_id": sourceExec.RunID,
"source_execution_id": sourceExec.ExecutionID,
"restarted_execution_id": restartExec.ExecutionID,
"reuse": reuse,
"scope": scope,
},
lineage := map[string]interface{}{
"kind": kind,
"source_run_id": sourceExec.RunID,
"source_execution_id": sourceExec.ExecutionID,
"restarted_execution_id": restartExec.ExecutionID,
"reuse": reuse,
"scope": scope,
}
var encodedReason json.RawMessage
if trimmed := strings.TrimSpace(reason); trimmed != "" {
metadata["reason"] = trimmed
encodedReason, _ = json.Marshal(trimmed)
}
encoded, err := json.Marshal(metadata)
encoded, err := json.Marshal(lineage)
if err != nil {
logger.Logger.Warn().Err(err).Str("run_id", plan.exec.RunID).Msg("failed to encode restart run metadata")
return
}
// This workflow_runs row exists only to carry lineage/golden metadata for the
// new run; it is the sole writer of this row for restart runs. Status and
// TotalSteps are seeded at enqueue time and are NOT kept current as the run
// progresses — every UI read path (run list, run detail, DAG) derives live
// status and step counts from execution aggregation and only reads the
// lineage/golden fields here. Do not treat these columns as authoritative.
if err := store.StoreWorkflowRun(ctx, &types.WorkflowRun{
RunID: plan.exec.RunID,
RootWorkflowID: plan.exec.RunID,
RootExecutionID: &plan.exec.ExecutionID,
Status: string(types.ExecutionStatusQueued),
TotalSteps: 1,
Metadata: json.RawMessage(encoded),
CreatedAt: now,
UpdatedAt: now,
if err := store.UpdateWorkflowRunMetadata(ctx, plan.exec.RunID, func(namespaces map[string]json.RawMessage) error {
namespaces["lineage"] = encoded
if encodedReason != nil {
namespaces["reason"] = encodedReason
} else {
delete(namespaces, "reason")
}
return nil
}); err != nil {
logger.Logger.Warn().Err(err).Str("run_id", plan.exec.RunID).Msg("failed to persist restart run metadata")
}
Expand Down
1 change: 1 addition & 0 deletions control-plane/internal/handlers/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ func (s *mcpServer) toolWaitRun(c *gin.Context, rawArgs json.RawMessage) (map[st
// the MCP tool. It returns as soon as the job is enqueued.
func (s *mcpServer) startAsyncRun(ctx context.Context, target string, input map[string]interface{}, headers executionHeaders, callerDID, targetDID string) (runID, execID string, err error) {
controller := newExecutionController(s.store, s.payloads, s.webhooks, s.timeout, s.internalToken)
// MCP creates an ordinary execution and deliberately leaves RunMetadata nil.
plan, err := controller.prepareExecutionForTarget(ctx, target, ExecuteRequest{Input: input}, headers, callerDID, targetDID)
if err != nil {
return "", "", err
Expand Down
Loading
Loading