Skip to content
This repository was archived by the owner on Jun 2, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions flow/internal/converter/dao/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ func TaskFrom(dao *model.Task) *taskdef.Task {
ExecutionID: dao.ExecutionID,
Status: dao.Status,
Message: dao.Message,
Report: dao.Report,
AppliedRuleID: dao.AppliedRuleID,
CreatedAt: dao.CreatedAt,
UpdatedAt: dao.UpdatedAt,
Expand Down Expand Up @@ -326,6 +327,7 @@ func TaskTo(task *taskdef.Task) *model.Task {
ExecutionID: task.ExecutionID,
Status: task.Status,
Message: task.Message,
Report: task.Report,
AppliedRuleID: task.AppliedRuleID,
QueueExpiresAt: task.QueueExpiresAt,
}
Expand Down
1 change: 1 addition & 0 deletions flow/internal/converter/protobuf/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ func TaskTo(task *taskdef.Task) *pb.Task {
ExecutionId: task.ExecutionID,
Status: TaskStatusTo(task.Status),
Message: task.Message,
Report: string(task.Report),
CreatedAt: timestamppb.New(task.CreatedAt),
UpdatedAt: timestamppb.New(task.UpdatedAt),
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-- SPDX-License-Identifier: Apache-2.0

ALTER TABLE public.task
DROP COLUMN IF EXISTS report;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-- SPDX-License-Identifier: Apache-2.0

ALTER TABLE public.task
ADD COLUMN report jsonb;
25 changes: 25 additions & 0 deletions flow/internal/db/model/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Task struct {
ExecutionID string `bun:"execution_id,notnull"`
Status taskcommon.TaskStatus `bun:"status,type:varchar(32),notnull"`
Message string `bun:"message,nullzero"`
Report json.RawMessage `bun:"report,type:jsonb"`
AppliedRuleID *uuid.UUID `bun:"applied_rule_id,type:uuid"` // Which operation rule was applied
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"`
Expand Down Expand Up @@ -92,17 +93,23 @@ func (t *Task) UpdateScheduledTask(
}

// UpdateTaskStatus updates the status of the task.
// report, when non-nil, replaces the stored report column.
func (t *Task) UpdateTaskStatus(
ctx context.Context,
idb bun.IDB,
status taskcommon.TaskStatus,
message string,
report json.RawMessage,
) error {
t.Status = status
t.Message = message
t.UpdatedAt = time.Now().UTC()

columns := []string{"status", "message", "updated_at", "finished_at"}
if report != nil {
t.Report = report
columns = append(columns, "report")
}

if status == taskcommon.TaskStatusRunning && t.StartedAt == nil {
t.StartedAt = &t.UpdatedAt
Expand All @@ -123,6 +130,24 @@ func (t *Task) UpdateTaskStatus(
return err
}

// UpdateTaskReport updates the structured report without changing status or message.
func (t *Task) UpdateTaskReport(
ctx context.Context,
idb bun.IDB,
report json.RawMessage,
) error {
t.Report = report
t.UpdatedAt = time.Now().UTC()

_, err := idb.NewUpdate().
Model(t).
Column("report", "updated_at").
Where("id = ?", t.ID).
Exec(ctx)

return err
}

// taskComponentIDFilter restricts a query to tasks whose attributes
// reference a specific component UUID. The UUID may live under any bucket
// of attributes.components_by_type, which rules out a fixed @> check
Expand Down
4 changes: 4 additions & 0 deletions flow/internal/scheduler/taskschedule/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ func (m *mockTaskStore) UpdateTaskStatus(_ context.Context, _ *taskdef.TaskStatu
panic("mockTaskStore.UpdateTaskStatus: not implemented")
}

func (m *mockTaskStore) UpdateTaskReport(_ context.Context, _ *taskdef.TaskReportUpdate) error {
panic("mockTaskStore.UpdateTaskReport: not implemented")
}

func (m *mockTaskStore) ListActiveTasksForRack(_ context.Context, _ uuid.UUID) ([]*taskdef.Task, error) {
panic("mockTaskStore.ListActiveTasksForRack: not implemented")
}
Expand Down
63 changes: 9 additions & 54 deletions flow/internal/scheduler/taskschedule/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/NVIDIA/infra-controller-rest/flow/internal/operation"
taskcommon "github.com/NVIDIA/infra-controller-rest/flow/internal/task/common"
"github.com/NVIDIA/infra-controller-rest/flow/internal/task/operations"
)

// TaskTemplate is the JSON-serialized operation stored in task_schedule.operation_template.
Expand Down Expand Up @@ -111,61 +112,15 @@ func OptionsFromTemplate(raw json.RawMessage) (TemplateOptions, error) {
// (e.g. "POWER_ON", "BRING_UP"). description is a short English phrase for
// display (e.g. "Power Off (forced)", "Upgrade Firmware to v2.1.0").
func SummaryFromTemplate(templateJSON json.RawMessage) (opType, description string, err error) {
var tmpl TaskTemplate
if err = json.Unmarshal(templateJSON, &tmpl); err != nil {
return "", "", fmt.Errorf("unmarshal operation_template: %w", err)
w, err := WrapperFromTemplate(templateJSON)
if err != nil {
return "", "", err
}

switch tmpl.Type {
case taskcommon.TaskTypePowerControl:
switch tmpl.Code {
case taskcommon.OpCodePowerControlPowerOn, taskcommon.OpCodePowerControlForcePowerOn:
return "POWER_ON", "Power On", nil
case taskcommon.OpCodePowerControlPowerOff:
return "POWER_OFF", "Power Off", nil
case taskcommon.OpCodePowerControlForcePowerOff:
return "POWER_OFF", "Power Off (forced)", nil
case taskcommon.OpCodePowerControlRestart, taskcommon.OpCodePowerControlWarmReset:
return "POWER_RESET", "Power Reset", nil
case taskcommon.OpCodePowerControlForceRestart, taskcommon.OpCodePowerControlColdReset:
return "POWER_RESET", "Power Reset (forced)", nil
default:
return "POWER_CONTROL", tmpl.Code, nil
}

case taskcommon.TaskTypeBringUp:
if tmpl.Code == taskcommon.OpCodeIngest {
return "INGEST", "Ingest", nil
}
return "BRING_UP", "Bring Up", nil

case taskcommon.TaskTypeFirmwareControl:
var info struct {
TargetVersion string `json:"target_version"`
}
if len(tmpl.Info) > 0 {
if err = json.Unmarshal(tmpl.Info, &info); err != nil {
return "", "", fmt.Errorf("unmarshal firmware info: %w", err)
}
}
switch tmpl.Code {
case taskcommon.OpCodeFirmwareControlUpgrade:
if info.TargetVersion != "" {
return "UPGRADE_FIRMWARE", "Upgrade Firmware to " + info.TargetVersion, nil
}
return "UPGRADE_FIRMWARE", "Upgrade Firmware", nil
case taskcommon.OpCodeFirmwareControlDowngrade:
if info.TargetVersion != "" {
return "DOWNGRADE_FIRMWARE", "Downgrade Firmware to " + info.TargetVersion, nil
}
return "DOWNGRADE_FIRMWARE", "Downgrade Firmware", nil
case taskcommon.OpCodeFirmwareControlRollback:
return "ROLLBACK_FIRMWARE", "Rollback Firmware", nil
default: // unrecognized
return "FIRMWARE_CONTROL", tmpl.Code, nil
}

default:
return string(tmpl.Type), tmpl.Code, nil
description, err = operations.SummaryFromWrapper(w)
if err != nil {
return "", "", err
}

return operations.OperationTypeFromWrapper(w), description, nil
}
6 changes: 3 additions & 3 deletions flow/internal/scheduler/taskschedule/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ func TestSummaryFromTemplate(t *testing.T) {
"unknown power control code": {
input: mustMarshalTemplate(taskcommon.TaskTypePowerControl, "bmc_cycle", emptyInfo),
wantOpType: "POWER_CONTROL",
wantDesc: "bmc_cycle",
wantDesc: "bmc cycle",
},

// ── bring-up / ingest ────────────────────────────────────────────────
Expand Down Expand Up @@ -281,8 +281,8 @@ func TestSummaryFromTemplate(t *testing.T) {
// ── unknown type ──────────────────────────────────────────────────────
"unknown operation type": {
input: mustMarshalTemplate(taskcommon.TaskTypeInjectExpectation, "inject", emptyInfo),
wantOpType: string(taskcommon.TaskTypeInjectExpectation),
wantDesc: "inject",
wantOpType: "INJECT_EXPECTATION",
wantDesc: "Inject Expectation",
},
}

Expand Down
4 changes: 4 additions & 0 deletions flow/internal/task/conflict/store_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ func (m *mockStore) ListRacksWithWaitingTasks(ctx context.Context) ([]uuid.UUID,
return m.racksWithWaiting, nil
}

func (m *mockStore) UpdateTaskReport(_ context.Context, _ *taskdef.TaskReportUpdate) error {
return nil
}

func (m *mockStore) UpdateTaskStatus(ctx context.Context, arg *taskdef.TaskStatusUpdate) error {
if m.updateStatusErr != nil {
return m.updateStatusErr
Expand Down
15 changes: 12 additions & 3 deletions flow/internal/task/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,25 @@ type Executor interface {
type ExecutorConfig interface {
Validate() error
// Build constructs the Executor. updater receives task status transitions
// from the execution engine back to the store. It must not be nil.
Build(ctx context.Context, updater task.TaskStatusUpdater) (Executor, error)
// from the execution engine back to the store and must not be nil.
// reportUpdater receives in-flight report snapshots and may be nil; when
// nil, activities that need it return an error at invocation time.
Build(
ctx context.Context,
updater task.TaskStatusUpdater,
reportUpdater task.TaskReportUpdater,
) (Executor, error)
}

// New validates the config and builds the Executor, wiring updater so the
// engine can report task status changes without importing store packages.
// reportUpdater is plumbed through the same call to persist progress
// snapshots; nil is allowed and disables in-flight reporting.
func New(
ctx context.Context,
executorConfig ExecutorConfig,
updater task.TaskStatusUpdater,
reportUpdater task.TaskReportUpdater,
) (Executor, error) {
if executorConfig == nil {
return nil, fmt.Errorf("executor config is required")
Expand All @@ -67,5 +76,5 @@ func New(
return nil, fmt.Errorf("task status updater is required")
}

return executorConfig.Build(ctx, updater)
return executorConfig.Build(ctx, updater, reportUpdater)
}
2 changes: 1 addition & 1 deletion flow/internal/task/executor/temporalworkflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ ExecutionRequest{OperationType, OperationInfo}

**Workflow registry** (`workflow/registry.go`): uses `init()` self-registration. Task-dispatched workflow files call `registerTaskWorkflow[T, *T](taskType, name, fn)`, which derives the timeout and builds the `Unmarshal` closure automatically. Internal workflows (those without a `TaskType`) call `register(WorkflowDescriptor{...})` directly. Nothing needs to be added to a central list — the registry is populated automatically at startup.

**Activity registry** (`activity/registry.go`): uses per-instance dependency injection. `Build()` creates an `*Activities` value via `activity.New(updater, registry)` and calls `acts.All()` to obtain the name → bound-method map, then registers each entry with the Temporal worker via `RegisterActivityWithOptions(fn, {Name: name})`. Because activities are methods on `*Activities`, each manager instance holds its own isolated copy of the dependencies — no shared mutable globals.
**Activity registry** (`activity/registry.go`): uses per-instance dependency injection. `Build()` creates an `*Activities` value via `activity.New(updater, reportUpdater, registry)` and calls `acts.All()` to obtain the name → bound-method map, then registers each entry with the Temporal worker via `RegisterActivityWithOptions(fn, {Name: name})`. Status and report updaters are wired as separate parameters so each role is explicit at the call site. Because activities are methods on `*Activities`, each manager instance holds its own isolated copy of the dependencies — no shared mutable globals.

## Adding a New Operation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const (
NamePowerControl = "PowerControl"
NameGetPowerStatus = "GetPowerStatus"
NameUpdateTaskStatus = "UpdateTaskStatus"
NameUpdateTaskReport = "UpdateTaskReport"
NameFirmwareControl = "FirmwareControl"
NameGetFirmwareStatus = "GetFirmwareStatus"
NameBringUpControl = "BringUpControl"
Expand Down Expand Up @@ -114,6 +115,23 @@ func (a *Activities) UpdateTaskStatus(
return a.updater.UpdateTaskStatus(ctx, arg)
}

// UpdateTaskReport is a Temporal activity that merges a structured report
// snapshot without changing status or message.
func (a *Activities) UpdateTaskReport(
ctx context.Context,
arg *task.TaskReportUpdate,
) error {
if a.reportUpdater == nil {
return fmt.Errorf("task report updater is not configured")
}

if arg == nil || arg.ID == uuid.Nil {
return fmt.Errorf("invalid task identifier")
}

return a.reportUpdater.UpdateTaskReport(ctx, arg)
}

// FirmwareControl initiates firmware update without waiting for completion.
// This activity returns immediately after the update request is accepted.
func (a *Activities) FirmwareControl(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import (
)

func TestActivitiesReturnErrorWhenComponentManagerRegistryIsMissing(t *testing.T) {
acts := New(nil, nil)
acts := New(nil, nil, nil)

for name, call := range activityCallsForMissingManagerTest(t, acts) {
t.Run(name, func(t *testing.T) {
Expand All @@ -54,7 +54,7 @@ func TestActivitiesReturnErrorWhenComponentManagerIsMissing(t *testing.T) {
)
require.NoError(t, err)

acts := New(nil, registry)
acts := New(nil, nil, registry)

for name, call := range activityCallsForMissingManagerTest(t, acts) {
t.Run(name, func(t *testing.T) {
Expand Down Expand Up @@ -292,7 +292,7 @@ func newCapabilityTestActivities(
)
require.NoError(t, err)

return New(nil, registry), manager
return New(nil, nil, registry), manager
}

func newActivityTestTarget() common.Target {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,22 @@ import (
// independent, multiple managers can coexist in the same process without
// sharing mutable state.
type Activities struct {
updater task.TaskStatusUpdater
registry *componentmanager.Registry
updater task.TaskStatusUpdater
reportUpdater task.TaskReportUpdater
registry *componentmanager.Registry
}

// New creates an Activities instance bound to the given status updater and
// component manager registry. Either argument may be nil; activity calls that
// require the missing dependency will return an error at invocation time.
// New creates an Activities instance. Any argument may be nil; activity
// calls that need a missing dependency return an error at invocation time.
func New(
updater task.TaskStatusUpdater,
reportUpdater task.TaskReportUpdater,
registry *componentmanager.Registry,
) *Activities {
return &Activities{
updater: updater,
registry: registry,
updater: updater,
reportUpdater: reportUpdater,
registry: registry,
}
}

Expand All @@ -55,6 +57,7 @@ func (a *Activities) All() map[string]any {
NamePowerControl: a.PowerControl,
NameGetPowerStatus: a.GetPowerStatus,
NameUpdateTaskStatus: a.UpdateTaskStatus,
NameUpdateTaskReport: a.UpdateTaskReport,
NameFirmwareControl: a.FirmwareControl,
NameGetFirmwareStatus: a.GetFirmwareStatus,
NameBringUpControl: a.BringUpControl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ import (
// TestActivities_All_ContainsAllActivities verifies that All() returns every
// expected activity name with a non-nil function value.
func TestActivities_All_ContainsAllActivities(t *testing.T) {
acts := New(nil, nil)
acts := New(nil, nil, nil)
all := acts.All()

expectedNames := []string{
NameInjectExpectation,
NamePowerControl,
NameGetPowerStatus,
NameUpdateTaskStatus,
NameUpdateTaskReport,
NameFirmwareControl,
NameGetFirmwareStatus,
NameBringUpControl,
Expand All @@ -56,7 +57,7 @@ func TestActivities_All_ContainsAllActivities(t *testing.T) {
// TestActivities_All_ReturnsCopy verifies that mutating the returned map does
// not affect subsequent calls — each call produces an independent map.
func TestActivities_All_ReturnsCopy(t *testing.T) {
acts := New(nil, nil)
acts := New(nil, nil, nil)
first := acts.All()
firstLen := len(first)

Expand All @@ -70,8 +71,8 @@ func TestActivities_All_ReturnsCopy(t *testing.T) {
// TestActivities_Isolation verifies that two Activities instances do not share
// state: mutations to one instance's map must not affect the other.
func TestActivities_Isolation(t *testing.T) {
a1 := New(nil, nil)
a2 := New(nil, nil)
a1 := New(nil, nil, nil)
a2 := New(nil, nil, nil)

m1 := a1.All()
m1["isolation-sentinel"] = func() {}
Expand All @@ -83,7 +84,7 @@ func TestActivities_Isolation(t *testing.T) {
// TestActivities_ValidAndGetComponentManager_NilRegistry verifies that manager
// lookup returns a clear configuration error when no registry is configured.
func TestActivities_ValidAndGetComponentManager_NilRegistry(t *testing.T) {
acts := New(nil, nil)
acts := New(nil, nil, nil)
_, err := acts.validAndGetComponentManager(
newActivityTestTarget(),
capability.CapabilityPowerControl,
Expand Down
Loading
Loading