PR #3468
Sections
Review

test(plugins): cover external priority persistence

main ← feature/fix-plugin-task-prio-w7w 4 files +258 −12 PR #3468 ↗

This PR adds an external-process test that proves task priority survives the plugin gRPC wire, Host adapter, and SQLite store.

Why this change

Task priority had no external-process coverage. A wire or store regression could change or drop the value without a test failure.

What it does

Architecture, end to end

The external fixture binary talks to the Host over gRPC. The Host forwards priority to the task service and SQLite. The test reads the value back.

flowchart LR
  Fixture["Fixture binary\nplugin-fixture"] -->|"HandleWebhook priority"| Host["Host\npluginHost"]
  Host -->|"Create / Update / Get"| Service["Task Service"]
  Service --> DB["(SQLite)"]
  Host -->|"protobuf bytes 0x5a / 0x32"| Wire["Wire check"]
  Test["External test\nTestPluginHost_ExternalProcess..."] -->|"build + Start + HandleWebhook"| Fixture
  Test -->|"assert readback"| Host

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Fixture package
Plugin binary
Host harness
webhooks: priority (POST) + capabilities.api_read
Click for details →

The manifest now declares api_read and a priority webhook so the fixture can exercise Host write and read.

Manifest diff
    events: ["task.created"]
  api_read: ["tasks"]
    api_write: ["tasks"]
    state: true
  - key: "priority"
    description: "Exercises external Host priority persistence for integration tests"
    method: "POST"
    - key: "test-hook"
      description: "Records inbound webhooks for e2e polling"
      method: "POST"
func TestFixtureManifest_ParsesAndValidates(t *testing.T)
Click for details →

The guard now checks api_read, api_write, and three webhooks to prevent drift.

Guard assertions
require.Equal(t, []string{"tasks"}, m.Capabilities.APIRead)
require.Equal(t, []string{"tasks"}, m.Capabilities.APIWrite)
require.Len(t, m.Webhooks, 3)
require.Equal(t, "priority", m.Webhooks[0].Key)
require.Equal(t, manifest.WebhookAccessAuthenticated, m.Webhooks[0].EffectiveAccess(m.APIVersion), "priority exercises the private Host write path")
require.Equal(t, "test-hook", m.Webhooks[1].Key)
require.Equal(t, "public-hook", m.Webhooks[2].Key)
Fixture binary adds priorityProbeapps/backend/cmd/plugin-fixture/plugin.go ↗
func (p *fixturePlugin) priorityProbe(ctx context.Context) (*pluginsdk.WebhookResponse, error)
Click for details →

The probe creates tasks with high and default priority, updates one, checks invalid values, and returns readback JSON.

Webhook routing
func (p *fixturePlugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) {
  rec := webhookRecord{WebhookKey: req.WebhookKey, Method: req.Method}
  if err := appendJSONLine(filepath.Join(p.dataDir, webhooksFileName), rec); err != nil {
    return nil, err
  }
  p.snapshotConfigBestEffort(ctx)
  p.snapshotSecretProbeBestEffort(ctx)
  if req.WebhookKey == writeProbeWebhookKey {
    p.snapshotWriteProbeBestEffort(ctx)
  }
  if req.WebhookKey == priorityProbeWebhookKey {
    return p.priorityProbe(ctx)
  }
  return &pluginsdk.WebhookResponse{Status: 200, Body: []byte("ok")}, nil
}
priorityProbe
type priorityProbeResult struct {
  CreateHighReadback    string `json:"create_high_readback"`
  CreateDefaultReadback string `json:"create_default_readback"`
  UpdateHighReadback    string `json:"update_high_readback"`
  InvalidCreateError    string `json:"invalid_create_error"`
  InvalidUpdateError    string `json:"invalid_update_error"`
}

