mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Drop-in replacement — same API, same package name (dbx). 75 Go files updated. Zero pocketbase references in imports.
736 lines
21 KiB
Go
736 lines
21 KiB
Go
package tasks
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/dbx"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/types"
|
|
)
|
|
|
|
const (
|
|
// TasksCollection is the internal collection for tasks.
|
|
TasksCollection = "_tasks"
|
|
// WorkflowsCollection is the internal collection for workflows.
|
|
WorkflowsCollection = "_workflows"
|
|
)
|
|
|
|
var (
|
|
ErrTaskNotFound = errors.New("task not found")
|
|
ErrWorkflowNotFound = errors.New("workflow not found")
|
|
ErrInvalidTransition = errors.New("invalid state transition")
|
|
ErrAlreadyClaimed = errors.New("task already claimed")
|
|
)
|
|
|
|
// Store provides SQLite-backed task persistence using Base collections.
|
|
type Store struct {
|
|
app core.App
|
|
}
|
|
|
|
// NewStore creates a new task store.
|
|
func NewStore(app core.App) *Store {
|
|
return &Store{app: app}
|
|
}
|
|
|
|
// --- Task CRUD ---
|
|
|
|
// CreateTask persists a new task. ID is auto-generated if empty.
|
|
func (s *Store) CreateTask(task *Task) error {
|
|
if task.Title == "" {
|
|
return errors.New("title is required")
|
|
}
|
|
|
|
col, err := s.app.FindCollectionByNameOrId(TasksCollection)
|
|
if err != nil {
|
|
return fmt.Errorf("tasks collection not found: %w", err)
|
|
}
|
|
|
|
record := core.NewRecord(col)
|
|
s.setTaskFields(record, task)
|
|
|
|
if task.State == "" {
|
|
record.Set("state", string(TaskPending))
|
|
}
|
|
|
|
if err := s.app.Save(record); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Read back auto-generated fields.
|
|
task.ID = record.Id
|
|
task.CreatedAt = record.GetDateTime("created").Time()
|
|
task.UpdatedAt = record.GetDateTime("updated").Time()
|
|
if task.State == "" {
|
|
task.State = TaskPending
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetTask retrieves a task by ID.
|
|
// If orgID is provided, verifies the task belongs to that org.
|
|
func (s *Store) GetTask(id string, orgID ...string) (*Task, error) {
|
|
record, err := s.app.FindRecordById(TasksCollection, id)
|
|
if err != nil {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
task := s.recordToTask(record)
|
|
if len(orgID) > 0 && orgID[0] != "" && task.OrgID != orgID[0] {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
// UpdateTask patches mutable fields on an existing task.
|
|
func (s *Store) UpdateTask(task *Task) error {
|
|
record, err := s.app.FindRecordById(TasksCollection, task.ID)
|
|
if err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
|
|
s.setTaskFields(record, task)
|
|
|
|
if err := s.app.Save(record); err != nil {
|
|
return err
|
|
}
|
|
task.UpdatedAt = record.GetDateTime("updated").Time()
|
|
return nil
|
|
}
|
|
|
|
// ListTasks returns filtered tasks, sorted by priority DESC then created ASC.
|
|
func (s *Store) ListTasks(filters TaskFilters) ([]*Task, error) {
|
|
query := s.app.RecordQuery(TasksCollection).
|
|
OrderBy("priority DESC", "created ASC")
|
|
|
|
if filters.OrgID != "" {
|
|
query = query.AndWhere(dbx.HashExp{"orgId": filters.OrgID})
|
|
}
|
|
if filters.SpaceID != "" {
|
|
query = query.AndWhere(dbx.HashExp{"spaceId": filters.SpaceID})
|
|
}
|
|
if filters.State != nil {
|
|
query = query.AndWhere(dbx.HashExp{"state": string(*filters.State)})
|
|
}
|
|
if filters.AssignedTo != nil {
|
|
query = query.AndWhere(dbx.HashExp{"assignedTo": *filters.AssignedTo})
|
|
}
|
|
if filters.Priority != nil {
|
|
query = query.AndWhere(dbx.HashExp{"priority": int(*filters.Priority)})
|
|
}
|
|
if filters.WorkflowID != nil {
|
|
query = query.AndWhere(dbx.HashExp{"workflowId": *filters.WorkflowID})
|
|
}
|
|
if filters.Offset > 0 {
|
|
query = query.Offset(int64(filters.Offset))
|
|
}
|
|
limit := filters.Limit
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
query = query.Limit(int64(limit))
|
|
|
|
var records []*core.Record
|
|
if err := query.All(&records); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]*Task, 0, len(records))
|
|
for _, r := range records {
|
|
result = append(result, s.recordToTask(r))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// --- Atomic state transitions (raw SQL for exactly-once semantics) ---
|
|
|
|
// ClaimTask atomically transitions a task from pending to claimed.
|
|
// orgID scopes the mutation to a specific org when provided.
|
|
func (s *Store) ClaimTask(taskID, agentID string, orgID ...string) error {
|
|
now := types.NowDateTime()
|
|
where := "WHERE [[id]] = {:id} AND [[state]] = {:pending}"
|
|
params := dbx.Params{
|
|
"state": string(TaskClaimed),
|
|
"agent": agentID,
|
|
"now": now.String(),
|
|
"id": taskID,
|
|
"pending": string(TaskPending),
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
result, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + TasksCollection + "}} SET " +
|
|
"[[state]] = {:state}, [[assignedTo]] = {:agent}, [[updated]] = {:now} " +
|
|
where,
|
|
).Bind(params).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
// Check if task exists and belongs to the caller's org.
|
|
if _, err := s.GetTask(taskID, orgID...); err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
return ErrAlreadyClaimed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartTask transitions a claimed (or pending) task to running.
|
|
// orgID scopes the mutation to a specific org when provided.
|
|
func (s *Store) StartTask(taskID string, orgID ...string) error {
|
|
now := types.NowDateTime()
|
|
where := "WHERE [[id]] = {:id} AND ([[state]] = {:claimed} OR [[state]] = {:pending})"
|
|
params := dbx.Params{
|
|
"running": string(TaskRunning),
|
|
"now": now.String(),
|
|
"id": taskID,
|
|
"claimed": string(TaskClaimed),
|
|
"pending": string(TaskPending),
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
result, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + TasksCollection + "}} SET " +
|
|
"[[state]] = {:running}, [[startedAt]] = {:now}, [[updated]] = {:now} " +
|
|
where,
|
|
).Bind(params).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
if _, err := s.GetTask(taskID, orgID...); err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
return ErrInvalidTransition
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CompleteTask transitions a running task to completed with output.
|
|
// orgID scopes the mutation to a specific org when provided.
|
|
func (s *Store) CompleteTask(taskID string, output map[string]any, orgID ...string) error {
|
|
now := types.NowDateTime()
|
|
outputJSON := marshalJSON(output)
|
|
|
|
where := "WHERE [[id]] = {:id} AND [[state]] = {:running}"
|
|
params := dbx.Params{
|
|
"state": string(TaskCompleted),
|
|
"output": string(outputJSON),
|
|
"now": now.String(),
|
|
"id": taskID,
|
|
"running": string(TaskRunning),
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
result, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + TasksCollection + "}} SET " +
|
|
"[[state]] = {:state}, [[output]] = {:output}, [[progress]] = 100, " +
|
|
"[[completedAt]] = {:now}, [[updated]] = {:now} " +
|
|
where,
|
|
).Bind(params).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
if _, err := s.GetTask(taskID, orgID...); err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
return ErrInvalidTransition
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FailTask transitions a running task to failed. If retries remain, re-queues as pending.
|
|
// Uses a single atomic SQL with CASE to avoid TOCTOU races.
|
|
// orgID scopes the mutation to a specific org when provided.
|
|
func (s *Store) FailTask(taskID string, errMsg string, orgID ...string) error {
|
|
now := types.NowDateTime()
|
|
where := "WHERE [[id]] = {:id} AND [[state]] = {:running}"
|
|
params := dbx.Params{
|
|
"pending": string(TaskPending),
|
|
"failed": string(TaskFailed),
|
|
"error": errMsg,
|
|
"now": now.String(),
|
|
"id": taskID,
|
|
"running": string(TaskRunning),
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
result, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + TasksCollection + "}} SET " +
|
|
"[[state]] = CASE WHEN [[retryCount]] < [[maxRetries]] THEN {:pending} ELSE {:failed} END, " +
|
|
"[[retryCount]] = CASE WHEN [[retryCount]] < [[maxRetries]] THEN [[retryCount]] + 1 ELSE [[retryCount]] END, " +
|
|
"[[assignedTo]] = CASE WHEN [[retryCount]] < [[maxRetries]] THEN '' ELSE [[assignedTo]] END, " +
|
|
"[[startedAt]] = CASE WHEN [[retryCount]] < [[maxRetries]] THEN '' ELSE [[startedAt]] END, " +
|
|
"[[progress]] = CASE WHEN [[retryCount]] < [[maxRetries]] THEN 0 ELSE [[progress]] END, " +
|
|
"[[completedAt]] = CASE WHEN [[retryCount]] >= [[maxRetries]] THEN {:now} ELSE [[completedAt]] END, " +
|
|
"[[error]] = {:error}, [[updated]] = {:now} " +
|
|
where,
|
|
).Bind(params).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
if _, err := s.GetTask(taskID, orgID...); err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
return ErrInvalidTransition
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CancelTask transitions any non-terminal task to cancelled.
|
|
// orgID scopes the mutation to a specific org when provided.
|
|
func (s *Store) CancelTask(taskID string, orgID ...string) error {
|
|
now := types.NowDateTime()
|
|
where := "WHERE [[id]] = {:id} AND [[state]] NOT IN ({:completed}, {:cancelled})"
|
|
params := dbx.Params{
|
|
"cancelled": string(TaskCancelled),
|
|
"completed": string(TaskCompleted),
|
|
"now": now.String(),
|
|
"id": taskID,
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
result, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + TasksCollection + "}} SET " +
|
|
"[[state]] = {:cancelled}, [[completedAt]] = {:now}, [[updated]] = {:now} " +
|
|
where,
|
|
).Bind(params).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
if _, err := s.GetTask(taskID, orgID...); err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
return ErrInvalidTransition
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateProgress sets progress (0-100) on a running task.
|
|
// orgID scopes the mutation to a specific org when provided.
|
|
func (s *Store) UpdateProgress(taskID string, progress int, orgID ...string) error {
|
|
if progress < 0 {
|
|
progress = 0
|
|
}
|
|
if progress > 100 {
|
|
progress = 100
|
|
}
|
|
|
|
now := types.NowDateTime()
|
|
where := "WHERE [[id]] = {:id} AND [[state]] = {:running}"
|
|
params := dbx.Params{
|
|
"progress": progress,
|
|
"now": now.String(),
|
|
"id": taskID,
|
|
"running": string(TaskRunning),
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
result, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + TasksCollection + "}} SET " +
|
|
"[[progress]] = {:progress}, [[updated]] = {:now} " +
|
|
where,
|
|
).Bind(params).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
if _, err := s.GetTask(taskID, orgID...); err != nil {
|
|
return ErrTaskNotFound
|
|
}
|
|
return ErrInvalidTransition
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetNextPendingTask finds and atomically claims the highest-priority pending task
|
|
// in the given space whose dependencies are all completed.
|
|
// orgID scopes the query to a specific org when provided.
|
|
func (s *Store) GetNextPendingTask(spaceID, agentID string, orgID ...string) (*Task, error) {
|
|
pending := TaskPending
|
|
filters := TaskFilters{
|
|
SpaceID: spaceID,
|
|
State: &pending,
|
|
Limit: 50,
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
filters.OrgID = orgID[0]
|
|
}
|
|
candidates, err := s.ListTasks(filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, task := range candidates {
|
|
if !s.dependenciesMet(task) {
|
|
continue
|
|
}
|
|
|
|
// Attempt atomic claim scoped to org.
|
|
if err := s.ClaimTask(task.ID, agentID, orgID...); err != nil {
|
|
continue // lost race or invalid transition
|
|
}
|
|
|
|
// Re-read the claimed task scoped to org.
|
|
claimed, err := s.GetTask(task.ID, orgID...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return claimed, nil
|
|
}
|
|
|
|
return nil, nil // no eligible tasks
|
|
}
|
|
|
|
// dependenciesMet checks if all tasks in DependsOn are completed.
|
|
func (s *Store) dependenciesMet(task *Task) bool {
|
|
if len(task.DependsOn) == 0 {
|
|
return true
|
|
}
|
|
for _, depID := range task.DependsOn {
|
|
dep, err := s.GetTask(depID)
|
|
if err != nil || dep.State != TaskCompleted {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// AgentHasActiveTask reports whether the agent has a claimed or running task.
|
|
// orgID scopes the query to a specific org when provided.
|
|
func (s *Store) AgentHasActiveTask(agentID string, orgID ...string) (bool, error) {
|
|
where := "WHERE [[assignedTo]] = {:agent} AND [[state]] IN ({:claimed}, {:running})"
|
|
params := dbx.Params{
|
|
"agent": agentID,
|
|
"claimed": string(TaskClaimed),
|
|
"running": string(TaskRunning),
|
|
}
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = orgID[0]
|
|
}
|
|
|
|
var count int
|
|
err := s.app.ConcurrentDB().NewQuery(
|
|
"SELECT COUNT(*) FROM {{" + TasksCollection + "}} " + where,
|
|
).Bind(params).Row(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// --- Workflow CRUD ---
|
|
|
|
// CreateWorkflow persists a new workflow.
|
|
func (s *Store) CreateWorkflow(wf *Workflow) error {
|
|
if wf.Name == "" {
|
|
return errors.New("workflow name is required")
|
|
}
|
|
|
|
col, err := s.app.FindCollectionByNameOrId(WorkflowsCollection)
|
|
if err != nil {
|
|
return fmt.Errorf("workflows collection not found: %w", err)
|
|
}
|
|
|
|
record := core.NewRecord(col)
|
|
record.Set("orgId", wf.OrgID)
|
|
record.Set("spaceId", wf.SpaceID)
|
|
record.Set("name", wf.Name)
|
|
record.Set("description", wf.Description)
|
|
record.Set("state", string(TaskPending))
|
|
record.Set("createdBy", wf.CreatedBy)
|
|
record.Set("tasks", marshalJSON(wf.Tasks))
|
|
record.Set("metadata", marshalJSON(wf.Metadata))
|
|
|
|
if err := s.app.Save(record); err != nil {
|
|
return err
|
|
}
|
|
|
|
wf.ID = record.Id
|
|
wf.State = TaskPending
|
|
wf.CreatedAt = record.GetDateTime("created").Time()
|
|
wf.UpdatedAt = record.GetDateTime("updated").Time()
|
|
return nil
|
|
}
|
|
|
|
// GetWorkflow retrieves a workflow by ID.
|
|
// If orgID is provided, verifies the workflow belongs to that org.
|
|
func (s *Store) GetWorkflow(id string, orgID ...string) (*Workflow, error) {
|
|
record, err := s.app.FindRecordById(WorkflowsCollection, id)
|
|
if err != nil {
|
|
return nil, ErrWorkflowNotFound
|
|
}
|
|
wf := s.recordToWorkflow(record)
|
|
if len(orgID) > 0 && orgID[0] != "" && wf.OrgID != orgID[0] {
|
|
return nil, ErrWorkflowNotFound
|
|
}
|
|
return wf, nil
|
|
}
|
|
|
|
// ListWorkflows returns workflows for a space, optionally scoped to an org.
|
|
func (s *Store) ListWorkflows(spaceID string, orgID ...string) ([]*Workflow, error) {
|
|
query := s.app.RecordQuery(WorkflowsCollection).
|
|
OrderBy("created ASC")
|
|
|
|
if len(orgID) > 0 && orgID[0] != "" {
|
|
query = query.AndWhere(dbx.HashExp{"orgId": orgID[0]})
|
|
}
|
|
if spaceID != "" {
|
|
query = query.AndWhere(dbx.HashExp{"spaceId": spaceID})
|
|
}
|
|
|
|
var records []*core.Record
|
|
if err := query.All(&records); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]*Workflow, 0, len(records))
|
|
for _, r := range records {
|
|
result = append(result, s.recordToWorkflow(r))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpdateWorkflowTasks updates the task ID list on an existing workflow.
|
|
// When the workflow has an OrgID, the update is scoped to that org.
|
|
func (s *Store) UpdateWorkflowTasks(wf *Workflow) error {
|
|
now := types.NowDateTime()
|
|
where := "WHERE [[id]] = {:id}"
|
|
params := dbx.Params{
|
|
"tasks": string(marshalJSON(wf.Tasks)),
|
|
"now": now.String(),
|
|
"id": wf.ID,
|
|
}
|
|
if wf.OrgID != "" {
|
|
where += " AND [[orgId]] = {:orgId}"
|
|
params["orgId"] = wf.OrgID
|
|
}
|
|
|
|
_, err := s.app.NonconcurrentDB().NewQuery(
|
|
"UPDATE {{" + WorkflowsCollection + "}} SET [[tasks]] = {:tasks}, [[updated]] = {:now} " + where,
|
|
).Bind(params).Execute()
|
|
return err
|
|
}
|
|
|
|
// AdvanceWorkflows checks non-terminal workflows and updates their state
|
|
// based on constituent task states.
|
|
func (s *Store) AdvanceWorkflows() error {
|
|
notDone := []string{string(TaskCompleted), string(TaskFailed), string(TaskCancelled)}
|
|
|
|
var records []*core.Record
|
|
err := s.app.RecordQuery(WorkflowsCollection).
|
|
AndWhere(dbx.NewExp(
|
|
"[[state]] NOT IN ({:s1}, {:s2}, {:s3})",
|
|
dbx.Params{"s1": notDone[0], "s2": notDone[1], "s3": notDone[2]},
|
|
)).
|
|
All(&records)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := types.NowDateTime()
|
|
for _, record := range records {
|
|
wf := s.recordToWorkflow(record)
|
|
|
|
allCompleted := true
|
|
anyFailed := false
|
|
|
|
for _, taskID := range wf.Tasks {
|
|
t, err := s.GetTask(taskID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
switch t.State {
|
|
case TaskCompleted:
|
|
// ok
|
|
case TaskFailed, TaskCancelled:
|
|
anyFailed = true
|
|
allCompleted = false
|
|
default:
|
|
allCompleted = false
|
|
}
|
|
}
|
|
|
|
var newState TaskState
|
|
if allCompleted && len(wf.Tasks) > 0 {
|
|
newState = TaskCompleted
|
|
} else if anyFailed {
|
|
newState = TaskFailed
|
|
} else if wf.State == TaskPending {
|
|
newState = TaskRunning
|
|
}
|
|
|
|
if newState != "" && newState != wf.State {
|
|
params := dbx.Params{
|
|
"state": string(newState),
|
|
"now": now.String(),
|
|
"id": record.Id,
|
|
}
|
|
query := "UPDATE {{" + WorkflowsCollection + "}} SET [[state]] = {:state}, [[updated]] = {:now}"
|
|
if newState == TaskCompleted || newState == TaskFailed {
|
|
query += ", [[completedAt]] = {:now}"
|
|
}
|
|
query += " WHERE [[id]] = {:id}"
|
|
|
|
_, _ = s.app.NonconcurrentDB().NewQuery(query).Bind(params).Execute()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckTimeouts fails or retries tasks that have exceeded their timeout.
|
|
func (s *Store) CheckTimeouts() error {
|
|
var records []*core.Record
|
|
err := s.app.RecordQuery(TasksCollection).
|
|
AndWhere(dbx.HashExp{"state": string(TaskRunning)}).
|
|
AndWhere(dbx.NewExp("[[timeout]] > 0")).
|
|
AndWhere(dbx.NewExp("[[startedAt]] != ''")).
|
|
All(&records)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, r := range records {
|
|
task := s.recordToTask(r)
|
|
if task.StartedAt == nil || task.Timeout <= 0 {
|
|
continue
|
|
}
|
|
if now.Sub(*task.StartedAt) <= task.Timeout {
|
|
continue
|
|
}
|
|
|
|
_ = s.FailTask(task.ID, "task timed out")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Record ↔ Task conversion ---
|
|
|
|
func (s *Store) setTaskFields(record *core.Record, task *Task) {
|
|
record.Set("orgId", task.OrgID)
|
|
record.Set("spaceId", task.SpaceID)
|
|
record.Set("title", task.Title)
|
|
record.Set("description", task.Description)
|
|
record.Set("state", string(task.State))
|
|
record.Set("priority", int(task.Priority))
|
|
record.Set("assignedTo", task.AssignedTo)
|
|
record.Set("createdBy", task.CreatedBy)
|
|
record.Set("workflowId", task.WorkflowID)
|
|
record.Set("parentTaskId", task.ParentTaskID)
|
|
record.Set("error", task.Error)
|
|
record.Set("progress", task.Progress)
|
|
record.Set("maxRetries", task.MaxRetries)
|
|
record.Set("retryCount", task.RetryCount)
|
|
record.Set("timeout", int(task.Timeout.Seconds()))
|
|
|
|
record.Set("dependsOn", marshalJSON(task.DependsOn))
|
|
record.Set("labels", marshalJSON(task.Labels))
|
|
record.Set("input", marshalJSON(task.Input))
|
|
record.Set("output", marshalJSON(task.Output))
|
|
record.Set("metadata", marshalJSON(task.Metadata))
|
|
|
|
if task.StartedAt != nil {
|
|
dt, _ := types.ParseDateTime(*task.StartedAt)
|
|
record.Set("startedAt", dt)
|
|
}
|
|
if task.CompletedAt != nil {
|
|
dt, _ := types.ParseDateTime(*task.CompletedAt)
|
|
record.Set("completedAt", dt)
|
|
}
|
|
}
|
|
|
|
func (s *Store) recordToTask(record *core.Record) *Task {
|
|
task := &Task{
|
|
ID: record.Id,
|
|
OrgID: record.GetString("orgId"),
|
|
SpaceID: record.GetString("spaceId"),
|
|
Title: record.GetString("title"),
|
|
Description: record.GetString("description"),
|
|
State: TaskState(record.GetString("state")),
|
|
Priority: TaskPriority(int(record.GetFloat("priority"))),
|
|
AssignedTo: record.GetString("assignedTo"),
|
|
CreatedBy: record.GetString("createdBy"),
|
|
WorkflowID: record.GetString("workflowId"),
|
|
ParentTaskID: record.GetString("parentTaskId"),
|
|
Error: record.GetString("error"),
|
|
Progress: int(record.GetFloat("progress")),
|
|
MaxRetries: int(record.GetFloat("maxRetries")),
|
|
RetryCount: int(record.GetFloat("retryCount")),
|
|
Timeout: time.Duration(record.GetFloat("timeout")) * time.Second,
|
|
CreatedAt: record.GetDateTime("created").Time(),
|
|
UpdatedAt: record.GetDateTime("updated").Time(),
|
|
}
|
|
|
|
unmarshalJSONField(record.Get("dependsOn"), &task.DependsOn)
|
|
unmarshalJSONField(record.Get("labels"), &task.Labels)
|
|
unmarshalJSONField(record.Get("input"), &task.Input)
|
|
unmarshalJSONField(record.Get("output"), &task.Output)
|
|
unmarshalJSONField(record.Get("metadata"), &task.Metadata)
|
|
|
|
if dt := record.GetDateTime("startedAt"); !dt.IsZero() {
|
|
t := dt.Time()
|
|
task.StartedAt = &t
|
|
}
|
|
if dt := record.GetDateTime("completedAt"); !dt.IsZero() {
|
|
t := dt.Time()
|
|
task.CompletedAt = &t
|
|
}
|
|
|
|
return task
|
|
}
|
|
|
|
func (s *Store) recordToWorkflow(record *core.Record) *Workflow {
|
|
wf := &Workflow{
|
|
ID: record.Id,
|
|
OrgID: record.GetString("orgId"),
|
|
SpaceID: record.GetString("spaceId"),
|
|
Name: record.GetString("name"),
|
|
Description: record.GetString("description"),
|
|
State: TaskState(record.GetString("state")),
|
|
CreatedBy: record.GetString("createdBy"),
|
|
CreatedAt: record.GetDateTime("created").Time(),
|
|
UpdatedAt: record.GetDateTime("updated").Time(),
|
|
}
|
|
|
|
unmarshalJSONField(record.Get("tasks"), &wf.Tasks)
|
|
unmarshalJSONField(record.Get("metadata"), &wf.Metadata)
|
|
|
|
if dt := record.GetDateTime("completedAt"); !dt.IsZero() {
|
|
t := dt.Time()
|
|
wf.CompletedAt = &t
|
|
}
|
|
|
|
return wf
|
|
}
|