feat(workspace): gate cleanup on verified receipts
Hash deliverables through Capability.ReadArtifact, resolve backends on the server, and refuse workspace removal until frozen members match FAV receipts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -259,7 +259,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
IntendedUse: req.IntendedUse,
|
IntendedUse: req.IntendedUse,
|
||||||
TargetMarket: req.TargetMarket,
|
TargetMarket: req.TargetMarket,
|
||||||
Fields: req.Fields,
|
Fields: req.Fields,
|
||||||
Backend: req.Backend,
|
Backend: resolveTaskBackend(s, req.Backend),
|
||||||
AutoRepair: req.AutoRepair,
|
AutoRepair: req.AutoRepair,
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
@@ -308,7 +308,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
TargetMarket: req.TargetMarket,
|
TargetMarket: req.TargetMarket,
|
||||||
Fields: req.Fields,
|
Fields: req.Fields,
|
||||||
Materials: req.Materials,
|
Materials: req.Materials,
|
||||||
Backend: req.Backend,
|
Backend: resolveTaskBackend(s, req.Backend),
|
||||||
AutoRepair: req.AutoRepair,
|
AutoRepair: req.AutoRepair,
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chat-agent-system/internal/agent"
|
"chat-agent-system/internal/agent"
|
||||||
|
"chat-agent-system/internal/app/uow"
|
||||||
|
"chat-agent-system/internal/artifactvault"
|
||||||
"chat-agent-system/internal/auth"
|
"chat-agent-system/internal/auth"
|
||||||
"chat-agent-system/internal/authz"
|
"chat-agent-system/internal/authz"
|
||||||
"chat-agent-system/internal/bizdev"
|
"chat-agent-system/internal/bizdev"
|
||||||
@@ -45,6 +47,8 @@ type Server struct {
|
|||||||
instSvc *projectinstance.Service
|
instSvc *projectinstance.Service
|
||||||
notifySvc *notification.Service
|
notifySvc *notification.Service
|
||||||
resolver *workspace.Resolver
|
resolver *workspace.Resolver
|
||||||
|
wsCap *workspace.Capability
|
||||||
|
cmds *taskrun.Commands
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +97,18 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
|||||||
if pool != nil {
|
if pool != nil {
|
||||||
taskMgr.SetStore(taskrun.NewPGStore(pool))
|
taskMgr.SetStore(taskrun.NewPGStore(pool))
|
||||||
}
|
}
|
||||||
|
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
|
||||||
|
cmds := &taskrun.Commands{
|
||||||
|
Ctx: svc.ContextManager(),
|
||||||
|
PW: pwSvc,
|
||||||
|
Env: cfg.Env,
|
||||||
|
StorageIdentity: "env:" + cfg.Env,
|
||||||
|
}
|
||||||
|
if pool != nil {
|
||||||
|
cmds.UoW = uow.NewPG(pool)
|
||||||
|
cmds.Fav = &artifactvault.Ingest{Env: cfg.Env}
|
||||||
|
}
|
||||||
|
taskMgr.SetCommands(cmds)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -109,6 +125,8 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
|||||||
instSvc: instSvc,
|
instSvc: instSvc,
|
||||||
notifySvc: notification.NewService(pool),
|
notifySvc: notification.NewService(pool),
|
||||||
resolver: workspace.NewResolver(cfg.DataDir, pool),
|
resolver: workspace.NewResolver(cfg.DataDir, pool),
|
||||||
|
wsCap: wsCap,
|
||||||
|
cmds: cmds,
|
||||||
}
|
}
|
||||||
if instSvc != nil && instSvc.Available() {
|
if instSvc != nil && instSvc.Available() {
|
||||||
svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc))
|
svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc))
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.DeviceType = q.Get("device_type")
|
req.DeviceType = q.Get("device_type")
|
||||||
req.IntendedUse = q.Get("intended_use")
|
req.IntendedUse = q.Get("intended_use")
|
||||||
req.TargetMarket = q.Get("target_market")
|
req.TargetMarket = q.Get("target_market")
|
||||||
req.Backend = q.Get("backend")
|
req.Backend = resolveTaskBackend(s, q.Get("backend"))
|
||||||
req.WorkDir = q.Get("work_dir")
|
req.WorkDir = q.Get("work_dir")
|
||||||
req.AutoRepair, _ = strconv.ParseBool(q.Get("auto_repair"))
|
req.AutoRepair, _ = strconv.ParseBool(q.Get("auto_repair"))
|
||||||
if pid := q.Get("project_id"); pid != "" {
|
if pid := q.Get("project_id"); pid != "" {
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"chat-agent-system/internal/bizdev"
|
"chat-agent-system/internal/bizdev"
|
||||||
"chat-agent-system/internal/squad"
|
"chat-agent-system/internal/squad"
|
||||||
"chat-agent-system/internal/taskrun"
|
"chat-agent-system/internal/taskrun"
|
||||||
|
"chat-agent-system/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FinalizeTaskRunProjection 将任务执行结果投影到 workflow_runs 与聊天助手消息(供回填/管理命令使用)。
|
// FinalizeTaskRunProjection 将任务执行结果投影到 workflow_runs 与聊天助手消息(供回填/管理命令使用)。
|
||||||
@@ -18,101 +17,69 @@ func (s *Server) FinalizeTaskRunProjection(ctx context.Context, record *taskrun.
|
|||||||
return s.finalizeTaskRun(ctx, record, result)
|
return s.finalizeTaskRun(ctx, record, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeTaskRun 将任务执行结果投影到 workflow_runs 与聊天助手消息。
|
// finalizeTaskRun 是薄适配:产物哈希在事务外经 workspace.Capability 读取,终态命令交给 taskrun.Commands。
|
||||||
func (s *Server) finalizeTaskRun(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) {
|
func (s *Server) finalizeTaskRun(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) {
|
||||||
if record == nil || result == nil {
|
if record == nil || result == nil {
|
||||||
return "failed", fmt.Errorf("finalize: missing record or result")
|
return "failed", fmt.Errorf("finalize: missing record or result")
|
||||||
}
|
}
|
||||||
|
if result.DocRecord != nil && result.DocRecord.ContentSHA256 == "" {
|
||||||
|
if hexSum := s.hashDeliverable(ctx, record, result); hexSum != "" {
|
||||||
|
result.DocRecord.ContentSHA256 = hexSum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmds := s.taskCommands()
|
||||||
|
if s != nil {
|
||||||
|
cmds.PW = s.pwSvc
|
||||||
|
if s.svc != nil {
|
||||||
|
cmds.Ctx = s.svc.ContextManager()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmds.Finalize(ctx, record, result)
|
||||||
|
}
|
||||||
|
|
||||||
workspaceID := result.WorkDir
|
func (s *Server) taskCommands() *taskrun.Commands {
|
||||||
if workspaceID == "" {
|
if s != nil && s.cmds != nil {
|
||||||
workspaceID = record.WorkspaceID
|
return s.cmds
|
||||||
}
|
}
|
||||||
|
cmds := &taskrun.Commands{
|
||||||
|
PW: s.pwSvc,
|
||||||
|
Env: "test",
|
||||||
|
}
|
||||||
|
if s != nil && s.cfg != nil {
|
||||||
|
cmds.Env = s.cfg.Env
|
||||||
|
cmds.StorageIdentity = "env:" + s.cfg.Env
|
||||||
|
}
|
||||||
|
if s != nil && s.svc != nil {
|
||||||
|
cmds.Ctx = s.svc.ContextManager()
|
||||||
|
}
|
||||||
|
return cmds
|
||||||
|
}
|
||||||
|
|
||||||
terminalStatus := result.TerminalStatus()
|
func (s *Server) hashDeliverable(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) string {
|
||||||
summaryContent := result.CompletionSummary()
|
if s == nil || s.wsCap == nil || record == nil {
|
||||||
|
return ""
|
||||||
docID := ""
|
|
||||||
artifactTitle := result.ArtifactTitle
|
|
||||||
sha256Hex := ""
|
|
||||||
if result.DocRecord != nil {
|
|
||||||
docID = result.DocRecord.DocID
|
|
||||||
if result.DocRecord.ContentSHA256 != "" {
|
|
||||||
sha256Hex = result.DocRecord.ContentSHA256
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if docID == "" && record.ProjectID > 0 && record.TaskKey != "" {
|
id := workspace.OpaqueID(result.WorkDir, record.WorkspaceID)
|
||||||
docID = fmt.Sprintf("doc_proj%d_%s", record.ProjectID, record.TaskKey)
|
if id == "" {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
if artifactTitle == "" && record.TaskKey != "" {
|
env := ""
|
||||||
artifactTitle = squad.TaskArtifactTitle(record.TaskKey)
|
if s.cfg != nil {
|
||||||
|
env = s.cfg.Env
|
||||||
}
|
}
|
||||||
|
ref := workspace.WorkspaceRef{
|
||||||
if sha256Hex == "" && workspaceID != "" {
|
ID: id,
|
||||||
if computed, err := hashOutputFile(workspaceID); err == nil {
|
SchemaVersion: workspace.SchemaVersion,
|
||||||
sha256Hex = computed
|
ProjectScope: record.ProjectID,
|
||||||
}
|
RunID: record.RunID,
|
||||||
|
Environment: env,
|
||||||
}
|
}
|
||||||
|
body, err := s.wsCap.ReadArtifact(ctx, ref, workspace.DeliverableKey)
|
||||||
meta := map[string]any{
|
if err != nil || len(body) == 0 {
|
||||||
"run_id": record.RunID,
|
return ""
|
||||||
"workspace_id": workspaceID,
|
|
||||||
"task_status": terminalStatus,
|
|
||||||
"task_key": record.TaskKey,
|
|
||||||
}
|
}
|
||||||
if record.ProjectID > 0 {
|
sum := sha256.Sum256(body)
|
||||||
meta["project_id"] = record.ProjectID
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
|
||||||
if art := result.ArtifactMetadata(); art != nil {
|
|
||||||
if workspaceID != "" {
|
|
||||||
art["workspace_id"] = workspaceID
|
|
||||||
}
|
|
||||||
if sha256Hex != "" {
|
|
||||||
art["sha256"] = sha256Hex
|
|
||||||
}
|
|
||||||
if artifactTitle != "" {
|
|
||||||
art["title"] = artifactTitle
|
|
||||||
}
|
|
||||||
if record.TaskKey != "" {
|
|
||||||
art["task_key"] = record.TaskKey
|
|
||||||
}
|
|
||||||
if docID != "" {
|
|
||||||
art["doc_id"] = docID
|
|
||||||
}
|
|
||||||
meta["artifact"] = art
|
|
||||||
} else if docID != "" {
|
|
||||||
artifact := map[string]any{
|
|
||||||
"doc_id": docID,
|
|
||||||
"title": artifactTitle,
|
|
||||||
"task_key": record.TaskKey,
|
|
||||||
"workspace_id": workspaceID,
|
|
||||||
}
|
|
||||||
if sha256Hex != "" {
|
|
||||||
artifact["sha256"] = sha256Hex
|
|
||||||
}
|
|
||||||
if result.QualityReport != nil {
|
|
||||||
artifact["quality_passed"] = result.QualityReport.Passed
|
|
||||||
artifact["quality_score"] = result.QualityReport.Score
|
|
||||||
if len(result.QualityReport.Violations) > 0 {
|
|
||||||
artifact["quality_violations"] = result.QualityReport.Violations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meta["artifact"] = artifact
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pwSvc != nil && record.ProjectID > 0 && record.RunID != "" {
|
|
||||||
if err := s.pwSvc.UpdateWorkflowRun(ctx, record.RunID, terminalStatus, docID, artifactTitle, ""); err != nil {
|
|
||||||
return "finalizing_failed", fmt.Errorf("update workflow run: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.svc != nil && s.svc.ContextManager() != nil && record.AssistantMsgID > 0 {
|
|
||||||
if err := s.svc.ContextManager().FinalizeAssistantTaskMessage(ctx, record.AssistantMsgID, summaryContent, meta, terminalStatus); err != nil {
|
|
||||||
return "finalizing_failed", fmt.Errorf("finalize assistant message: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return terminalStatus, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTerminalStatus(result *bizdev.TaskRunResult) string {
|
func resolveTerminalStatus(result *bizdev.TaskRunResult) string {
|
||||||
@@ -124,19 +91,6 @@ func buildTaskCompletionSummary(result *bizdev.TaskRunResult, terminalStatus str
|
|||||||
return result.CompletionSummary()
|
return result.CompletionSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashOutputFile(workDir string) (string, error) {
|
|
||||||
path := filepath.Join(workDir, "output.md")
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
return "", fmt.Errorf("output.md is empty")
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
return hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func squadResultToTaskRun(res *squad.SquadRunResult, taskKey string) *bizdev.TaskRunResult {
|
func squadResultToTaskRun(res *squad.SquadRunResult, taskKey string) *bizdev.TaskRunResult {
|
||||||
if res == nil {
|
if res == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, client
|
|||||||
TargetMarket: dto.TargetMarket,
|
TargetMarket: dto.TargetMarket,
|
||||||
Fields: dto.Fields,
|
Fields: dto.Fields,
|
||||||
Materials: dto.Materials,
|
Materials: dto.Materials,
|
||||||
Backend: dto.Backend,
|
Backend: resolveTaskBackend(s, dto.Backend),
|
||||||
AutoRepair: true, // 产品契约:受控小队任务服务端启用修复,不依赖浏览器字段
|
AutoRepair: true, // 产品契约:受控小队任务服务端启用修复,不依赖浏览器字段
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
UserID: dto.UserID,
|
UserID: dto.UserID,
|
||||||
@@ -664,6 +664,19 @@ func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, client
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveTaskBackend(s *Server, requested string) string {
|
||||||
|
configured := ""
|
||||||
|
if s != nil && s.cfg != nil {
|
||||||
|
configured = s.cfg.AgentBackend
|
||||||
|
}
|
||||||
|
resolved, err := taskrun.ResolveBackend(requested, configured)
|
||||||
|
if err != nil {
|
||||||
|
fallback, _ := taskrun.ResolveBackend("", configured)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clientTurnID, runID string) bizdev.StageRunRequest {
|
func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clientTurnID, runID string) bizdev.StageRunRequest {
|
||||||
return bizdev.StageRunRequest{
|
return bizdev.StageRunRequest{
|
||||||
Stage: bizdev.MaturityStage(dto.Stage),
|
Stage: bizdev.MaturityStage(dto.Stage),
|
||||||
@@ -674,7 +687,7 @@ func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clien
|
|||||||
TargetMarket: dto.TargetMarket,
|
TargetMarket: dto.TargetMarket,
|
||||||
Fields: dto.Fields,
|
Fields: dto.Fields,
|
||||||
Materials: dto.Materials,
|
Materials: dto.Materials,
|
||||||
Backend: dto.Backend,
|
Backend: resolveTaskBackend(s, dto.Backend),
|
||||||
AutoRepair: true, // 产品契约:阶段任务同样启用修复预算
|
AutoRepair: true, // 产品契约:阶段任务同样启用修复预算
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
UserID: dto.UserID,
|
UserID: dto.UserID,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpaqueID turns a stored work dir or already-opaque id into a WorkspaceRef.ID.
|
||||||
|
// It never returns a physical path.
|
||||||
|
func OpaqueID(parts ...string) string {
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slash := filepath.ToSlash(p)
|
||||||
|
if i := strings.LastIndex(slash, "/workspaces/"); i >= 0 {
|
||||||
|
rest := slash[i+len("/workspaces/"):]
|
||||||
|
if rest == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.Split(rest, "/")[0]
|
||||||
|
}
|
||||||
|
if !strings.Contains(slash, "/") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Base(filepath.Clean(p))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetentionReceipt struct {
|
||||||
|
LogicalID string
|
||||||
|
Digest string
|
||||||
|
StorageIdentity string
|
||||||
|
Verified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrozenManifest struct {
|
||||||
|
SHA256 string
|
||||||
|
StorageIdentity string
|
||||||
|
WorkspaceID string
|
||||||
|
Members []FrozenMember
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrozenMember struct {
|
||||||
|
LogicalID string
|
||||||
|
Digest string
|
||||||
|
Kind string
|
||||||
|
}
|
||||||
|
|
||||||
|
func memberNeedsRetention(kind string) bool {
|
||||||
|
switch kind {
|
||||||
|
case "document", "assistant_turn", "run_manifest", "attachment":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrCleanupBlocked = fmt.Errorf("workspace: cleanup blocked until verified receipts match the frozen manifest")
|
||||||
|
|
||||||
|
func AssertCleanupAllowed(frozen FrozenManifest, receipts []RetentionReceipt) error {
|
||||||
|
if frozen.SHA256 == "" || frozen.StorageIdentity == "" {
|
||||||
|
return ErrCleanupBlocked
|
||||||
|
}
|
||||||
|
byLogical := map[string]RetentionReceipt{}
|
||||||
|
for _, r := range receipts {
|
||||||
|
byLogical[r.LogicalID] = r
|
||||||
|
}
|
||||||
|
for _, mem := range frozen.Members {
|
||||||
|
if !memberNeedsRetention(mem.Kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, ok := byLogical[mem.LogicalID]
|
||||||
|
if !ok || !r.Verified {
|
||||||
|
return ErrCleanupBlocked
|
||||||
|
}
|
||||||
|
if r.Digest != mem.Digest || r.StorageIdentity != frozen.StorageIdentity {
|
||||||
|
return ErrCleanupBlocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes the workspace directory only after verified receipts match the frozen manifest.
|
||||||
|
func (c *Capability) Cleanup(ctx context.Context, ref WorkspaceRef, frozen FrozenManifest, receipts []RetentionReceipt) error {
|
||||||
|
if err := AssertCleanupAllowed(frozen, receipts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.guard(ctx, ref, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root := filepath.Join(c.dataRoot, "workspaces", ref.ID)
|
||||||
|
abs, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataRoot, err := filepath.Abs(c.dataRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(dataRoot, abs)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
|
return ErrPathEscape
|
||||||
|
}
|
||||||
|
return os.RemoveAll(abs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpaqueID_StripsPhysicalPath(t *testing.T) {
|
||||||
|
got := OpaqueID("/data/dev/workspaces/run_abc/output.md", "")
|
||||||
|
if got != "run_abc" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if OpaqueID("already-opaque") != "already-opaque" {
|
||||||
|
t.Fatal("opaque id changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup_RequiresVerifiedReceipts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cap := NewCapability(root, nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
ref, err := cap.Issue(WorkspaceRef{ID: "ws_clean", ProjectScope: 1, RunID: "run_clean"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cap.WriteArtifact(ctx, ref, DeliverableKey, []byte("# body\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
frozen := FrozenManifest{
|
||||||
|
SHA256: "deadbeef", StorageIdentity: "env:dev", WorkspaceID: ref.ID,
|
||||||
|
Members: []FrozenMember{{LogicalID: "doc_a", Digest: "abc", Kind: "document"}},
|
||||||
|
}
|
||||||
|
if err := cap.Cleanup(ctx, ref, frozen, nil); err == nil {
|
||||||
|
t.Fatal("cleanup without receipts must fail")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "workspaces", ref.ID, DeliverableKey)); err != nil {
|
||||||
|
t.Fatalf("workspace removed without receipts: %v", err)
|
||||||
|
}
|
||||||
|
receipts := []RetentionReceipt{{
|
||||||
|
LogicalID: "doc_a", Digest: "abc", StorageIdentity: "env:dev", Verified: true,
|
||||||
|
}}
|
||||||
|
if err := cap.Cleanup(ctx, ref, frozen, receipts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "workspaces", ref.ID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("workspace still present: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup_MismatchDigestRejected(t *testing.T) {
|
||||||
|
frozen := FrozenManifest{
|
||||||
|
SHA256: "x", StorageIdentity: "env:dev",
|
||||||
|
Members: []FrozenMember{{LogicalID: "doc_a", Digest: "abc", Kind: "document"}},
|
||||||
|
}
|
||||||
|
err := AssertCleanupAllowed(frozen, []RetentionReceipt{{
|
||||||
|
LogicalID: "doc_a", Digest: "other", StorageIdentity: "env:dev", Verified: true,
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("mismatch must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
// DeliverableKey is the only artifact key for the task run markdown deliverable.
|
||||||
|
const DeliverableKey = "output.md"
|
||||||
Reference in New Issue
Block a user