func (p *fixturePlugin) priorityProbe(ctx context.Context) (*pluginsdk.WebhookResponse, error) {
  host := p.Host()
  if host == nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte("no host")}, nil
  }
  result := priorityProbeResult{}
  createdHigh, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{
    WorkspaceID: "ws-probe", WorkflowID: "wf-probe", Title: "priority high", Priority: "high",
  })
  if err != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil
  }
  if readback, readErr := host.Tasks().Get(ctx, createdHigh.ID); readErr != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(readErr.Error())}, nil
  } else {
    result.CreateHighReadback = readback.Priority
  }
  createdDefault, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{
    WorkspaceID: "ws-probe", WorkflowID: "wf-probe", Title: "priority default",
  })
  if err != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil
  }
  if readback, readErr := host.Tasks().Get(ctx, createdDefault.ID); readErr != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(readErr.Error())}, nil
  } else {
    result.CreateDefaultReadback = readback.Priority
  }
  high := "high"
  if _, err := host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{ID: createdDefault.ID, Priority: &high}); err != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil
  }
  if readback, readErr := host.Tasks().Get(ctx, createdDefault.ID); readErr != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(readErr.Error())}, nil
  } else {
    result.UpdateHighReadback = readback.Priority
  }
  invalid := "invalid"
  if _, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{
    WorkspaceID: "ws-probe", WorkflowID: "wf-probe", Title: "invalid priority", Priority: invalid,
  }); err != nil {
    result.InvalidCreateError = err.Error()
  }
  if _, err := host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{ID: createdDefault.ID, Priority: &invalid}); err != nil {
    result.InvalidUpdateError = err.Error()
  }
  body, err := json.Marshal(result)
  if err != nil {
    return nil, err
  }
  return &pluginsdk.WebhookResponse{Status: 200, Body: body}, nil
}
func TestPluginHost_ExternalProcessPersistsPriorityThroughTaskService(t *testing.T)
Click for details →

The test builds the fixture binary, starts it as an external process, calls the priority webhook, and checks SQLite persistence.

Wire payload pin
func TestPluginPriorityWirePayload(t *testing.T) {
  high := "high"
  createPayload, err := proto.Marshal(&pluginv1.CreateTaskRequest{Priority: high})
  require.NoError(t, err)
  require.True(t, bytes.Contains(createPayload, []byte{0x5a, 0x04, 'h', 'i', 'g', 'h'}))
  updatePayload, err := proto.Marshal(&pluginv1.UpdateTaskRequest{Priority: &high})
  require.NoError(t, err)
  require.True(t, bytes.Contains(updatePayload, []byte{0x32, 0x04, 'h', 'i', 'g', 'h'}))
}
External process test
func TestPluginHost_ExternalProcessPersistsPriorityThroughTaskService(t *testing.T) {
  ctx := context.Background()
  taskSvc, closeDB := newExternalProcessTaskService(t)
  t.Cleanup(closeDB)
  workspaces, err := taskSvc.ListWorkspaces(ctx)
  require.NoError(t, err)
  workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true)
  require.NoError(t, err)
  writer := &externalProcessTaskWriter{svc: taskSvc, workspaceID: workspaces[0].ID, workflowID: workflows[0].ID}
  host := &pluginHost{
    pluginID: "priority-probe",
    capabilities: manifest.Capabilities{APIRead: []string{"tasks"}, APIWrite: []string{"tasks"}},
    taskData: taskSvc,
    taskWriter: writer,
  }
  bin := buildExternalPriorityProbe(t)
  installPath := t.TempDir()
  platform := goruntime.GOOS + "-" + goruntime.GOARCH
  relative := filepath.Join("server", "plugin-"+platform)
  destination := filepath.Join(installPath, relative)
  require.NoError(t, os.MkdirAll(filepath.Dir(destination), 0o755))
  contents, err := os.ReadFile(bin)
  require.NoError(t, err)
  require.NoError(t, os.WriteFile(destination, contents, 0o755))
  log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
  require.NoError(t, err)
  runtime := pluginruntime.NewManager(t.TempDir(), nil, log)
  t.Cleanup(runtime.StopAll)
  record := &store.Record{Manifest: manifest.Manifest{
    ID: "priority-probe", APIVersion: 1, Version: "1.0.0",
    Runtime: manifest.Runtime{Type: "binary", Executables: map[string]string{platform: relative}},
  }, InstallPath: installPath}
  require.NoError(t, runtime.Start(ctx, record, func(string) pluginsdk.Host { return host }))
  remote, ok := runtime.Get(record.ID)
  require.True(t, ok)
  var response *pluginsdk.WebhookResponse
  require.Eventually(t, func() bool {
    var callErr error
    response, callErr = remote.HandleWebhook(ctx, &pluginsdk.WebhookRequest{WebhookKey: "priority"})
    return callErr == nil && string(response.Body) != "no host"
  }, 5*time.Second, 20*time.Millisecond, "the plugin must receive its Host before the priority probe")
  require.Equalf(t, int32(200), response.Status, "external plugin response: %s", response.Body)
  var probe externalPriorityProbeResult
  require.NoError(t, json.Unmarshal(response.Body, &probe), "plugin response must expose immediate Host readback")
  require.Equal(t, "high", probe.CreateHighReadback)
  require.Equal(t, "medium", probe.CreateDefaultReadback)
  require.Equal(t, "high", probe.UpdateHighReadback)
  require.NotEmpty(t, probe.InvalidCreateError)
  require.NotEmpty(t, probe.InvalidUpdateError)
  require.Equal(t, []string{"high", ""}, writer.createPriorities)
  require.Equal(t, []string{"high"}, writer.updatePriorities)
}
Task writer adapter
type externalProcessTaskWriter struct {
  svc              *taskservice.Service
  workspaceID      string
  workflowID       string
  createPriorities []string
  updatePriorities []string
}

