feat(工作流):支持本地图编排策略包导入导出 (#195)

* docs: define local workflow package mvp

* docs: fix workflow package api contract

* feat: add local workflow package mvp

* feat(workflow): add package API client

* feat(workflow): add package import interface

* feat(workflow): connect package import flow

* fix(workflow): map package validation errors

* fix(workflow): handle import key generation errors

* feat(workflow): localize package import states

* fix(workflow): reset package import modal on open

---------

Co-authored-by: ruanmingchen <“ruanm@chenchen”>
This commit is contained in:
chenchen
2026-07-14 11:28:37 +08:00
committed by GitHub
parent 2e5c1ff286
commit acfacfe1b3
24 changed files with 3154 additions and 3 deletions
+17
View File
@@ -120,6 +120,18 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
audit.RegisterConversationCreateHook(auditSvc)
auditSvc.PurgeExpired()
audit.StartRetentionLoop(auditSvc, log.Logger)
if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil {
log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err))
}
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil {
log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err))
}
}
}()
monitorRetention := monitor.NewService(db, cfg, log.Logger)
monitorRetention.PurgeExpired()
@@ -1314,6 +1326,11 @@ func setupRoutes(
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
protected.POST("/workflows/validate", workflowHandler.Validate)
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
protected.POST("/workflow-package-imports", workflowHandler.ApplyPackageImport)
protected.GET("/workflow-package-imports/:importId", workflowHandler.GetPackageImport)
protected.GET("/workflows", workflowHandler.List)
protected.GET("/workflows/:id", workflowHandler.Get)
protected.POST("/workflows", workflowHandler.Create)
+30 -3
View File
@@ -667,6 +667,28 @@ func (db *DB) initTables() error {
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);`
createWorkflowPackageInspectionsTable := `
CREATE TABLE IF NOT EXISTS workflow_package_inspections (
id TEXT PRIMARY KEY, package_hash TEXT NOT NULL, manifest_json TEXT NOT NULL,
workflow_payload_json TEXT NOT NULL, inspection_json TEXT NOT NULL,
source_workflow_id TEXT NOT NULL, source_revision INTEGER NOT NULL,
source_content_hash TEXT NOT NULL, source_graph_hash TEXT NOT NULL,
local_conflict_state TEXT NOT NULL CHECK (local_conflict_state IN ('none','identical','id_conflict')),
local_workflow_id TEXT, local_content_hash TEXT, local_graph_hash TEXT,
created_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('ready','consumed','expired')),
created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, consumed_at DATETIME
);`
createWorkflowPackageImportsTable := `
CREATE TABLE IF NOT EXISTS workflow_package_imports (
id TEXT PRIMARY KEY, inspection_id TEXT NOT NULL, request_hash TEXT NOT NULL,
idempotency_key TEXT NOT NULL, actor_user_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create','keep_existing','overwrite','rename')),
source_workflow_id TEXT NOT NULL, target_workflow_id TEXT NOT NULL, resulting_workflow_id TEXT,
result TEXT NOT NULL CHECK (result IN ('created','overwritten','renamed','kept_existing','skipped_identical','failed')),
error_code TEXT, error_message TEXT, created_at DATETIME NOT NULL, applied_at DATETIME,
FOREIGN KEY (inspection_id) REFERENCES workflow_package_inspections(id)
);`
// 创建索引
createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -731,6 +753,9 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON workflow_runs(conversation_id);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run ON workflow_node_runs(run_id);
CREATE INDEX IF NOT EXISTS idx_workflow_package_inspections_creator_expiry ON workflow_package_inspections(created_by, expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_actor_key ON workflow_package_imports(actor_user_id, idempotency_key);
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_inspection_success ON workflow_package_imports(inspection_id) WHERE result IN ('created','overwritten','renamed','kept_existing','skipped_identical');
`
if _, err := db.Exec(createConversationsTable); err != nil {
@@ -830,9 +855,11 @@ func (db *DB) initTables() error {
}
for tableName, ddl := range map[string]string{
"workflow_definitions": createWorkflowDefinitionsTable,
"workflow_runs": createWorkflowRunsTable,
"workflow_node_runs": createWorkflowNodeRunsTable,
"workflow_definitions": createWorkflowDefinitionsTable,
"workflow_runs": createWorkflowRunsTable,
"workflow_node_runs": createWorkflowNodeRunsTable,
"workflow_package_inspections": createWorkflowPackageInspectionsTable,
"workflow_package_imports": createWorkflowPackageImportsTable,
} {
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("创建%s表失败: %w", tableName, err)
+286
View File
@@ -0,0 +1,286 @@
package database
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"unicode"
"github.com/google/uuid"
)
type WorkflowPackageInspection struct {
ID, PackageHash, ManifestJSON, WorkflowPayloadJSON, InspectionJSON string
SourceWorkflowID, SourceContentHash, SourceGraphHash string
SourceRevision int
LocalConflictState, LocalWorkflowID, LocalContentHash, LocalGraphHash string
CreatedBy, Status string
CreatedAt, ExpiresAt time.Time
ConsumedAt *time.Time
}
type WorkflowPackageImport struct {
ID, InspectionID, RequestHash, IdempotencyKey, ActorUserID string
Action, SourceWorkflowID, TargetWorkflowID, ResultingWorkflowID string
Result, ErrorCode, ErrorMessage string
CreatedAt time.Time
AppliedAt *time.Time
}
type WorkflowPackageApplyRequest struct {
InspectionID, RequestHash, IdempotencyKey, ActorUserID, Action, NewWorkflowID string
ConfirmOverwrite bool
}
type WorkflowPackageStoreError struct{ Code, Message string }
func (e *WorkflowPackageStoreError) Error() string { return e.Code + ": " + e.Message }
func workflowPackageStoreError(code, message string) error {
return &WorkflowPackageStoreError{code, message}
}
func (db *DB) CreateWorkflowPackageInspection(v *WorkflowPackageInspection) error {
if v == nil || strings.TrimSpace(v.ID) == "" || strings.TrimSpace(v.CreatedBy) == "" {
return fmt.Errorf("workflow package inspection is incomplete")
}
if v.CreatedAt.IsZero() {
v.CreatedAt = time.Now().UTC()
}
if v.ExpiresAt.IsZero() {
v.ExpiresAt = v.CreatedAt.Add(30 * time.Minute)
}
_, err := db.Exec(`INSERT INTO workflow_package_inspections (id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,local_workflow_id,local_content_hash,local_graph_hash,created_by,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, v.ID, v.PackageHash, v.ManifestJSON, v.WorkflowPayloadJSON, v.InspectionJSON, v.SourceWorkflowID, v.SourceRevision, v.SourceContentHash, v.SourceGraphHash, v.LocalConflictState, nullString(v.LocalWorkflowID), nullString(v.LocalContentHash), nullString(v.LocalGraphHash), v.CreatedBy, "ready", v.CreatedAt.UTC(), v.ExpiresAt.UTC())
return err
}
func (db *DB) GetWorkflowPackageInspection(id, actor string) (*WorkflowPackageInspection, error) {
now := time.Now().UTC()
_, _ = db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at <= ?`, now)
row, err := scanWorkflowPackageInspection(db.QueryRow(`SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
if err == sql.ErrNoRows {
return nil, nil
}
return row, err
}
func scanWorkflowPackageInspection(s interface{ Scan(...any) error }) (*WorkflowPackageInspection, error) {
var v WorkflowPackageInspection
var consumed sql.NullTime
err := s.Scan(&v.ID, &v.PackageHash, &v.ManifestJSON, &v.WorkflowPayloadJSON, &v.InspectionJSON, &v.SourceWorkflowID, &v.SourceRevision, &v.SourceContentHash, &v.SourceGraphHash, &v.LocalConflictState, &v.LocalWorkflowID, &v.LocalContentHash, &v.LocalGraphHash, &v.CreatedBy, &v.Status, &v.CreatedAt, &v.ExpiresAt, &consumed)
if consumed.Valid {
t := consumed.Time
v.ConsumedAt = &t
}
return &v, err
}
func (db *DB) GetWorkflowPackageImport(id, actor string) (*WorkflowPackageImport, error) {
v, err := scanWorkflowPackageImport(db.QueryRow(`SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE id=? AND actor_user_id=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
if err == sql.ErrNoRows {
return nil, nil
}
return v, err
}
func scanWorkflowPackageImport(s interface{ Scan(...any) error }) (*WorkflowPackageImport, error) {
var v WorkflowPackageImport
var applied sql.NullTime
err := s.Scan(&v.ID, &v.InspectionID, &v.RequestHash, &v.IdempotencyKey, &v.ActorUserID, &v.Action, &v.SourceWorkflowID, &v.TargetWorkflowID, &v.ResultingWorkflowID, &v.Result, &v.ErrorCode, &v.ErrorMessage, &v.CreatedAt, &applied)
if applied.Valid {
t := applied.Time
v.AppliedAt = &t
}
return &v, err
}
func (db *DB) ApplyWorkflowPackageImport(ctx context.Context, req WorkflowPackageApplyRequest) (*WorkflowPackageImport, bool, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, false, err
}
defer tx.Rollback()
var existingHash string
previous, prevErr := scanWorkflowPackageImport(tx.QueryRowContext(ctx, `SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE actor_user_id=? AND idempotency_key=?`, req.ActorUserID, req.IdempotencyKey))
if prevErr == nil {
existingHash = previous.RequestHash
if existingHash != req.RequestHash {
return nil, false, workflowPackageStoreError("WFPKG_IDEMPOTENCY_KEY_REUSED", "幂等键已用于其他请求")
}
return previous, true, nil
}
if prevErr != sql.ErrNoRows {
return nil, false, prevErr
}
inspection, err := scanWorkflowPackageInspection(tx.QueryRowContext(ctx, `SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, req.InspectionID, req.ActorUserID))
if err == sql.ErrNoRows {
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_NOT_FOUND", "预检不存在")
}
if err != nil {
return nil, false, err
}
now := time.Now().UTC()
if !inspection.ExpiresAt.After(now) || inspection.Status == "expired" {
_, _ = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='expired' WHERE id=?`, inspection.ID)
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_EXPIRED", "预检已过期")
}
if inspection.Status != "ready" {
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_CONSUMED", "预检已被使用")
}
var payload struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
GraphJSON string `json:"graph_json"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal([]byte(inspection.WorkflowPayloadJSON), &payload); err != nil {
return nil, false, fmt.Errorf("decode inspection payload: %w", err)
}
targetID := inspection.SourceWorkflowID
if req.Action == "rename" {
targetID = strings.TrimSpace(req.NewWorkflowID)
if !validWorkflowPackageID(targetID) {
return nil, false, workflowPackageStoreError("WFPKG_INVALID_RENAME_ID", "新工作流 ID 无效")
}
}
sourceCurrent, err := scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", inspection.SourceWorkflowID))
if err == sql.ErrNoRows {
sourceCurrent = nil
} else if err != nil {
return nil, false, err
}
if err := checkWorkflowPackageSnapshot(inspection, sourceCurrent, inspection.SourceWorkflowID); err != nil {
return nil, false, err
}
current := sourceCurrent
if targetID != inspection.SourceWorkflowID {
current, err = scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", targetID))
if err == sql.ErrNoRows {
current = nil
} else if err != nil {
return nil, false, err
}
}
result := ""
resultingID := ""
switch req.Action {
case "create":
if inspection.LocalConflictState != "none" || current != nil {
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "目标工作流已存在")
}
result = "created"
resultingID = targetID
case "keep_existing":
if inspection.LocalConflictState == "none" {
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许保留本地")
}
if inspection.LocalConflictState == "identical" {
result = "skipped_identical"
} else {
result = "kept_existing"
}
resultingID = targetID
case "overwrite":
if inspection.LocalConflictState != "id_conflict" {
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许覆盖")
}
if !req.ConfirmOverwrite {
return nil, false, workflowPackageStoreError("WFPKG_OVERWRITE_CONFIRMATION_REQUIRED", "覆盖需要确认")
}
result = "overwritten"
resultingID = targetID
case "rename":
if inspection.LocalConflictState != "id_conflict" || current != nil {
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许另存")
}
result = "renamed"
resultingID = targetID
default:
return nil, false, workflowPackageStoreError("WFPKG_INVALID_ACTION", "导入动作无效")
}
if result == "created" || result == "renamed" {
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id,name,description,version,graph_json,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`, resultingID, payload.Name, payload.Description, 1, payload.GraphJSON, boolToInt(payload.Enabled), now, now)
} else if result == "overwritten" {
_, err = tx.ExecContext(ctx, `UPDATE workflow_definitions SET name=?,description=?,version=version+1,graph_json=?,enabled=?,updated_at=? WHERE id=?`, payload.Name, payload.Description, payload.GraphJSON, boolToInt(payload.Enabled), now, resultingID)
}
if err != nil {
return nil, false, err
}
imp := &WorkflowPackageImport{ID: "wpii_" + strings.ReplaceAll(uuid.NewString(), "-", ""), InspectionID: inspection.ID, RequestHash: req.RequestHash, IdempotencyKey: req.IdempotencyKey, ActorUserID: req.ActorUserID, Action: req.Action, SourceWorkflowID: inspection.SourceWorkflowID, TargetWorkflowID: targetID, ResultingWorkflowID: resultingID, Result: result, CreatedAt: now, AppliedAt: &now}
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_package_imports (id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,resulting_workflow_id,result,created_at,applied_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, imp.ID, imp.InspectionID, imp.RequestHash, imp.IdempotencyKey, imp.ActorUserID, imp.Action, imp.SourceWorkflowID, imp.TargetWorkflowID, nullString(imp.ResultingWorkflowID), imp.Result, now, now)
if err != nil {
return nil, false, err
}
if _, err = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='consumed',consumed_at=? WHERE id=? AND status='ready'`, now, inspection.ID); err != nil {
return nil, false, err
}
if err = tx.Commit(); err != nil {
return nil, false, err
}
return imp, false, nil
}
func checkWorkflowPackageSnapshot(i *WorkflowPackageInspection, current *WorkflowDefinition, targetID string) error {
if i.LocalConflictState == "none" {
if current != nil {
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
}
return nil
}
if current == nil || current.ID != i.LocalWorkflowID || current.ID != targetID {
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
}
content, graph := workflowDefinitionPackageHashes(current)
if content != i.LocalContentHash || graph != i.LocalGraphHash {
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
}
return nil
}
func workflowDefinitionPackageHashes(w *WorkflowDefinition) (string, string) {
var g any
dec := json.NewDecoder(strings.NewReader(w.GraphJSON))
dec.UseNumber()
_ = dec.Decode(&g)
graph, _ := json.Marshal(g)
payload := struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int `json:"version"`
GraphJSON string `json:"graph_json"`
Enabled bool `json:"enabled"`
}{w.ID, w.Name, w.Description, w.Version, string(graph), w.Enabled}
b, _ := json.Marshal(payload)
return workflowPackageHash(b), workflowPackageHash(graph)
}
func workflowPackageHash(b []byte) string {
s := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(s[:])
}
func validWorkflowPackageID(id string) bool {
if len(id) < 1 || len(id) > 128 {
return false
}
for _, r := range id {
if unicode.IsControl(r) {
return false
}
}
return true
}
func (db *DB) PurgeWorkflowPackageLifecycle(now time.Time) error {
now = now.UTC()
if _, err := db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at<=?`, now); err != nil {
return err
}
if _, err := db.Exec(`DELETE FROM workflow_package_inspections WHERE status='expired' AND expires_at<? AND NOT EXISTS (SELECT 1 FROM workflow_package_imports i WHERE i.inspection_id=workflow_package_inspections.id)`, now.Add(-24*time.Hour)); err != nil {
return err
}
_, err := db.Exec(`DELETE FROM workflow_package_imports WHERE created_at<?`, now.AddDate(0, 0, -90))
return err
}
@@ -0,0 +1,74 @@
package database
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestWorkflowPackageApplyOverwriteIsTransactionalAndIdempotent(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
current := &WorkflowDefinition{ID: "wf-1", Name: "Local", Version: 12, GraphJSON: `{"nodes":[]}`, Enabled: true}
if err := db.UpsertWorkflowDefinition(current); err != nil {
t.Fatal(err)
}
current, _ = db.GetWorkflowDefinition("wf-1")
content, graph := workflowDefinitionPackageHashes(current)
payload, _ := json.Marshal(map[string]any{"id": "wf-1", "name": "Imported", "description": "new", "version": 18, "graph_json": `{"nodes":[]}`, "enabled": false})
now := time.Now().UTC()
inspection := &WorkflowPackageInspection{ID: "wpi_test", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-1", SourceRevision: 18, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-1", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
t.Fatal(err)
}
req := WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-1", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true}
imp, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
if err != nil || replayed || imp.Result != "overwritten" {
t.Fatalf("apply = %#v replay=%v err=%v", imp, replayed, err)
}
updated, _ := db.GetWorkflowDefinition("wf-1")
if updated.Version != 13 || updated.Name != "Imported" || updated.Enabled {
t.Fatalf("updated workflow = %#v", updated)
}
replay, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
if err != nil || !replayed || replay.ID != imp.ID {
t.Fatalf("replay = %#v replay=%v err=%v", replay, replayed, err)
}
gotInspection, err := db.GetWorkflowPackageInspection(inspection.ID, "user-1")
if err != nil || gotInspection.Status != "consumed" {
t.Fatalf("inspection=%#v err=%v", gotInspection, err)
}
}
func TestWorkflowPackageApplyRejectsChangedConflictSnapshot(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package-conflict.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Local", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
t.Fatal(err)
}
local, _ := db.GetWorkflowDefinition("wf-2")
content, graph := workflowDefinitionPackageHashes(local)
payload, _ := json.Marshal(map[string]any{"id": "wf-2", "name": "Imported", "version": 2, "graph_json": `{"nodes":[]}`, "enabled": true})
now := time.Now().UTC()
inspection := &WorkflowPackageInspection{ID: "wpi_changed", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-2", SourceRevision: 2, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-2", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
t.Fatal(err)
}
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Changed", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
t.Fatal(err)
}
_, _, err = db.ApplyWorkflowPackageImport(context.Background(), WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-2", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true})
if e, ok := err.(*WorkflowPackageStoreError); !ok || e.Code != "WFPKG_CONFLICT_CHANGED" {
t.Fatalf("err=%v", err)
}
}
+319
View File
@@ -0,0 +1,319 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
workflowrunner "cyberstrike-ai/internal/workflow"
workflowpkg "cyberstrike-ai/internal/workflow/package"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type workflowPackageResolution struct {
Action string `json:"action"`
NewWorkflowID string `json:"new_workflow_id"`
}
type workflowPackageImportRequest struct {
InspectionID string `json:"inspection_id"`
Resolution workflowPackageResolution `json:"resolution"`
ConfirmOverwrite bool `json:"confirm_overwrite"`
}
func (h *WorkflowHandler) ExportPackage(c *gin.Context) {
wf, err := h.db.GetWorkflowDefinition(c.Param("id"))
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
return
}
if wf == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_WORKFLOW_NOT_FOUND", "工作流不存在", nil)
return
}
pkg, meta, err := workflowpkg.Export(workflowPackageDocument(wf))
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": meta.FileName}))
c.Header("ETag", `"`+meta.PackageHash+`"`)
c.Header("X-Workflow-Package-SHA256", meta.PackageHash)
if h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "export", "导出工作流包", "workflow", wf.ID, map[string]interface{}{"package_hash": meta.PackageHash})
}
c.Data(http.StatusOK, "application/zip", pkg)
}
func (h *WorkflowHandler) CreatePackageInspection(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok || strings.TrimSpace(session.UserID) == "" {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, workflowpkg.MaxArchiveBytes+1)
file, _, err := c.Request.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
return
}
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_REQUIRED", "必须上传工作流包文件", nil)
return
}
defer file.Close()
archive, err := io.ReadAll(io.LimitReader(file, workflowpkg.MaxArchiveBytes+1))
if err != nil || len(archive) > workflowpkg.MaxArchiveBytes {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
return
}
inspected, err := workflowpkg.InspectArchive(c.Request.Context(), archive, workflowrunner.ValidateGraphJSON)
if err != nil {
code := workflowpkg.ErrorCode(err)
if code == "" {
code = "WFPKG_INVALID_ARCHIVE"
}
if h.audit != nil {
h.audit.RecordFail(c, "workflow_package", "inspect", "工作流包预检失败", map[string]interface{}{"code": code, "package_hash": workflowPackageHash(archive)})
}
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, code, "工作流包预检失败", nil)
return
}
state, local, err := h.workflowPackageConflict(inspected.Document.ID, inspected.ContentHash)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取本地工作流失败", nil)
return
}
summary := workflowPackageInspectionSummary{ID: "wpi_" + strings.ReplaceAll(uuid.NewString(), "-", ""), Status: "ready", ExpiresAt: time.Now().UTC().Add(30 * time.Minute), Package: workflowPackagePackageSummary{PackageFormat: inspected.Manifest.PackageFormat, FormatVersion: inspected.Manifest.FormatVersion, PackageID: inspected.Manifest.PackageID, PackageHash: inspected.PackageHash}, Workflow: workflowPackageWorkflowSummary{SourceID: inspected.Document.ID, Name: inspected.Document.Name, Description: inspected.Document.Description, SourceRevision: inspected.Document.Version, Enabled: inspected.Document.Enabled, ContentHash: inspected.ContentHash, GraphHash: inspected.GraphHash, NodeCount: inspected.NodeCount, EdgeCount: inspected.EdgeCount}, Conflict: workflowPackageConflictSummary{State: state}, Warnings: []string{}}
if local != nil {
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local))
summary.Conflict.LocalWorkflow = &workflowPackageLocalWorkflow{ID: local.ID, Version: local.Version, ContentHash: content, GraphHash: graph}
}
manifestJSON, _ := json.Marshal(inspected.Manifest)
payloadJSON, err := json.Marshal(inspected.Document)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil)
return
}
inspectionJSON, _ := json.Marshal(summary)
record := &database.WorkflowPackageInspection{ID: summary.ID, PackageHash: inspected.PackageHash, ManifestJSON: string(manifestJSON), WorkflowPayloadJSON: string(payloadJSON), InspectionJSON: string(inspectionJSON), SourceWorkflowID: inspected.Document.ID, SourceRevision: inspected.Document.Version, SourceContentHash: inspected.ContentHash, SourceGraphHash: inspected.GraphHash, LocalConflictState: state, CreatedBy: session.UserID, CreatedAt: time.Now().UTC(), ExpiresAt: summary.ExpiresAt}
if local != nil {
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local))
record.LocalWorkflowID = local.ID
record.LocalContentHash = content
record.LocalGraphHash = graph
}
if err := h.db.CreateWorkflowPackageInspection(record); err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil)
return
}
if h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "inspect", "工作流包预检成功", "inspection", record.ID, map[string]interface{}{"package_hash": record.PackageHash, "workflow_id": record.SourceWorkflowID})
}
c.JSON(http.StatusCreated, gin.H{"inspection": summary})
}
func (h *WorkflowHandler) GetPackageInspection(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
v, err := h.db.GetWorkflowPackageInspection(c.Param("inspectionId"), session.UserID)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取预检失败", nil)
return
}
if v == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "预检不存在", nil)
return
}
if v.Status == "expired" {
writeWorkflowPackageError(c, http.StatusConflict, "WFPKG_INSPECTION_EXPIRED", "预检已过期", nil)
return
}
var summary any
_ = json.Unmarshal([]byte(v.InspectionJSON), &summary)
c.JSON(http.StatusOK, gin.H{"inspection": summary})
}
func (h *WorkflowHandler) ApplyPackageImport(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
if _, err := uuid.Parse(key); err != nil {
writeWorkflowPackageError(c, http.StatusBadRequest, "WFPKG_IDEMPOTENCY_KEY_REQUIRED", "必须提供 UUID 幂等键", nil)
return
}
var req workflowPackageImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "导入请求无效", nil)
return
}
req.InspectionID = strings.TrimSpace(req.InspectionID)
req.Resolution.Action = strings.TrimSpace(req.Resolution.Action)
req.Resolution.NewWorkflowID = strings.TrimSpace(req.Resolution.NewWorkflowID)
if req.Resolution.Action != "rename" && req.Resolution.NewWorkflowID != "" {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "当前导入动作不接受新工作流 ID", nil)
return
}
requestHash := workflowPackageRequestHash(req)
imp, replayed, err := h.db.ApplyWorkflowPackageImport(c.Request.Context(), database.WorkflowPackageApplyRequest{InspectionID: req.InspectionID, RequestHash: requestHash, IdempotencyKey: key, ActorUserID: session.UserID, Action: req.Resolution.Action, NewWorkflowID: req.Resolution.NewWorkflowID, ConfirmOverwrite: req.ConfirmOverwrite})
if err != nil {
h.writeWorkflowPackageImportError(c, req.InspectionID, err)
return
}
wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID)
if !replayed && (imp.Result == "created" || imp.Result == "overwritten" || imp.Result == "renamed") {
workflowrunner.InvalidateCompiledCache(imp.ResultingWorkflowID)
}
response := h.workflowPackageImportResponse(imp, wf)
if !replayed && h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "import", "工作流包导入成功", "workflow", imp.ResultingWorkflowID, map[string]interface{}{"inspection_id": imp.InspectionID, "action": imp.Action, "result": imp.Result})
}
status := http.StatusCreated
if replayed {
status = http.StatusOK
}
c.JSON(status, gin.H{"import": response})
}
func (h *WorkflowHandler) GetPackageImport(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
imp, err := h.db.GetWorkflowPackageImport(c.Param("importId"), session.UserID)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取导入结果失败", nil)
return
}
if imp == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "导入结果不存在", nil)
return
}
wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID)
c.JSON(http.StatusOK, gin.H{"import": h.workflowPackageImportResponse(imp, wf)})
}
func (h *WorkflowHandler) workflowPackageConflict(id, sourceHash string) (string, *database.WorkflowDefinition, error) {
local, err := h.db.GetWorkflowDefinition(id)
if err != nil {
return "", nil, err
}
if local == nil {
return "none", nil, nil
}
localHash, _, _, err := workflowpkg.DocumentHashes(workflowPackageDocument(local))
if err != nil {
return "", nil, err
}
if localHash == sourceHash {
return "identical", local, nil
}
return "id_conflict", local, nil
}
func workflowPackageDocument(w *database.WorkflowDefinition) workflowpkg.Document {
return workflowpkg.Document{ID: w.ID, Name: w.Name, Description: w.Description, Version: w.Version, GraphJSON: w.GraphJSON, Enabled: w.Enabled, UpdatedAt: w.UpdatedAt}
}
func workflowPackageRequestHash(req workflowPackageImportRequest) string {
value := struct {
ConfirmOverwrite bool `json:"confirm_overwrite"`
InspectionID string `json:"inspection_id"`
Resolution workflowPackageResolution `json:"resolution"`
}{req.ConfirmOverwrite, req.InspectionID, req.Resolution}
b, _ := json.Marshal(value)
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func workflowPackageHash(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func (h *WorkflowHandler) writeWorkflowPackageImportError(c *gin.Context, inspectionID string, err error) {
var e *database.WorkflowPackageStoreError
if errors.As(err, &e) {
status := http.StatusConflict
if e.Code == "WFPKG_INVALID_ACTION" || e.Code == "WFPKG_INVALID_RENAME_ID" {
status = http.StatusUnprocessableEntity
}
if h.audit != nil {
h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": e.Code, "inspection_id": inspectionID})
}
writeWorkflowPackageError(c, status, e.Code, e.Message, nil)
return
}
if h.audit != nil {
h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": "WFPKG_IMPORT_FAILED", "inspection_id": inspectionID})
}
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "导入工作流包失败", nil)
}
func writeWorkflowPackageError(c *gin.Context, status int, code, message string, details map[string]any) {
body := gin.H{"code": code, "message": message}
if len(details) > 0 {
body["details"] = details
}
c.JSON(status, gin.H{"error": body})
}
type workflowPackageInspectionSummary struct {
ID string `json:"id"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
Package workflowPackagePackageSummary `json:"package"`
Workflow workflowPackageWorkflowSummary `json:"workflow"`
Conflict workflowPackageConflictSummary `json:"conflict"`
Warnings []string `json:"warnings"`
}
type workflowPackagePackageSummary struct {
PackageFormat string `json:"package_format"`
FormatVersion string `json:"format_version"`
PackageID string `json:"package_id"`
PackageHash string `json:"package_hash"`
}
type workflowPackageWorkflowSummary struct {
SourceID string `json:"source_id"`
Name string `json:"name"`
Description string `json:"description"`
SourceRevision int `json:"source_revision"`
Enabled bool `json:"enabled"`
ContentHash string `json:"content_hash"`
GraphHash string `json:"graph_hash"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
}
type workflowPackageConflictSummary struct {
State string `json:"state"`
LocalWorkflow *workflowPackageLocalWorkflow `json:"local_workflow,omitempty"`
}
type workflowPackageLocalWorkflow struct {
ID string `json:"id"`
Version int `json:"version"`
ContentHash string `json:"content_hash"`
GraphHash string `json:"graph_hash"`
}
func (h *WorkflowHandler) workflowPackageImportResponse(imp *database.WorkflowPackageImport, wf *database.WorkflowDefinition) gin.H {
out := gin.H{"id": imp.ID, "inspection_id": imp.InspectionID, "status": "succeeded", "result": imp.Result, "action": imp.Action, "source_workflow_id": imp.SourceWorkflowID, "target_workflow_id": imp.TargetWorkflowID, "applied_at": imp.AppliedAt}
if wf != nil {
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(wf))
out["workflow"] = gin.H{"id": wf.ID, "version": wf.Version, "content_hash": content, "graph_hash": graph}
}
return out
}
+78
View File
@@ -0,0 +1,78 @@
package handler
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
workflowpkg "cyberstrike-ai/internal/workflow/package"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
h := NewWorkflowHandler(db, zap.NewNop())
pkg, _, err := workflowpkg.Export(workflowpkg.Document{ID: "wf-api", Name: "API workflow", Version: 4, Enabled: true, UpdatedAt: time.Now().UTC(), GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`})
if err != nil {
t.Fatal(err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "wf-api.csapkg.zip")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(pkg); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflow-package-inspections", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
c.Set(security.ContextSessionKey, security.Session{UserID: "user-1"})
h.CreatePackageInspection(c)
if w.Code != http.StatusCreated {
t.Fatalf("inspection status=%d body=%s", w.Code, w.Body.String())
}
var inspected struct {
Inspection struct {
ID string `json:"id"`
} `json:"inspection"`
}
if err := json.Unmarshal(w.Body.Bytes(), &inspected); err != nil {
t.Fatal(err)
}
applyBody := bytes.NewBufferString(`{"inspection_id":"` + inspected.Inspection.ID + `","resolution":{"action":"create","new_workflow_id":""},"confirm_overwrite":false}`)
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflow-package-imports", applyBody)
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Idempotency-Key", uuid.NewString())
c.Set(security.ContextSessionKey, security.Session{UserID: "user-1"})
h.ApplyPackageImport(c)
if w.Code != http.StatusCreated {
t.Fatalf("import status=%d body=%s", w.Code, w.Body.String())
}
saved, _ := db.GetWorkflowDefinition("wf-api")
if saved == nil || saved.Version != 1 {
t.Fatalf("saved=%#v", saved)
}
}
+7
View File
@@ -164,6 +164,10 @@ func permissionForRequest(method, fullPath string) string {
return crudPermission(method, "files")
case strings.HasPrefix(path, "/roles"):
return crudPermission(method, "roles")
case path == "/workflows/:id/package":
return "workflow:read"
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
return "workflow:write"
case strings.HasPrefix(path, "/workflows"):
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
return "workflow:execute"
@@ -257,6 +261,9 @@ func isProcessGlobalMutationPath(path string) bool {
// Workflow runs inherit conversation access; definitions are global.
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run"
}
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
return true
}
if strings.HasPrefix(path, "/knowledge") {
return path != "/knowledge/search"
}
@@ -0,0 +1,20 @@
package security
import (
"net/http"
"testing"
)
func TestWorkflowPackageRoutesHaveExplicitWorkflowPermissions(t *testing.T) {
if got := permissionForRequest(http.MethodGet, "/api/workflows/:id/package"); got != "workflow:read" {
t.Fatalf("export permission=%q", got)
}
for _, path := range []string{"/api/workflow-package-inspections", "/api/workflow-package-inspections/:inspectionId", "/api/workflow-package-imports", "/api/workflow-package-imports/:importId"} {
if got := permissionForRequest(http.MethodGet, path); got != "workflow:write" {
t.Fatalf("%s permission=%q", path, got)
}
}
if !isProcessGlobalMutationPath("/workflow-package-imports") || !isProcessGlobalMutationPath("/workflow-package-inspections") {
t.Fatal("package mutations must require all-resource scope")
}
}
+98
View File
@@ -0,0 +1,98 @@
package workflowpackage
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"path"
"strings"
"time"
)
// Export builds a deterministic, human-readable single-workflow package.
func Export(source Document) ([]byte, ExportMetadata, error) {
source.ID = strings.TrimSpace(source.ID)
source.Name = strings.TrimSpace(source.Name)
if source.ID == "" || source.Name == "" || source.Version <= 0 || !safePackageWorkflowID(source.ID) {
return nil, ExportMetadata{}, fmt.Errorf("workflow id, name and version are required")
}
if strings.TrimSpace(source.GraphJSON) == "" {
return nil, ExportMetadata{}, fmt.Errorf("workflow graph_json is required")
}
contentHash, graphHash, payload, err := DocumentHashes(source)
if err != nil {
return nil, ExportMetadata{}, err
}
workflowPath := path.Join("workflows", source.ID+".json")
createdAt := source.UpdatedAt.UTC()
if createdAt.IsZero() {
createdAt = time.Unix(0, 0).UTC()
}
manifest := Manifest{
PackageFormat: PackageFormat,
FormatVersion: FormatVersion,
PackageID: "pkg_" + strings.TrimPrefix(contentHash, "sha256:")[:16],
CreatedAt: createdAt.Format(time.RFC3339),
Items: []ManifestItem{{
Type: "workflow",
Path: workflowPath,
SourceID: source.ID,
SourceRevision: source.Version,
ContentHash: contentHash,
GraphHash: graphHash,
}},
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return nil, ExportMetadata{}, fmt.Errorf("marshal manifest: %w", err)
}
checksums := fmt.Sprintf("%s manifest.json\n%s %s\n", strings.TrimPrefix(sha256Prefixed(manifestBytes), "sha256:"), strings.TrimPrefix(contentHash, "sha256:"), workflowPath)
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, entry := range []struct {
name string
data []byte
}{
{name: "checksums.sha256", data: []byte(checksums)},
{name: "manifest.json", data: manifestBytes},
{name: workflowPath, data: payload},
} {
header := &zip.FileHeader{Name: entry.name, Method: zip.Store}
header.SetModTime(time.Unix(0, 0).UTC())
writer, err := zw.CreateHeader(header)
if err != nil {
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
}
if _, err := writer.Write(entry.data); err != nil {
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
}
}
if err := zw.Close(); err != nil {
return nil, ExportMetadata{}, fmt.Errorf("close package: %w", err)
}
pkg := out.Bytes()
return pkg, ExportMetadata{
PackageHash: sha256Prefixed(pkg),
ContentHash: contentHash,
GraphHash: graphHash,
SourceRevision: source.Version,
FileName: source.ID + ".csapkg.zip",
}, nil
}
// DocumentHashes returns the canonical package item and graph hashes together
// with the canonical item bytes used by export and inspection persistence.
func DocumentHashes(source Document) (string, string, []byte, error) {
graph, err := canonicalJSON([]byte(source.GraphJSON))
if err != nil {
return "", "", nil, fmt.Errorf("canonicalize graph_json: %w", err)
}
source.GraphJSON = string(graph)
payload, err := json.Marshal(source)
if err != nil {
return "", "", nil, fmt.Errorf("marshal workflow payload: %w", err)
}
return sha256Prefixed(payload), sha256Prefixed(graph), payload, nil
}
@@ -0,0 +1,58 @@
package workflowpackage
import (
"archive/zip"
"bytes"
"strings"
"testing"
"time"
)
func testDocument() Document {
return Document{
ID: "web-src-hunting",
Name: "Web SRC 猎洞",
Description: "面向 SRC Web 资产的侦察与漏洞候选流程",
Version: 18,
Enabled: true,
GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`,
UpdatedAt: time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC),
}
}
func TestExportIsDeterministicAndSelfDescribing(t *testing.T) {
first, firstMeta, err := Export(testDocument())
if err != nil {
t.Fatalf("first export: %v", err)
}
second, secondMeta, err := Export(testDocument())
if err != nil {
t.Fatalf("second export: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("identical document must produce byte-identical package")
}
if firstMeta.PackageHash != secondMeta.PackageHash || !strings.HasPrefix(firstMeta.PackageHash, "sha256:") {
t.Fatalf("unexpected deterministic package hash: %#v / %#v", firstMeta, secondMeta)
}
zr, err := zip.NewReader(bytes.NewReader(first), int64(len(first)))
if err != nil {
t.Fatalf("open package: %v", err)
}
if len(zr.File) != 3 {
t.Fatalf("zip entry count = %d, want 3", len(zr.File))
}
wantNames := []string{"checksums.sha256", "manifest.json", "workflows/web-src-hunting.json"}
for i, f := range zr.File {
if f.Name != wantNames[i] {
t.Fatalf("entry %d = %q, want %q", i, f.Name, wantNames[i])
}
}
if firstMeta.SourceRevision != 18 {
t.Fatalf("source revision = %d, want 18", firstMeta.SourceRevision)
}
if !strings.HasPrefix(firstMeta.ContentHash, "sha256:") || !strings.HasPrefix(firstMeta.GraphHash, "sha256:") {
t.Fatalf("content/graph hashes must be sha256: %#v", firstMeta)
}
}
+225
View File
@@ -0,0 +1,225 @@
package workflowpackage
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
"unicode"
)
const (
MaxArchiveBytes = 10 << 20
MaxExtractedBytes = 20 << 20
)
// PackageError contains only a contract error code and safe, client-facing fields.
type PackageError struct {
Code string
Message string
Details map[string]any
}
func (e *PackageError) Error() string { return e.Code + ": " + e.Message }
func packageError(code, message string) error {
return &PackageError{Code: code, Message: message}
}
// ErrorCode returns a package contract code without exposing internal errors.
func ErrorCode(err error) string {
var target *PackageError
if errors.As(err, &target) {
return target.Code
}
return ""
}
type InspectionResult struct {
PackageHash string
Manifest Manifest
Document Document
ContentHash string
GraphHash string
NodeCount int
EdgeCount int
}
// InspectArchive verifies an archive without executing any package content.
// validateGraph is injected by the application so this format package has no
// dependency on the workflow runtime or database driver.
func InspectArchive(ctx context.Context, archive []byte, validateGraph func(context.Context, string) error) (*InspectionResult, error) {
if len(archive) == 0 {
return nil, packageError("WFPKG_FILE_REQUIRED", "必须上传工作流包文件")
}
if len(archive) > MaxArchiveBytes {
return nil, packageError("WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制")
}
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包不是有效 ZIP 文件")
}
entries := make(map[string][]byte, len(zr.File))
var extracted int64
for _, file := range zr.File {
if !safeArchivePath(file.Name) || file.FileInfo().IsDir() || file.FileInfo().Mode()&os.ModeSymlink != 0 {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含不安全文件路径")
}
if _, exists := entries[file.Name]; exists {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含重复文件")
}
if file.UncompressedSize64 > MaxExtractedBytes || extracted+int64(file.UncompressedSize64) > MaxExtractedBytes {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
}
reader, err := file.Open()
if err != nil {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "无法读取工作流包文件")
}
data, readErr := io.ReadAll(io.LimitReader(reader, int64(MaxExtractedBytes)-extracted+1))
closeErr := reader.Close()
if readErr != nil || closeErr != nil || len(data) > MaxExtractedBytes-int(extracted) {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
}
extracted += int64(len(data))
entries[file.Name] = data
}
manifestRaw, hasManifest := entries["manifest.json"]
checksumsRaw, hasChecksums := entries["checksums.sha256"]
if !hasManifest || !hasChecksums {
return nil, packageError("WFPKG_UNSUPPORTED_FORMAT", "工作流包缺少必需文件")
}
manifest, err := parseManifest(manifestRaw)
if err != nil {
return nil, err
}
if len(manifest.Items) != 1 || manifest.Items[0].Type != "workflow" {
return nil, packageError("WFPKG_MULTIPLE_WORKFLOWS", "工作流包必须且只能包含一个工作流")
}
item := manifest.Items[0]
workflowRaw, exists := entries[item.Path]
if !exists || !safeWorkflowPath(item.Path) || len(entries) != 3 {
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含未声明文件")
}
checksums, err := parseChecksums(checksumsRaw)
if err != nil {
return nil, err
}
if len(checksums) != 2 || checksums["manifest.json"] != sha256Prefixed(manifestRaw) || checksums[item.Path] != sha256Prefixed(workflowRaw) {
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和不匹配")
}
if item.ContentHash != sha256Prefixed(workflowRaw) || !validHash(item.ContentHash) || !validHash(item.GraphHash) {
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包内容校验和不匹配")
}
doc, err := parseDocument(workflowRaw)
if err != nil {
return nil, err
}
if !safePackageWorkflowID(doc.ID) || doc.ID != item.SourceID || doc.Version != item.SourceRevision {
return nil, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单与工作流内容不一致")
}
graph, err := canonicalJSON([]byte(doc.GraphJSON))
if err != nil || item.GraphHash != sha256Prefixed(graph) {
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流图校验和不匹配")
}
if validateGraph == nil || validateGraph(ctx, string(graph)) != nil {
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图校验失败")
}
var graphShape struct {
Nodes []json.RawMessage `json:"nodes"`
Edges []json.RawMessage `json:"edges"`
}
if err := json.Unmarshal(graph, &graphShape); err != nil {
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图不是有效 JSON")
}
return &InspectionResult{
PackageHash: sha256Prefixed(archive),
Manifest: manifest,
Document: doc,
ContentHash: item.ContentHash,
GraphHash: item.GraphHash,
NodeCount: len(graphShape.Nodes),
EdgeCount: len(graphShape.Edges),
}, nil
}
func safeArchivePath(name string) bool {
return name != "" && !strings.Contains(name, `\`) && !strings.HasPrefix(name, "/") && path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".."
}
func safeWorkflowPath(name string) bool {
rest := strings.TrimPrefix(name, "workflows/")
return safeArchivePath(name) && strings.HasPrefix(name, "workflows/") && rest != "" && !strings.Contains(rest, "/") && strings.HasSuffix(rest, ".json")
}
func safePackageWorkflowID(id string) bool {
if id == "" || strings.ContainsAny(id, `/\`) {
return false
}
for _, r := range id {
if unicode.IsControl(r) {
return false
}
}
return true
}
func parseManifest(raw []byte) (Manifest, error) {
var manifest Manifest
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&manifest); err != nil {
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式无效")
}
if err := consumeJSONEnd(dec); err != nil || manifest.PackageFormat != PackageFormat || manifest.FormatVersion != FormatVersion || strings.TrimSpace(manifest.PackageID) == "" || len(manifest.Items) == 0 {
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式不受支持")
}
return manifest, nil
}
func parseDocument(raw []byte) (Document, error) {
var doc Document
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&doc); err != nil || consumeJSONEnd(dec) != nil {
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义格式无效")
}
doc.ID = strings.TrimSpace(doc.ID)
doc.Name = strings.TrimSpace(doc.Name)
if doc.ID == "" || doc.Name == "" || doc.Version <= 0 || strings.TrimSpace(doc.GraphJSON) == "" {
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义缺少必需字段")
}
return doc, nil
}
func consumeJSONEnd(dec *json.Decoder) error {
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return fmt.Errorf("multiple JSON values")
}
return err
}
return nil
}
func parseChecksums(raw []byte) (map[string]string, error) {
entries := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
if len(parts) != 2 || !validHash("sha256:"+parts[0]) || !safeArchivePath(parts[1]) {
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和格式无效")
}
if _, exists := entries[parts[1]]; exists {
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和重复")
}
entries[parts[1]] = "sha256:" + parts[0]
}
return entries, nil
}
+154
View File
@@ -0,0 +1,154 @@
package workflowpackage
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"testing"
)
func TestInspectArchiveAcceptsSingleVerifiedWorkflow(t *testing.T) {
pkg, meta, err := Export(testDocument())
if err != nil {
t.Fatal(err)
}
result, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return nil })
if err != nil {
t.Fatalf("InspectArchive: %v", err)
}
if result.PackageHash != meta.PackageHash || result.Document.ID != "web-src-hunting" {
t.Fatalf("unexpected inspection: %#v", result)
}
if result.NodeCount != 2 || result.EdgeCount != 1 {
t.Fatalf("counts = %d/%d, want 2/1", result.NodeCount, result.EdgeCount)
}
}
func TestInspectArchiveRejectsUnsafeArchiveShapes(t *testing.T) {
valid, _, err := Export(testDocument())
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
archive []byte
}{
{name: "duplicate entry", archive: appendZipEntry(t, valid, "manifest.json", []byte(`{}`), 0)},
{name: "path traversal", archive: appendZipEntry(t, valid, "../payload.json", []byte(`{}`), 0)},
{name: "symlink", archive: appendZipEntry(t, valid, "workflows/link.json", []byte("target"), 0o120777)},
{name: "undeclared file", archive: appendZipEntry(t, valid, "notes.txt", []byte("not allowed"), 0)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := InspectArchive(context.Background(), tc.archive, func(context.Context, string) error { return nil })
if ErrorCode(err) != "WFPKG_INVALID_ARCHIVE" {
t.Fatalf("code = %q, err = %v", ErrorCode(err), err)
}
})
}
}
func TestInspectArchiveRejectsChecksumMismatchAndInvalidWorkflow(t *testing.T) {
pkg, _, err := Export(testDocument())
if err != nil {
t.Fatal(err)
}
badChecksum := replaceZipEntry(t, pkg, "checksums.sha256", []byte("00 manifest.json\n"), 0)
if _, err := InspectArchive(context.Background(), badChecksum, func(context.Context, string) error { return nil }); ErrorCode(err) != "WFPKG_CHECKSUM_MISMATCH" {
t.Fatalf("checksum code = %q, err = %v", ErrorCode(err), err)
}
if _, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return errors.New("invalid graph") }); ErrorCode(err) != "WFPKG_WORKFLOW_INVALID" {
t.Fatalf("graph code = %q, err = %v", ErrorCode(err), err)
}
}
func appendZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
t.Helper()
return rewriteZip(t, archive, func(zw *zip.Writer) error {
h := &zip.FileHeader{Name: name, Method: zip.Store}
h.SetMode(mode)
w, err := zw.CreateHeader(h)
if err != nil {
return err
}
_, err = w.Write(data)
return err
})
}
func replaceZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, f := range zr.File {
if f.Name == name {
h := &zip.FileHeader{Name: name, Method: zip.Store}
h.SetMode(mode)
w, err := zw.CreateHeader(h)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
continue
}
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
w, err := zw.CreateHeader(h)
if err == nil {
_, err = io.Copy(w, r)
}
_ = r.Close()
if err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
func rewriteZip(t *testing.T, archive []byte, appendEntry func(*zip.Writer) error) []byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
zw := zip.NewWriter(&out)
for _, f := range zr.File {
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
w, err := zw.CreateHeader(h)
if err == nil {
_, err = io.Copy(w, r)
}
_ = r.Close()
if err != nil {
t.Fatal(err)
}
}
if err := appendEntry(zw); err != nil {
t.Fatal(fmt.Errorf("append entry: %w", err))
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
+78
View File
@@ -0,0 +1,78 @@
package workflowpackage
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
const (
PackageFormat = "cyberstrikeai.workflow-package"
FormatVersion = "1.0"
)
// Document is the single non-executable workflow definition carried by a package.
// Version is the source instance revision and is never applied as a target version.
type Document struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int `json:"version"`
GraphJSON string `json:"graph_json"`
Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"-"`
}
type Manifest struct {
PackageFormat string `json:"package_format"`
FormatVersion string `json:"format_version"`
PackageID string `json:"package_id"`
CreatedAt string `json:"created_at"`
Items []ManifestItem `json:"items"`
}
type ManifestItem struct {
Type string `json:"type"`
Path string `json:"path"`
SourceID string `json:"source_id"`
SourceRevision int `json:"source_revision"`
ContentHash string `json:"content_hash"`
GraphHash string `json:"graph_hash"`
}
type ExportMetadata struct {
PackageHash string
ContentHash string
GraphHash string
SourceRevision int
FileName string
}
func canonicalJSON(raw []byte) ([]byte, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.UseNumber()
var value any
if err := dec.Decode(&value); err != nil {
return nil, err
}
if dec.More() {
return nil, fmt.Errorf("extra JSON values")
}
return json.Marshal(value)
}
func sha256Prefixed(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func validHash(value string) bool {
if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 {
return false
}
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
return err == nil && value == strings.ToLower(value)
}