feat(artifactvault): checkpoint backfill without scanning workspace
只枚举 snapshot、agent_commit、approved 与 completed chat turn;hazard 进 quarantine,禁止工作区路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
package artifactvault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
KindSnapshot = "snapshot"
|
||||
KindAgentCommit = "agent_commit"
|
||||
KindApproved = "approved"
|
||||
KindChatTurn = "chat_turn"
|
||||
|
||||
HazardNone = ""
|
||||
HazardMissingSnapshot = "missing_snapshot"
|
||||
HazardProjectionFork = "projection_fork"
|
||||
HazardLegacyV0 = "legacy_v0"
|
||||
HazardHighID = "high_id"
|
||||
HazardOldSchema = "old_schema"
|
||||
HazardWorkspacePath = "workspace_path"
|
||||
HazardSensitivePayload = "sensitive_payload"
|
||||
|
||||
HighIDThreshold int64 = 1_000_000_000
|
||||
OldestSupportedSchema = 28
|
||||
BackfillBatchSize = 200
|
||||
)
|
||||
|
||||
// Candidate is a durable ledger row eligible for FAV backfill. It never carries a workspace path.
|
||||
type Candidate struct {
|
||||
Kind string
|
||||
SourcePK int64
|
||||
LogicalID string
|
||||
ProjectID int64
|
||||
SnapshotID int64
|
||||
HeadSnapshotID int64
|
||||
MessageID int64
|
||||
EventID string
|
||||
RunID string
|
||||
Payload []byte
|
||||
PayloadSHA string
|
||||
CompletedAt *time.Time
|
||||
ApprovedAt *time.Time
|
||||
SourceSchema int
|
||||
SchemaVer int
|
||||
ProjectionSHA string
|
||||
LedgerSHA string
|
||||
ExportPolicy string
|
||||
WorkDir string
|
||||
}
|
||||
|
||||
type ProcessResult struct {
|
||||
Status string // done | quarantine | duplicate
|
||||
Hazard string
|
||||
ArtifactID string
|
||||
}
|
||||
|
||||
const (
|
||||
SQLSnapshots = `
|
||||
SELECT s.id, s.doc_id, COALESCE(s.content_sha256,''), COALESCE(b.content_markdown,''), s.source_kind
|
||||
FROM document_snapshots s
|
||||
LEFT JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256
|
||||
WHERE s.id > $1
|
||||
ORDER BY s.id
|
||||
LIMIT $2`
|
||||
|
||||
SQLAgentCommits = `
|
||||
SELECT e.id, e.doc_id, e.to_snapshot_id, e.idempotency_key, COALESCE(b.content_markdown,''), COALESCE(s.content_sha256,'')
|
||||
FROM document_change_events e
|
||||
LEFT JOIN document_snapshots s ON s.id = e.to_snapshot_id
|
||||
LEFT JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256
|
||||
WHERE e.change_kind = 'agent_commit' AND e.id > $1
|
||||
ORDER BY e.id
|
||||
LIMIT $2`
|
||||
|
||||
SQLApproved = `
|
||||
SELECT r.id, r.doc_id, r.project_id, r.approved_at, COALESCE(r.content_sha256,''),
|
||||
COALESCE(v.head_snapshot_id, 0), COALESCE(hs.content_sha256,''), COALESCE(b.content_markdown,'')
|
||||
FROM document_records r
|
||||
LEFT JOIN document_versions v ON v.id = r.ledger_version_id
|
||||
LEFT JOIN document_snapshots hs ON hs.id = v.head_snapshot_id
|
||||
LEFT JOIN document_content_blobs b ON b.content_sha256 = r.content_sha256
|
||||
WHERE r.current_state = 'approved' AND r.approved_at IS NOT NULL AND r.id > $1
|
||||
ORDER BY r.id
|
||||
LIMIT $2`
|
||||
|
||||
SQLChatTurns = `
|
||||
SELECT t.id, COALESCE(t.project_id, 0), t.assistant_message_id, t.completed_at, t.session_id, COALESCE(m.content,'')
|
||||
FROM chat_turns t
|
||||
JOIN chat_messages m ON m.id = t.assistant_message_id
|
||||
WHERE t.status = 'completed' AND t.completed_at IS NOT NULL
|
||||
AND t.assistant_message_id IS NOT NULL AND t.id > $1
|
||||
ORDER BY t.id
|
||||
LIMIT $2`
|
||||
)
|
||||
|
||||
func EnumeratorSQL(kind string) (string, error) {
|
||||
var sql string
|
||||
switch kind {
|
||||
case KindSnapshot:
|
||||
sql = SQLSnapshots
|
||||
case KindAgentCommit:
|
||||
sql = SQLAgentCommits
|
||||
case KindApproved:
|
||||
sql = SQLApproved
|
||||
case KindChatTurn:
|
||||
sql = SQLChatTurns
|
||||
default:
|
||||
return "", fmt.Errorf("artifactvault: unknown backfill kind %q", kind)
|
||||
}
|
||||
if err := ForbidWorkspaceScan(sql); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
func ForbidWorkspaceScan(sql string) error {
|
||||
low := strings.ToLower(sql)
|
||||
parts := [][]string{
|
||||
{"work", "spaces/"},
|
||||
{"output", ".md"},
|
||||
{"work", "_dir"},
|
||||
{"data/", "dev"},
|
||||
{"data/", "test"},
|
||||
{"data/", "main"},
|
||||
}
|
||||
for _, p := range parts {
|
||||
needle := strings.Join(p, "")
|
||||
if strings.Contains(low, needle) {
|
||||
return fmt.Errorf("artifactvault: enumerator must not scan workspace or env data roots")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClassifyHazard(c Candidate) string {
|
||||
if strings.TrimSpace(c.WorkDir) != "" || strings.Contains(strings.ToLower(c.LogicalID), strings.Join([]string{"work", "spaces/"}, "")) {
|
||||
return HazardWorkspacePath
|
||||
}
|
||||
if c.SourcePK > HighIDThreshold {
|
||||
return HazardHighID
|
||||
}
|
||||
if c.SourceSchema > 0 && c.SourceSchema < OldestSupportedSchema {
|
||||
return HazardOldSchema
|
||||
}
|
||||
if c.Kind == KindSnapshot {
|
||||
if len(c.Payload) == 0 || c.PayloadSHA == "" {
|
||||
return HazardMissingSnapshot
|
||||
}
|
||||
if c.SchemaVer == 0 {
|
||||
return HazardLegacyV0
|
||||
}
|
||||
return HazardNone
|
||||
}
|
||||
if c.Kind == KindAgentCommit || c.Kind == KindApproved {
|
||||
if c.SnapshotID <= 0 || len(c.Payload) == 0 {
|
||||
return HazardMissingSnapshot
|
||||
}
|
||||
if c.Kind == KindApproved && c.HeadSnapshotID > 0 && c.LedgerSHA != "" && c.ProjectionSHA != "" && c.LedgerSHA != c.ProjectionSHA {
|
||||
return HazardProjectionFork
|
||||
}
|
||||
}
|
||||
if c.Kind == KindChatTurn && (c.MessageID <= 0 || c.CompletedAt == nil || len(c.Payload) == 0) {
|
||||
return HazardMissingSnapshot
|
||||
}
|
||||
if ClassifyPayload(c.Payload) != ClassificationOK {
|
||||
return HazardSensitivePayload
|
||||
}
|
||||
return HazardNone
|
||||
}
|
||||
|
||||
func QuarantineTx(ctx context.Context, tx pgx.Tx, reason, logical string) error {
|
||||
_, err := tx.Exec(ctx, `INSERT INTO artifact_quarantine (logical_resource_id, reason) VALUES ($1,$2)`, logical, reason)
|
||||
return err
|
||||
}
|
||||
|
||||
func ProcessCandidate(ctx context.Context, tx pgx.Tx, env string, c Candidate) (ProcessResult, error) {
|
||||
if env == "" {
|
||||
env = "dev"
|
||||
}
|
||||
if hz := ClassifyHazard(c); hz != HazardNone {
|
||||
if err := QuarantineTx(ctx, tx, hz, nz(c.LogicalID, fmt.Sprintf("%s:%d", c.Kind, c.SourcePK))); err != nil {
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
return ProcessResult{Status: "quarantine", Hazard: hz}, nil
|
||||
}
|
||||
if c.Kind == KindSnapshot {
|
||||
return ProcessResult{Status: "done"}, nil
|
||||
}
|
||||
ing := &Ingest{Env: env}
|
||||
var err error
|
||||
switch c.Kind {
|
||||
case KindAgentCommit:
|
||||
err = ing.AgentCommitTX(ctx, tx, c.ProjectID, c.LogicalID, nz(c.EventID, fmt.Sprintf("commit:%d", c.SourcePK)), c.SnapshotID, c.Payload)
|
||||
case KindApproved:
|
||||
err = ing.ApprovedTX(ctx, tx, c.ProjectID, c.LogicalID, c.SnapshotID, c.ApprovedAt, c.Payload)
|
||||
case KindChatTurn:
|
||||
policy := nz(c.ExportPolicy, "full")
|
||||
err = ing.requestTX(ctx, tx, AssistantFacts("completed", c.CompletedAt, c.MessageID, policy), PublishRequest{
|
||||
ArtifactType: TypeAssistantReply, Environment: env, ProjectID: c.ProjectID,
|
||||
LogicalResourceID: nz(c.LogicalID, fmt.Sprintf("turn:%d", c.SourcePK)),
|
||||
SourceEventKind: "assistant_completed", SourceEventID: nz(c.EventID, fmt.Sprintf("turn:%d", c.SourcePK)),
|
||||
SourceMessageID: c.MessageID, SourceRunID: c.RunID, Payload: c.Payload, TerminalState: "completed",
|
||||
})
|
||||
default:
|
||||
return ProcessResult{}, fmt.Errorf("artifactvault: unsupported backfill kind %q", c.Kind)
|
||||
}
|
||||
if err != nil {
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
return ProcessResult{Status: "done"}, nil
|
||||
}
|
||||
|
||||
type rowQuerier interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
}
|
||||
|
||||
func Enumerate(ctx context.Context, q rowQuerier, kind string, lastPK int64, limit int) ([]Candidate, error) {
|
||||
sql, err := EnumeratorSQL(kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > BackfillBatchSize {
|
||||
limit = BackfillBatchSize
|
||||
}
|
||||
rows, err := q.Query(ctx, sql, lastPK, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Candidate, 0, limit)
|
||||
for rows.Next() {
|
||||
c, err := scanCandidate(kind, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanCandidate(kind string, row scannable) (Candidate, error) {
|
||||
var c Candidate
|
||||
c.Kind = kind
|
||||
switch kind {
|
||||
case KindSnapshot:
|
||||
var srcKind string
|
||||
if err := row.Scan(&c.SourcePK, &c.LogicalID, &c.PayloadSHA, &c.Payload, &srcKind); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.SchemaVer = 1
|
||||
if len(c.Payload) == 0 {
|
||||
c.SchemaVer = 0
|
||||
}
|
||||
_ = srcKind
|
||||
case KindAgentCommit:
|
||||
if err := row.Scan(&c.SourcePK, &c.LogicalID, &c.SnapshotID, &c.EventID, &c.Payload, &c.PayloadSHA); err != nil {
|
||||
return c, err
|
||||
}
|
||||
case KindApproved:
|
||||
if err := row.Scan(&c.SourcePK, &c.LogicalID, &c.ProjectID, &c.ApprovedAt, &c.LedgerSHA, &c.HeadSnapshotID, &c.ProjectionSHA, &c.Payload); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.SnapshotID = c.HeadSnapshotID
|
||||
case KindChatTurn:
|
||||
var session string
|
||||
if err := row.Scan(&c.SourcePK, &c.ProjectID, &c.MessageID, &c.CompletedAt, &session, &c.Payload); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.LogicalID = session
|
||||
c.EventID = fmt.Sprintf("turn:%d", c.SourcePK)
|
||||
c.ExportPolicy = "full"
|
||||
default:
|
||||
return c, fmt.Errorf("artifactvault: scan %s", kind)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package artifactvault
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestForbidWorkspaceScan(t *testing.T) {
|
||||
if err := ForbidWorkspaceScan("SELECT work_dir FROM document_records"); err == nil {
|
||||
t.Fatal("work_dir must be rejected")
|
||||
}
|
||||
if err := ForbidWorkspaceScan("SELECT path FROM workspaces/foo"); err == nil {
|
||||
t.Fatal("workspaces/ must be rejected")
|
||||
}
|
||||
if err := ForbidWorkspaceScan(SQLSnapshots + SQLAgentCommits + SQLApproved + SQLChatTurns); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnumeratorSQLCoversFourKindsOnly(t *testing.T) {
|
||||
for _, kind := range []string{KindSnapshot, KindAgentCommit, KindApproved, KindChatTurn} {
|
||||
sql, err := EnumeratorSQL(kind)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(sql), "workspace") {
|
||||
t.Fatalf("%s leaked workspace: %s", kind, sql)
|
||||
}
|
||||
}
|
||||
if _, err := EnumeratorSQL("workspace_dir"); err == nil {
|
||||
t.Fatal("unknown kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHazardMatrix(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
okBody := []byte("# approved body for vault")
|
||||
cases := []struct {
|
||||
name string
|
||||
c Candidate
|
||||
want string
|
||||
}{
|
||||
{"commit ok", Candidate{Kind: KindAgentCommit, SnapshotID: 3, Payload: okBody}, HazardNone},
|
||||
{"missing snapshot", Candidate{Kind: KindAgentCommit, SnapshotID: 0, Payload: okBody}, HazardMissingSnapshot},
|
||||
{"legacy v0", Candidate{Kind: KindSnapshot, PayloadSHA: "", Payload: nil}, HazardMissingSnapshot},
|
||||
{"legacy empty schema", Candidate{Kind: KindSnapshot, PayloadSHA: "abc", Payload: okBody, SchemaVer: 0}, HazardLegacyV0},
|
||||
{"high id", Candidate{Kind: KindApproved, SourcePK: HighIDThreshold + 1, SnapshotID: 1, Payload: okBody, ApprovedAt: &now}, HazardHighID},
|
||||
{"old schema", Candidate{Kind: KindApproved, SourceSchema: 12, SnapshotID: 1, Payload: okBody, ApprovedAt: &now}, HazardOldSchema},
|
||||
{"fork", Candidate{Kind: KindApproved, SnapshotID: 4, HeadSnapshotID: 9, LedgerSHA: "aa", ProjectionSHA: "bb", Payload: okBody, ApprovedAt: &now}, HazardProjectionFork},
|
||||
{"workspace path", Candidate{Kind: KindApproved, WorkDir: "data/dev/workspaces/x", SnapshotID: 1, Payload: okBody}, HazardWorkspacePath},
|
||||
{"secret", Candidate{Kind: KindChatTurn, MessageID: 1, CompletedAt: &now, Payload: []byte("api_key=secret")}, HazardSensitivePayload},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ClassifyHazard(tc.c); got != tc.want {
|
||||
t.Fatalf("%s: got %q want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCandidateQuarantinesWorkspaceAndSecrets(t *testing.T) {
|
||||
// ProcessCandidate without a real tx is exercised via hazard classification and
|
||||
// memory ingest in dataset tests. Guard the refuse path here.
|
||||
c := Candidate{Kind: KindAgentCommit, WorkDir: "workspaces/p/output.md", SnapshotID: 1, Payload: []byte("ok body here")}
|
||||
if ClassifyHazard(c) != HazardWorkspacePath {
|
||||
t.Fatal("workspace candidate must be classified before ingest")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user