func (w *externalProcessTaskWriter) CreateTask(ctx context.Context, in TaskCreateInput) (*taskmodels.Task, error) {
  w.createPriorities = append(w.createPriorities, in.Priority)
  result, err := w.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{
    WorkspaceID: w.workspaceID, WorkflowID: w.workflowID, WorkflowStepID: in.WorkflowStepID,
    Title: in.Title, Description: in.Description, ParentID: in.ParentID, Metadata: in.Metadata,
    PlanMode: in.PlanMode, Priority: in.Priority, StartAgent: in.StartAgent,
  })
  return result.Task, err
}

func (w *externalProcessTaskWriter) UpdateTask(ctx context.Context, in TaskUpdateInput) (*taskmodels.Task, error) {
  if in.Priority != nil {
    w.updatePriorities = append(w.updatePriorities, *in.Priority)
  }
  return w.svc.UpdateTask(ctx, in.ID, &taskservice.UpdateTaskRequest{
    Title: in.Title, Description: in.Description, Priority: in.Priority,
  })
}
Read the changes as a list

Fixture manifest declares priority contract

apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml

The manifest now declares api_read and a priority webhook so the fixture can exercise Host write and read.

Manifest diff
    events: ["task.created"]
  api_read: ["tasks"]
    api_write: ["tasks"]
    state: true
  - key: "priority"
    description: "Exercises external Host priority persistence for integration tests"
    method: "POST"
    - key: "test-hook"
      description: "Records inbound webhooks for e2e polling"
      method: "POST"

Manifest guard updated

apps/backend/cmd/plugin-fixture/fixture_package_test.go

The guard now checks api_read, api_write, and three webhooks to prevent drift.

Guard assertions
require.Equal(t, []string{"tasks"}, m.Capabilities.APIRead)
require.Equal(t, []string{"tasks"}, m.Capabilities.APIWrite)
require.Len(t, m.Webhooks, 3)
require.Equal(t, "priority", m.Webhooks[0].Key)
require.Equal(t, manifest.WebhookAccessAuthenticated, m.Webhooks[0].EffectiveAccess(m.APIVersion), "priority exercises the private Host write path")
require.Equal(t, "test-hook", m.Webhooks[1].Key)
require.Equal(t, "public-hook", m.Webhooks[2].Key)

Fixture binary adds priorityProbe

apps/backend/cmd/plugin-fixture/plugin.go

The probe creates tasks with high and default priority, updates one, checks invalid values, and returns readback JSON.

Webhook routing
func (p *fixturePlugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) {
  rec := webhookRecord{WebhookKey: req.WebhookKey, Method: req.Method}
  if err := appendJSONLine(filepath.Join(p.dataDir, webhooksFileName), rec); err != nil {
    return nil, err
  }
  p.snapshotConfigBestEffort(ctx)
  p.snapshotSecretProbeBestEffort(ctx)
  if req.WebhookKey == writeProbeWebhookKey {
    p.snapshotWriteProbeBestEffort(ctx)
  }
  if req.WebhookKey == priorityProbeWebhookKey {
    return p.priorityProbe(ctx)
  }
  return &pluginsdk.WebhookResponse{Status: 200, Body: []byte("ok")}, nil
}
priorityProbe
type priorityProbeResult struct {
  CreateHighReadback    string `json:"create_high_readback"`
  CreateDefaultReadback string `json:"create_default_readback"`
  UpdateHighReadback    string `json:"update_high_readback"`
  InvalidCreateError    string `json:"invalid_create_error"`
  InvalidUpdateError    string `json:"invalid_update_error"`
}

