Move mem/pg activate/live/meta/plan/token/seed paths into ≤800-line siblings without behavior change; short tests green.
234 lines
6.9 KiB
Go
234 lines
6.9 KiB
Go
package projectinstance
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"chat-agent-system/internal/workflowdef"
|
|
)
|
|
|
|
func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
|
|
return s.activateInstanceTx(ctx, s.pool, "", projectID, inst, version, expectedVersion, activatedBy, "")
|
|
}
|
|
|
|
func (s *pgStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
|
|
return s.activateInstanceTx(ctx, s.pool, token, projectID, inst, version, expectedVersion, activatedBy, planHash)
|
|
}
|
|
|
|
func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
|
|
workflowKey := ""
|
|
if version != nil {
|
|
workflowKey = version.WorkflowKey
|
|
}
|
|
tx, err := db.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot)
|
|
var currentPolicyRev int64
|
|
if usesAuthorOnly {
|
|
snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, tx)
|
|
if !ok {
|
|
return nil, ErrAuthorOnlyPolicyUnavailable
|
|
}
|
|
if lockErr != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr)
|
|
}
|
|
if !snap.Allowed {
|
|
return nil, ErrAuthorOnlyPolicyDisabled
|
|
}
|
|
currentPolicyRev = snap.Revision
|
|
}
|
|
|
|
if err := runActivateMemberRecheck(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if token != "" {
|
|
tokenPolicyRev, err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if usesAuthorOnly {
|
|
if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev {
|
|
return nil, ErrAuthorOnlyPolicyChanged
|
|
}
|
|
}
|
|
}
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2,
|
|
workflow_selection_version = workflow_selection_version + 1,
|
|
active_workflow_changed_at = now(), active_workflow_changed_by = $4,
|
|
updated_at = now()
|
|
WHERE id = $1 AND workflow_selection_version = $3
|
|
`, projectID, workflowKey, expectedVersion, activatedBy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, ErrConcurrentConflict
|
|
}
|
|
|
|
now := time.Now()
|
|
row := tx.QueryRow(ctx, `
|
|
UPDATE project_workflow_instances SET status = 'active', activated_by = $2, activated_at = $3, updated_at = $3
|
|
WHERE id = $1 AND status = 'planning'
|
|
RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
|
|
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
|
|
`, inst.ID, activatedBy, now)
|
|
activated, err := scanInstance(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE project_stage_instances SET status = CASE WHEN enabled THEN 'active' ELSE 'skipped' END, updated_at = now()
|
|
WHERE workflow_instance_id = $1 AND stage_key = (
|
|
SELECT stage_key FROM project_stage_instances
|
|
WHERE workflow_instance_id = $1 AND enabled = true
|
|
ORDER BY id ASC LIMIT 1
|
|
)
|
|
`, inst.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE project_stage_instances SET status = 'pending', updated_at = now()
|
|
WHERE workflow_instance_id = $1 AND status <> 'active' AND enabled = true
|
|
`, inst.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if version != nil {
|
|
if err := s.applyRuntimeMetadata(ctx, tx, inst.ID, version.Definition); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return activated, nil
|
|
}
|
|
|
|
type pgxDB interface {
|
|
Begin(ctx context.Context) (pgx.Tx, error)
|
|
}
|
|
|
|
func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) (*int64, error) {
|
|
tokenHash := hashActivationToken(token)
|
|
var storedHash string
|
|
var usedAt *time.Time
|
|
var expiresAt time.Time
|
|
var policyRev *int64
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT plan_hash, used_at, expires_at, author_only_policy_revision
|
|
FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2
|
|
`, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt, &policyRev)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrTokenInvalid
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash {
|
|
return nil, ErrTokenInvalid
|
|
}
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL
|
|
`, tokenHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, ErrTokenInvalid
|
|
}
|
|
return policyRev, nil
|
|
}
|
|
|
|
func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) {
|
|
if projectID <= 0 {
|
|
return 0, nil
|
|
}
|
|
var count int
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM task_runs
|
|
WHERE project_id = $1 AND status IN ('pending', 'running')
|
|
`, projectID).Scan(&count)
|
|
if err != nil {
|
|
return 0, nil
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (s *pgStore) applyRuntimeMetadata(ctx context.Context, tx pgx.Tx, instanceID int64, def workflowdef.VersionDefinition) error {
|
|
taskDefs := map[string]workflowdef.DocTaskDef{}
|
|
for _, st := range def.Stages {
|
|
for _, dt := range st.DocTasks {
|
|
taskDefs[dt.Key] = dt
|
|
}
|
|
}
|
|
// 先完整读取并关闭结果集,再执行 UPDATE:
|
|
// pgx 在同一事务连接上存在未读完的 rows 时执行 Exec 会报 "conn busy"。
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT dti.id, dti.task_key
|
|
FROM project_doc_task_instances dti
|
|
JOIN project_stage_instances si ON si.id = dti.stage_instance_id
|
|
WHERE si.workflow_instance_id = $1
|
|
`, instanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type taskInstanceRow struct {
|
|
id int64
|
|
taskKey string
|
|
}
|
|
taskRows := make([]taskInstanceRow, 0)
|
|
for rows.Next() {
|
|
var r taskInstanceRow
|
|
if err := rows.Scan(&r.id, &r.taskKey); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
taskRows = append(taskRows, r)
|
|
}
|
|
iterErr := rows.Err()
|
|
rows.Close()
|
|
if iterErr != nil {
|
|
return iterErr
|
|
}
|
|
for _, r := range taskRows {
|
|
td, ok := taskDefs[r.taskKey]
|
|
if !ok {
|
|
continue
|
|
}
|
|
tplKey := td.TemplateAssetKey
|
|
tplVer := td.TemplateVersion
|
|
if td.Template != nil {
|
|
if td.Template.AssetKey != "" {
|
|
tplKey = td.Template.AssetKey
|
|
}
|
|
if td.Template.Version > 0 {
|
|
tplVer = td.Template.Version
|
|
}
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE project_doc_task_instances SET
|
|
executor_key = $2, quality_gate_key = $3,
|
|
template_asset_key = $4, template_version = $5,
|
|
output_doc_type = $6, output_doc_title = $7, updated_at = now()
|
|
WHERE id = $1
|
|
`, r.id, td.ExecutorKey, td.QualityGateKey, tplKey, tplVer, td.OutputDocType, td.OutputDocTitle); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|