func (p *fixturePlugin) priorityProbe(ctx context.Context) (*pluginsdk.WebhookResponse, error) {
  host := p.Host()
  if host == nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte("no host")}, nil
  }
  result := priorityProbeResult{}
  createdHigh, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{
    WorkspaceID: "ws-probe", WorkflowID: "wf-probe", Title: "priority high", Priority: "high",
  })
  if err != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil
  }
  if readback, readErr := host.Tasks().Get(ctx, createdHigh.ID); readErr != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(readErr.Error())}, nil
  } else {
    result.CreateHighReadback = readback.Priority
  }
  createdDefault, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{
    WorkspaceID: "ws-probe", WorkflowID: "wf-probe", Title: "priority default",
  })
  if err != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil
  }
  if readback, readErr := host.Tasks().Get(ctx, createdDefault.ID); readErr != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(readErr.Error())}, nil
  } else {
    result.CreateDefaultReadback = readback.Priority
  }
  high := "high"
  if _, err := host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{ID: createdDefault.ID, Priority: &high}); err != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil
  }
  if readback, readErr := host.Tasks().Get(ctx, createdDefault.ID); readErr != nil {
    return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(readErr.Error())}, nil
  } else {
    result.UpdateHighReadback = readback.Priority
  }
  invalid := "invalid"
  if _, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{
    WorkspaceID: "ws-probe", WorkflowID: "wf-probe", Title: "invalid priority", Priority: invalid,
  }); err != nil {
    result.InvalidCreateError = err.Error()
  }
  if _, err := host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{ID: createdDefault.ID, Priority: &invalid}); err != nil {
    result.InvalidUpdateError = err.Error()
  }
  body, err := json.Marshal(result)
  if err != nil {
    return nil, err
  }
  return &pluginsdk.WebhookResponse{Status: 200, Body: body}, nil
}

External-process persistence test

apps/backend/internal/plugins/host_write_external_process_test.go

The test builds the fixture binary, starts it as an external process, calls the priority webhook, and checks SQLite persistence.

Wire payload pin
func TestPluginPriorityWirePayload(t *testing.T) {
  high := "high"
  createPayload, err := proto.Marshal(&pluginv1.CreateTaskRequest{Priority: high})
  require.NoError(t, err)
  require.True(t, bytes.Contains(createPayload, []byte{0x5a, 0x04, 'h', 'i', 'g', 'h'}))
  updatePayload, err := proto.Marshal(&pluginv1.UpdateTaskRequest{Priority: &high})
  require.NoError(t, err)
  require.True(t, bytes.Contains(updatePayload, []byte{0x32, 0x04, 'h', 'i', 'g', 'h'}))
}
External process test
func TestPluginHost_ExternalProcessPersistsPriorityThroughTaskService(t *testing.T) {
  ctx := context.Background()
  taskSvc, closeDB := newExternalProcessTaskService(t)
  t.Cleanup(closeDB)
  workspaces, err := taskSvc.ListWorkspaces(ctx)
  require.NoError(t, err)
  workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true)
  require.NoError(t, err)
  writer := &externalProcessTaskWriter{svc: taskSvc, workspaceID: workspaces[0].ID, workflowID: workflows[0].ID}
  host := &pluginHost{
    pluginID: "priority-probe",
    capabilities: manifest.Capabilities{APIRead: []string{"tasks"}, APIWrite: []string{"tasks"}},
    taskData: taskSvc,
    taskWriter: writer,
  }
  bin := buildExternalPriorityProbe(t)
  installPath := t.TempDir()
  platform := goruntime.GOOS + "-" + goruntime.GOARCH
  relative := filepath.Join("server", "plugin-"+platform)
  destination := filepath.Join(installPath, relative)
  require.NoError(t, os.MkdirAll(filepath.Dir(destination), 0o755))
  contents, err := os.ReadFile(bin)
  require.NoError(t, err)
  require.NoError(t, os.WriteFile(destination, contents, 0o755))
  log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
  require.NoError(t, err)
  runtime := pluginruntime.NewManager(t.TempDir(), nil, log)
  t.Cleanup(runtime.StopAll)
  record := &store.Record{Manifest: manifest.Manifest{
    ID: "priority-probe", APIVersion: 1, Version: "1.0.0",
    Runtime: manifest.Runtime{Type: "binary", Executables: map[string]string{platform: relative}},
  }, InstallPath: installPath}
  require.NoError(t, runtime.Start(ctx, record, func(string) pluginsdk.Host { return host }))
  remote, ok := runtime.Get(record.ID)
  require.True(t, ok)
  var response *pluginsdk.WebhookResponse
  require.Eventually(t, func() bool {
    var callErr error
    response, callErr = remote.HandleWebhook(ctx, &pluginsdk.WebhookRequest{WebhookKey: "priority"})
    return callErr == nil && string(response.Body) != "no host"
  }, 5*time.Second, 20*time.Millisecond, "the plugin must receive its Host before the priority probe")
  require.Equalf(t, int32(200), response.Status, "external plugin response: %s", response.Body)
  var probe externalPriorityProbeResult
  require.NoError(t, json.Unmarshal(response.Body, &probe), "plugin response must expose immediate Host readback")
  require.Equal(t, "high", probe.CreateHighReadback)
  require.Equal(t, "medium", probe.CreateDefaultReadback)
  require.Equal(t, "high", probe.UpdateHighReadback)
  require.NotEmpty(t, probe.InvalidCreateError)
  require.NotEmpty(t, probe.InvalidUpdateError)
  require.Equal(t, []string{"high", ""}, writer.createPriorities)
  require.Equal(t, []string{"high"}, writer.updatePriorities)
}
Task writer adapter
type externalProcessTaskWriter struct {
  svc              *taskservice.Service
  workspaceID      string
  workflowID       string
  createPriorities []string
  updatePriorities []string
}

func (w *externalProcessTaskWriter) CreateTask(ctx context.Context, in TaskCreateInput) (*taskmodels.Task, error) {
  w.createPriorities = append(w.createPriorities, in.Priority)
  result, err := w.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{
    WorkspaceID: w.workspaceID, WorkflowID: w.workflowID, WorkflowStepID: in.WorkflowStepID,
    Title: in.Title, Description: in.Description, ParentID: in.ParentID, Metadata: in.Metadata,
    PlanMode: in.PlanMode, Priority: in.Priority, StartAgent: in.StartAgent,
  })
  return result.Task, err
}

func (w *externalProcessTaskWriter) UpdateTask(ctx context.Context, in TaskUpdateInput) (*taskmodels.Task, error) {
  if in.Priority != nil {
    w.updatePriorities = append(w.updatePriorities, *in.Priority)
  }
  return w.svc.UpdateTask(ctx, in.ID, &taskservice.UpdateTaskRequest{
    Title: in.Title, Description: in.Description, Priority: in.Priority,
  })
}

Data and storage

Priority is a task field that flows from plugin input through Host to SQLite. The test checks each stage.

FieldTypeNotes
prioritystringhigh, medium, low, or empty (defaults to medium)
CreateTaskInput.Prioritystringsent as field 11 (0x5a) in CreateTaskRequest
UpdateTaskInput.Priority*stringsent as field 6 (0x32) in UpdateTaskRequest, nil means no change
Task.Prioritystringpersisted in SQLite and returned by Host.Tasks().Get

Risk

2 / 10 Low
1 low5 medium10 high

Why this score

  • Test-only change, no production code or migration.
  • Fixture binary is isolated to test scope and gated on webhook key.
  • External build adds time but runs only in this test file.

Trade-offs and review notes

Where to look first

  1. Check that priorityProbe covers high, default, update, and invalid cases with correct readback.
  2. Confirm wire payload test pins field numbers 0x5a and 0x32 and will break on proto drift.
  3. Verify externalProcessTaskWriter forwards Priority without transform and records it for assertions.
  4. Confirm manifest guard expects three webhooks and api_read/api_write for tasks.