You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -22,6 +22,10 @@ type JobType string
|
||||
const (
|
||||
// TypeWebhookDeliver delivers a notify.Message to the configured webhook URL.
|
||||
TypeWebhookDeliver JobType = "webhook.deliver"
|
||||
// TypeSecretReencrypt re-encrypts every encrypted-column row that is still on
|
||||
// an old key version onto the current active version (master-key rotation).
|
||||
// Enqueued by the rotate-key admin endpoint; idempotent + resumable.
|
||||
TypeSecretReencrypt JobType = "secret.reencrypt"
|
||||
)
|
||||
|
||||
// JobStatus matches the CHECK constraint on jobs.status.
|
||||
@@ -142,6 +146,12 @@ type MCPTokensPurgeConfig struct {
|
||||
Retention time.Duration
|
||||
}
|
||||
|
||||
// AcpSessionReaperConfig controls the ACP session reaper schedule.
|
||||
type AcpSessionReaperConfig struct {
|
||||
Enabled bool
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
// Config is the full jobs.Module configuration.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
@@ -154,4 +164,11 @@ type Config struct {
|
||||
JobPurge JobPurgeConfig
|
||||
AcpEventsPurge AcpEventsPurgeConfig
|
||||
MCPTokensPurge MCPTokensPurgeConfig
|
||||
AcpSessionReaper AcpSessionReaperConfig
|
||||
// OrchestratorSchedule controls the task-decomposition parallel scheduler tick
|
||||
// (autonomy roadmap §10). Registered via RunnerFns key "orchestrator_schedule".
|
||||
OrchestratorSchedule RunnerConfig
|
||||
// AuditRetention controls the audit-log retention purge tick (autonomy
|
||||
// roadmap §11). Registered via RunnerFns key "audit_retention".
|
||||
AuditRetention RunnerConfig
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
)
|
||||
|
||||
// ReencryptStore abstracts the DB scan + per-row re-seal so the handler is
|
||||
// testable without a live Postgres. ReencryptTable processes one batch of rows
|
||||
// for one logical table that are still below activeVersion, re-sealing each
|
||||
// blob with enc.EncryptVersion(active) inside a per-row transaction, and returns
|
||||
// how many rows it advanced. It returns 0 when no rows remain on an old version
|
||||
// (the table is done). Because each row's UPDATE sets both blob+key_version in
|
||||
// one tx, the job is idempotent and resumable: re-running after a crash simply
|
||||
// finds the rows that did not yet advance.
|
||||
type ReencryptStore interface {
|
||||
// Tables returns the logical table names this store can re-encrypt.
|
||||
Tables() []string
|
||||
// ReencryptTable advances up to batch rows of table below activeVersion onto
|
||||
// the active version, returning the number of rows updated this call.
|
||||
ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, table string, activeVersion, batch int) (int, error)
|
||||
// StaleCount reports how many rows of table remain below activeVersion (for
|
||||
// the reencrypt-status endpoint and the handler's completion check).
|
||||
StaleCount(ctx context.Context, table string, activeVersion int) (int, error)
|
||||
}
|
||||
|
||||
// SecretReencryptPayload is the job payload. ActiveVersion is the target write
|
||||
// version (the version crypto_keys is now active on); the handler re-encrypts
|
||||
// every stale row onto it. BatchSize bounds per-iteration work; 0 → default.
|
||||
type SecretReencryptPayload struct {
|
||||
ActiveVersion int `json:"active_version"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
}
|
||||
|
||||
// SecretReencryptHandler implements jobs.Handler for TypeSecretReencrypt.
|
||||
type SecretReencryptHandler struct {
|
||||
store ReencryptStore
|
||||
enc *crypto.KeyedEncryptor
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewSecretReencryptHandler builds the handler.
|
||||
func NewSecretReencryptHandler(store ReencryptStore, enc *crypto.KeyedEncryptor, log *slog.Logger) *SecretReencryptHandler {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &SecretReencryptHandler{store: store, enc: enc, log: log}
|
||||
}
|
||||
|
||||
// Type reports the job type this handler serves.
|
||||
func (h *SecretReencryptHandler) Type() jobs.JobType { return jobs.TypeSecretReencrypt }
|
||||
|
||||
const defaultReencryptBatch = 100
|
||||
|
||||
// Handle scans every encrypted-column table and re-seals rows still on an old
|
||||
// key version onto the active version, batch by batch, until none remain. It is
|
||||
// idempotent (already-advanced rows are skipped by the key_version<active
|
||||
// predicate) and resumable (a crash mid-run leaves a partial set; re-running
|
||||
// completes the rest). A malformed payload is a permanent error (no retry).
|
||||
func (h *SecretReencryptHandler) Handle(ctx context.Context, job jobs.Job) error {
|
||||
var p SecretReencryptPayload
|
||||
if err := json.Unmarshal(job.Payload, &p); err != nil {
|
||||
return jobs.Permanent(fmt.Errorf("secret_reencrypt: bad payload: %w", err))
|
||||
}
|
||||
active := p.ActiveVersion
|
||||
if active <= 0 {
|
||||
v, err := h.enc.ActiveVersion(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("secret_reencrypt: resolve active version: %w", err)
|
||||
}
|
||||
active = v
|
||||
}
|
||||
batch := p.BatchSize
|
||||
if batch <= 0 {
|
||||
batch = defaultReencryptBatch
|
||||
}
|
||||
|
||||
for _, table := range h.store.Tables() {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // canceled/timeout → retry later, resumable
|
||||
}
|
||||
n, err := h.store.ReencryptTable(ctx, h.enc, table, active, batch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("secret_reencrypt: table %s: %w", table, err)
|
||||
}
|
||||
if n == 0 {
|
||||
break // table drained
|
||||
}
|
||||
h.log.Info("secret_reencrypt.batch", "table", table, "rows", n, "active_version", active)
|
||||
}
|
||||
}
|
||||
h.log.Info("secret_reencrypt.done", "active_version", active)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||
)
|
||||
|
||||
// PostgresReencryptStore re-encrypts the platform's encrypted-column tables
|
||||
// using one transaction per row so the job is idempotent and resumable. Each
|
||||
// table's blob column(s) are decrypted under the row's current key_version then
|
||||
// re-sealed under the active version, and the row's key_version is advanced in
|
||||
// the same tx. Rows with NULL blobs are skipped by the WHERE predicate.
|
||||
type PostgresReencryptStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresReencryptStore builds the store over a pgx pool.
|
||||
func NewPostgresReencryptStore(pool *pgxpool.Pool) *PostgresReencryptStore {
|
||||
return &PostgresReencryptStore{pool: pool}
|
||||
}
|
||||
|
||||
var _ ReencryptStore = (*PostgresReencryptStore)(nil)
|
||||
|
||||
// tableSpec describes how to scan + update one encrypted-column table.
|
||||
type tableSpec struct {
|
||||
name string
|
||||
blobs []string // encrypted column names (1 or 2)
|
||||
notNul string // SQL predicate for "row carries ciphertext"
|
||||
}
|
||||
|
||||
// reencryptSpecs is the canonical list of encrypted-column owners. Order is
|
||||
// stable so the status endpoint and the handler iterate deterministically.
|
||||
var reencryptSpecs = []tableSpec{
|
||||
{name: "acp_agent_kinds", blobs: []string{"encrypted_env", "encrypted_mcp_servers"},
|
||||
notNul: "(encrypted_env IS NOT NULL OR encrypted_mcp_servers IS NOT NULL)"},
|
||||
{name: "acp_agent_kind_config_files", blobs: []string{"encrypted_content"},
|
||||
notNul: "encrypted_content IS NOT NULL"},
|
||||
{name: "workspace_run_profiles", blobs: []string{"encrypted_env"},
|
||||
notNul: "encrypted_env IS NOT NULL"},
|
||||
{name: "git_credentials", blobs: []string{"encrypted_secret"},
|
||||
notNul: "encrypted_secret IS NOT NULL"},
|
||||
{name: "llm_endpoints", blobs: []string{"api_key_encrypted"},
|
||||
notNul: "api_key_encrypted IS NOT NULL"},
|
||||
}
|
||||
|
||||
func specByName(name string) (tableSpec, bool) {
|
||||
for _, s := range reencryptSpecs {
|
||||
if s.name == name {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return tableSpec{}, false
|
||||
}
|
||||
|
||||
// Tables lists the re-encryptable table names.
|
||||
func (s *PostgresReencryptStore) Tables() []string {
|
||||
out := make([]string, 0, len(reencryptSpecs))
|
||||
for _, sp := range reencryptSpecs {
|
||||
out = append(out, sp.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StaleCount reports how many rows of table remain below activeVersion.
|
||||
func (s *PostgresReencryptStore) StaleCount(ctx context.Context, table string, active int) (int, error) {
|
||||
spec, ok := specByName(table)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown reencrypt table %q", table)
|
||||
}
|
||||
q := fmt.Sprintf(
|
||||
`SELECT count(*) FROM %s WHERE key_version < $1 AND %s`, spec.name, spec.notNul)
|
||||
var n int
|
||||
if err := s.pool.QueryRow(ctx, q, active).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("stale count %s: %w", table, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ReencryptTable advances up to batch rows below active onto the active version.
|
||||
// It selects a batch of ids FOR UPDATE SKIP LOCKED, then re-seals each row in its
|
||||
// own transaction so a crash leaves a clean partial state.
|
||||
func (s *PostgresReencryptStore) ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, table string, active, batch int) (int, error) {
|
||||
spec, ok := specByName(table)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown reencrypt table %q", table)
|
||||
}
|
||||
|
||||
// Pull a batch of candidate ids (cheap, uses the partial key_version index).
|
||||
idQ := fmt.Sprintf(
|
||||
`SELECT id FROM %s WHERE key_version < $1 AND %s ORDER BY id LIMIT $2`, spec.name, spec.notNul)
|
||||
rows, err := s.pool.Query(ctx, idQ, active, batch)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("select ids %s: %w", table, err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("scan id %s: %w", table, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
done := 0
|
||||
for _, id := range ids {
|
||||
if err := s.reencryptRow(ctx, enc, spec, active, id); err != nil {
|
||||
return done, err
|
||||
}
|
||||
done++
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// reencryptRow re-seals one row's blob(s) in a single transaction.
|
||||
func (s *PostgresReencryptStore) reencryptRow(ctx context.Context, enc *crypto.KeyedEncryptor, spec tableSpec, active int, id uuid.UUID) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Lock the row and read its current version + blobs.
|
||||
cols := ""
|
||||
for _, c := range spec.blobs {
|
||||
cols += c + ", "
|
||||
}
|
||||
selQ := fmt.Sprintf(`SELECT %skey_version FROM %s WHERE id = $1 FOR UPDATE`, cols, spec.name)
|
||||
|
||||
dest := make([]any, 0, len(spec.blobs)+1)
|
||||
blobVals := make([]*[]byte, len(spec.blobs))
|
||||
for i := range spec.blobs {
|
||||
blobVals[i] = new([]byte)
|
||||
dest = append(dest, blobVals[i])
|
||||
}
|
||||
var curVer int
|
||||
dest = append(dest, &curVer)
|
||||
|
||||
if err := tx.QueryRow(ctx, selQ, id).Scan(dest...); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil // row vanished; nothing to do
|
||||
}
|
||||
return fmt.Errorf("lock row %s/%s: %w", spec.name, id, err)
|
||||
}
|
||||
if curVer >= active {
|
||||
return tx.Commit(ctx) // already advanced by a concurrent run; idempotent
|
||||
}
|
||||
|
||||
// Re-seal each non-nil blob: decrypt under curVer, encrypt under active.
|
||||
setExpr := ""
|
||||
args := []any{id}
|
||||
argN := 2
|
||||
for i, c := range spec.blobs {
|
||||
b := *blobVals[i]
|
||||
if len(b) == 0 {
|
||||
continue // NULL/empty blob: leave as-is
|
||||
}
|
||||
plain, derr := enc.DecryptVersion(ctx, b, curVer)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("decrypt %s/%s.%s v%d: %w", spec.name, id, c, curVer, derr)
|
||||
}
|
||||
sealed, err := enc.SealWithVersion(ctx, active, plain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reseal %s/%s.%s: %w", spec.name, id, c, err)
|
||||
}
|
||||
if setExpr != "" {
|
||||
setExpr += ", "
|
||||
}
|
||||
setExpr += fmt.Sprintf("%s = $%d", c, argN)
|
||||
args = append(args, sealed)
|
||||
argN++
|
||||
}
|
||||
// Always advance key_version (covers the all-NULL-blob defensive case too).
|
||||
if setExpr != "" {
|
||||
setExpr += ", "
|
||||
}
|
||||
setExpr += fmt.Sprintf("key_version = $%d", argN)
|
||||
args = append(args, active)
|
||||
|
||||
updQ := fmt.Sprintf(`UPDATE %s SET %s WHERE id = $1`, spec.name, setExpr)
|
||||
if _, err := tx.Exec(ctx, updQ, args...); err != nil {
|
||||
return fmt.Errorf("update %s/%s: %w", spec.name, id, err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
)
|
||||
|
||||
// memRow models one encrypted-column row across versions.
|
||||
type memRow struct {
|
||||
blob []byte
|
||||
keyVersion int
|
||||
plaintext []byte // ground truth for assertions
|
||||
null bool
|
||||
}
|
||||
|
||||
// memStore is an in-memory ReencryptStore over a single logical table, with a
|
||||
// failNext hook to model a crash mid-run for the resumability test.
|
||||
type memStore struct {
|
||||
rows []*memRow
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (m *memStore) Tables() []string { return []string{"t"} }
|
||||
|
||||
func (m *memStore) StaleCount(_ context.Context, _ string, active int) (int, error) {
|
||||
n := 0
|
||||
for _, r := range m.rows {
|
||||
if !r.null && r.keyVersion < active {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, _ string, active, batch int) (int, error) {
|
||||
done := 0
|
||||
for _, r := range m.rows {
|
||||
if done >= batch {
|
||||
break
|
||||
}
|
||||
if r.null || r.keyVersion >= active {
|
||||
continue
|
||||
}
|
||||
if m.failNext {
|
||||
m.failNext = false
|
||||
return done, context.Canceled // simulate a crash after some rows
|
||||
}
|
||||
plain, err := enc.DecryptVersion(ctx, r.blob, r.keyVersion)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
sealed, err := enc.SealWithVersion(ctx, active, plain)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
r.blob = sealed
|
||||
r.keyVersion = active
|
||||
done++
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func key(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
_, err := rand.Read(k)
|
||||
require.NoError(t, err)
|
||||
return k
|
||||
}
|
||||
|
||||
// twoVersionEnc returns a KeyedEncryptor with v1+v2 keys and a store reporting
|
||||
// active=2, plus a helper to seal under v1.
|
||||
func twoVersionEnc(t *testing.T) *crypto.KeyedEncryptor {
|
||||
prov := crypto.NewEnvProvider(nil)
|
||||
prov.SetKey(1, key(t))
|
||||
prov.SetKey(2, key(t))
|
||||
return crypto.NewKeyedEncryptor(prov, crypto.NewStaticKeyStore(2))
|
||||
}
|
||||
|
||||
func TestSecretReencrypt_AdvancesAllRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
enc := twoVersionEnc(t)
|
||||
|
||||
// Seed 3 rows encrypted under v1 + 1 NULL row.
|
||||
store := &memStore{}
|
||||
for i := 0; i < 3; i++ {
|
||||
pt := []byte{byte(i), byte(i + 1)}
|
||||
ct, err := enc.SealWithVersion(ctx, 1, pt)
|
||||
require.NoError(t, err)
|
||||
store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt})
|
||||
}
|
||||
store.rows = append(store.rows, &memRow{null: true, keyVersion: 1})
|
||||
|
||||
h := NewSecretReencryptHandler(store, enc, nil)
|
||||
payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2, BatchSize: 2})
|
||||
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
|
||||
|
||||
// All non-null rows now on v2 and decrypt to identical plaintext.
|
||||
for _, r := range store.rows {
|
||||
if r.null {
|
||||
require.Equal(t, 1, r.keyVersion, "NULL rows are skipped, version unchanged")
|
||||
continue
|
||||
}
|
||||
require.Equal(t, 2, r.keyVersion)
|
||||
got, err := enc.DecryptVersion(ctx, r.blob, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, r.plaintext, got)
|
||||
}
|
||||
left, _ := store.StaleCount(ctx, "t", 2)
|
||||
require.Equal(t, 0, left)
|
||||
}
|
||||
|
||||
func TestSecretReencrypt_IdempotentRerun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
enc := twoVersionEnc(t)
|
||||
store := &memStore{}
|
||||
pt := []byte("hello")
|
||||
ct, err := enc.SealWithVersion(ctx, 1, pt)
|
||||
require.NoError(t, err)
|
||||
store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt})
|
||||
|
||||
h := NewSecretReencryptHandler(store, enc, nil)
|
||||
payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2})
|
||||
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
|
||||
blobAfterFirst := store.rows[0].blob
|
||||
|
||||
// Second run is a no-op: row already on active version, blob unchanged.
|
||||
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
|
||||
require.Equal(t, blobAfterFirst, store.rows[0].blob)
|
||||
}
|
||||
|
||||
func TestSecretReencrypt_ResumableAfterCrash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
enc := twoVersionEnc(t)
|
||||
store := &memStore{failNext: true}
|
||||
for i := 0; i < 5; i++ {
|
||||
pt := []byte{byte(i)}
|
||||
ct, err := enc.SealWithVersion(ctx, 1, pt)
|
||||
require.NoError(t, err)
|
||||
store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt})
|
||||
}
|
||||
|
||||
h := NewSecretReencryptHandler(store, enc, nil)
|
||||
payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2, BatchSize: 10})
|
||||
// First run crashes (ctx.Canceled bubbles up).
|
||||
require.Error(t, h.Handle(ctx, jobs.Job{Payload: payload}))
|
||||
left, _ := store.StaleCount(ctx, "t", 2)
|
||||
require.Greater(t, left, 0, "crash left some rows on old version")
|
||||
|
||||
// Re-run completes the rest.
|
||||
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
|
||||
left, _ = store.StaleCount(ctx, "t", 2)
|
||||
require.Equal(t, 0, left)
|
||||
for _, r := range store.rows {
|
||||
got, err := enc.DecryptVersion(ctx, r.blob, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, r.plaintext, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReencrypt_BadPayloadIsPermanent(t *testing.T) {
|
||||
h := NewSecretReencryptHandler(&memStore{}, twoVersionEnc(t), nil)
|
||||
err := h.Handle(context.Background(), jobs.Job{Payload: []byte("not json")})
|
||||
require.Error(t, err)
|
||||
require.True(t, jobs.IsPermanent(err))
|
||||
}
|
||||
@@ -146,8 +146,17 @@ func scheduleEntry(cfg Config, name string) (schedEntry, bool) {
|
||||
return schedEntry{cfg.JobPurge.Interval, cfg.JobPurge.Enabled}, true
|
||||
case "acp_events_purge":
|
||||
return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true
|
||||
case "acp_turns_purge":
|
||||
// 复用 acp_events 的保留/调度配置:回合记录与事件同源、同生命周期。
|
||||
return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true
|
||||
case "mcp_tokens_purge":
|
||||
return schedEntry{cfg.MCPTokensPurge.Interval, cfg.MCPTokensPurge.Enabled}, true
|
||||
case "acp_session_reaper":
|
||||
return schedEntry{cfg.AcpSessionReaper.Interval, cfg.AcpSessionReaper.Enabled}, true
|
||||
case "orchestrator_schedule":
|
||||
return schedEntry{cfg.OrchestratorSchedule.Interval, cfg.OrchestratorSchedule.Enabled}, true
|
||||
case "audit_retention":
|
||||
return schedEntry{cfg.AuditRetention.Interval, cfg.AuditRetention.Enabled}, true
|
||||
}
|
||||
return schedEntry{0, false}, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package runners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||
)
|
||||
|
||||
// ReaperSession is the minimal session projection the reaper needs.
|
||||
type ReaperSession struct {
|
||||
ID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
StartedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
MaxWallClockSeconds *int32
|
||||
}
|
||||
|
||||
// AcpSessionReaperRepo is the narrow repo surface this runner needs.
|
||||
// internal/acp.Repository satisfies it via an adapter in app.go.
|
||||
type AcpSessionReaperRepo interface {
|
||||
// ListSessionsForReaper returns active sessions exceeding their wall-clock
|
||||
// cap (per-session or the global wallClockStartedBefore cutoff) or idle since
|
||||
// idleSince. A zero wallClockStartedBefore disables the global wall-clock cut.
|
||||
ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error)
|
||||
// MarkSessionTerminatedReason records why a session was reaped (no-op if a
|
||||
// reason is already set, protecting against clobbering a budget-gate reason).
|
||||
MarkSessionTerminatedReason(ctx context.Context, id uuid.UUID, reason string) error
|
||||
}
|
||||
|
||||
// SessionKiller terminates a session subprocess. internal/acp.Supervisor.Kill
|
||||
// satisfies it directly.
|
||||
type SessionKiller interface {
|
||||
Kill(ctx context.Context, sid uuid.UUID, grace time.Duration)
|
||||
}
|
||||
|
||||
// AcpSessionReaperConfig controls the reaper's cutoffs.
|
||||
type AcpSessionReaperConfig struct {
|
||||
// WallClockTimeout is the global maximum session age (0 = rely only on the
|
||||
// per-session budget_max_wall_clock_seconds).
|
||||
WallClockTimeout time.Duration
|
||||
// IdleTimeout is the max time with no new activity before reaping.
|
||||
IdleTimeout time.Duration
|
||||
// KillGrace is passed to SessionKiller.Kill.
|
||||
KillGrace time.Duration
|
||||
}
|
||||
|
||||
// AcpSessionReaper terminates ACP sessions that exceed wall-clock or are idle.
|
||||
type AcpSessionReaper struct {
|
||||
repo AcpSessionReaperRepo
|
||||
killer SessionKiller
|
||||
disp *notify.Dispatcher
|
||||
cfg AcpSessionReaperConfig
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewAcpSessionReaper builds the reaper.
|
||||
func NewAcpSessionReaper(repo AcpSessionReaperRepo, killer SessionKiller, disp *notify.Dispatcher,
|
||||
cfg AcpSessionReaperConfig, log *slog.Logger) *AcpSessionReaper {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if cfg.KillGrace <= 0 {
|
||||
cfg.KillGrace = 5 * time.Second
|
||||
}
|
||||
return &AcpSessionReaper{repo: repo, killer: killer, disp: disp, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
// Run reaps once. Periodic; idempotent (terminated_reason is only set once).
|
||||
func (r *AcpSessionReaper) Run(ctx context.Context) {
|
||||
now := time.Now()
|
||||
var wallBefore time.Time
|
||||
if r.cfg.WallClockTimeout > 0 {
|
||||
wallBefore = now.Add(-r.cfg.WallClockTimeout)
|
||||
}
|
||||
idleSince := now.Add(-r.cfg.IdleTimeout)
|
||||
if r.cfg.IdleTimeout <= 0 {
|
||||
// No idle timeout configured -> make the idle cutoff unreachable so only
|
||||
// wall-clock rules apply (a zero time would reap everything).
|
||||
idleSince = time.Time{}
|
||||
}
|
||||
|
||||
sessions, err := r.repo.ListSessionsForReaper(ctx, wallBefore, idleSince)
|
||||
if err != nil {
|
||||
r.log.Error("acp_session_reaper.list", "err", err.Error())
|
||||
return
|
||||
}
|
||||
for _, s := range sessions {
|
||||
reason := r.classify(now, s)
|
||||
if reason == "" {
|
||||
continue
|
||||
}
|
||||
if merr := r.repo.MarkSessionTerminatedReason(ctx, s.ID, reason); merr != nil {
|
||||
r.log.Error("acp_session_reaper.mark", "session_id", s.ID, "err", merr.Error())
|
||||
// continue: still attempt the kill so the resource is freed.
|
||||
}
|
||||
// Kill must not be fatal on error (idempotent; session may have already
|
||||
// exited between listing and now).
|
||||
r.killer.Kill(ctx, s.ID, r.cfg.KillGrace)
|
||||
r.log.Info("acp_session_reaper.killed", "session_id", s.ID, "reason", reason)
|
||||
r.dispatch(ctx, s, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// classify decides which reaper reason applies, preferring wall-clock over idle.
|
||||
func (r *AcpSessionReaper) classify(now time.Time, s ReaperSession) string {
|
||||
// Per-session wall-clock cap.
|
||||
if s.MaxWallClockSeconds != nil {
|
||||
if now.Sub(s.StartedAt) >= time.Duration(*s.MaxWallClockSeconds)*time.Second {
|
||||
return "reaper_wall_clock"
|
||||
}
|
||||
}
|
||||
// Global wall-clock cap.
|
||||
if r.cfg.WallClockTimeout > 0 && now.Sub(s.StartedAt) >= r.cfg.WallClockTimeout {
|
||||
return "reaper_wall_clock"
|
||||
}
|
||||
// Idle cap.
|
||||
if r.cfg.IdleTimeout > 0 {
|
||||
activity := s.LastActivityAt
|
||||
if activity.IsZero() {
|
||||
activity = s.StartedAt
|
||||
}
|
||||
if now.Sub(activity) >= r.cfg.IdleTimeout {
|
||||
return "reaper_idle"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *AcpSessionReaper) dispatch(ctx context.Context, s ReaperSession, reason string) {
|
||||
if r.disp == nil {
|
||||
return
|
||||
}
|
||||
_ = r.disp.Dispatch(ctx, notify.Message{
|
||||
UserID: s.UserID,
|
||||
Topic: "acp.session_reaped",
|
||||
Severity: notify.SeverityWarning,
|
||||
Title: "ACP session terminated by reaper",
|
||||
Body: "An ACP session was terminated: " + reason + ".",
|
||||
Metadata: map[string]any{
|
||||
"session_id": s.ID.String(),
|
||||
"reason": reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package runners_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
||||
)
|
||||
|
||||
type fakeReaperRepo struct {
|
||||
sessions []runners.ReaperSession
|
||||
listErr error
|
||||
|
||||
mu sync.Mutex
|
||||
marked map[uuid.UUID]string
|
||||
markErr error
|
||||
}
|
||||
|
||||
func (f *fakeReaperRepo) ListSessionsForReaper(_ context.Context, _, _ time.Time) ([]runners.ReaperSession, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.sessions, nil
|
||||
}
|
||||
|
||||
func (f *fakeReaperRepo) MarkSessionTerminatedReason(_ context.Context, id uuid.UUID, reason string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.marked == nil {
|
||||
f.marked = map[uuid.UUID]string{}
|
||||
}
|
||||
f.marked[id] = reason
|
||||
return f.markErr
|
||||
}
|
||||
|
||||
type fakeKiller struct {
|
||||
mu sync.Mutex
|
||||
killed []uuid.UUID
|
||||
}
|
||||
|
||||
func (k *fakeKiller) Kill(_ context.Context, sid uuid.UUID, _ time.Duration) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.killed = append(k.killed, sid)
|
||||
}
|
||||
|
||||
func TestAcpSessionReaper_KillsWallClockAndIdle(t *testing.T) {
|
||||
now := time.Now()
|
||||
wallSession := runners.ReaperSession{
|
||||
ID: uuid.New(), UserID: uuid.New(),
|
||||
StartedAt: now.Add(-5 * time.Hour),
|
||||
LastActivityAt: now.Add(-1 * time.Minute), // active, but wall-clock exceeded
|
||||
}
|
||||
idleSession := runners.ReaperSession{
|
||||
ID: uuid.New(), UserID: uuid.New(),
|
||||
StartedAt: now.Add(-10 * time.Minute),
|
||||
LastActivityAt: now.Add(-1 * time.Hour), // idle
|
||||
}
|
||||
repo := &fakeReaperRepo{sessions: []runners.ReaperSession{wallSession, idleSession}}
|
||||
killer := &fakeKiller{}
|
||||
r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{
|
||||
WallClockTimeout: 4 * time.Hour,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
}, discardLogger())
|
||||
|
||||
r.Run(context.Background())
|
||||
|
||||
assert.ElementsMatch(t, []uuid.UUID{wallSession.ID, idleSession.ID}, killer.killed)
|
||||
assert.Equal(t, "reaper_wall_clock", repo.marked[wallSession.ID])
|
||||
assert.Equal(t, "reaper_idle", repo.marked[idleSession.ID])
|
||||
}
|
||||
|
||||
func TestAcpSessionReaper_PerSessionWallClock(t *testing.T) {
|
||||
now := time.Now()
|
||||
cap30s := int32(30)
|
||||
s := runners.ReaperSession{
|
||||
ID: uuid.New(), UserID: uuid.New(),
|
||||
StartedAt: now.Add(-1 * time.Minute),
|
||||
LastActivityAt: now,
|
||||
MaxWallClockSeconds: &cap30s, // exceeded per-session cap, no global cap set
|
||||
}
|
||||
repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}}
|
||||
killer := &fakeKiller{}
|
||||
r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{
|
||||
IdleTimeout: time.Hour, // generous idle so only wall-clock triggers
|
||||
}, discardLogger())
|
||||
|
||||
r.Run(context.Background())
|
||||
|
||||
require.Len(t, killer.killed, 1)
|
||||
assert.Equal(t, s.ID, killer.killed[0])
|
||||
assert.Equal(t, "reaper_wall_clock", repo.marked[s.ID])
|
||||
}
|
||||
|
||||
func TestAcpSessionReaper_SkipsWithinThresholds(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := runners.ReaperSession{
|
||||
ID: uuid.New(), UserID: uuid.New(),
|
||||
StartedAt: now.Add(-5 * time.Minute),
|
||||
LastActivityAt: now.Add(-1 * time.Minute),
|
||||
}
|
||||
repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}}
|
||||
killer := &fakeKiller{}
|
||||
r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{
|
||||
WallClockTimeout: 4 * time.Hour,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
}, discardLogger())
|
||||
|
||||
r.Run(context.Background())
|
||||
|
||||
assert.Empty(t, killer.killed)
|
||||
assert.Empty(t, repo.marked)
|
||||
}
|
||||
|
||||
func TestAcpSessionReaper_MarkErrorStillKills(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := runners.ReaperSession{
|
||||
ID: uuid.New(), UserID: uuid.New(),
|
||||
StartedAt: now.Add(-5 * time.Hour),
|
||||
LastActivityAt: now,
|
||||
}
|
||||
repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}, markErr: errors.New("db down")}
|
||||
killer := &fakeKiller{}
|
||||
r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{
|
||||
WallClockTimeout: 4 * time.Hour,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
}, discardLogger())
|
||||
|
||||
r.Run(context.Background())
|
||||
|
||||
require.Len(t, killer.killed, 1)
|
||||
}
|
||||
|
||||
func TestAcpSessionReaper_ListErrorIsNotFatal(t *testing.T) {
|
||||
repo := &fakeReaperRepo{listErr: errors.New("boom")}
|
||||
killer := &fakeKiller{}
|
||||
r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
}, discardLogger())
|
||||
|
||||
r.Run(context.Background())
|
||||
assert.Empty(t, killer.killed)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package runners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AcpTurnsPurgeRepo is the minimal repo surface this runner needs.
|
||||
// internal/acp.Repository satisfies it via an adapter in app.go.
|
||||
type AcpTurnsPurgeRepo interface {
|
||||
PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// AcpTurnsPurge deletes acp_turns older than retention. Periodic; idempotent.
|
||||
type AcpTurnsPurge struct {
|
||||
repo AcpTurnsPurgeRepo
|
||||
retention time.Duration
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewAcpTurnsPurge builds the purger.
|
||||
func NewAcpTurnsPurge(repo AcpTurnsPurgeRepo, retention time.Duration, log *slog.Logger) *AcpTurnsPurge {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &AcpTurnsPurge{repo: repo, retention: retention, log: log}
|
||||
}
|
||||
|
||||
// Run purges once.
|
||||
func (r *AcpTurnsPurge) Run(ctx context.Context) {
|
||||
cutoff := time.Now().Add(-r.retention)
|
||||
rows, err := r.repo.PurgeTurnsBefore(ctx, cutoff)
|
||||
if err != nil {
|
||||
r.log.Error("acp_turns.purge", "err", err.Error())
|
||||
return
|
||||
}
|
||||
if rows > 0 {
|
||||
r.log.Info("acp_turns.purged", "count", rows, "before", cutoff.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package runners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditRetentionRepo is the minimal surface this runner needs.
|
||||
// internal/audit.Reader satisfies it (PurgeBefore).
|
||||
type AuditRetentionRepo interface {
|
||||
PurgeBefore(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// AuditRetention deletes audit_logs older than retention. Periodic; idempotent.
|
||||
type AuditRetention struct {
|
||||
repo AuditRetentionRepo
|
||||
retention time.Duration
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewAuditRetention builds the audit-log retention purger.
|
||||
func NewAuditRetention(repo AuditRetentionRepo, retention time.Duration, log *slog.Logger) *AuditRetention {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &AuditRetention{repo: repo, retention: retention, log: log}
|
||||
}
|
||||
|
||||
// Run purges once. A non-positive retention disables the purge (safety: never
|
||||
// delete the whole table when misconfigured).
|
||||
func (r *AuditRetention) Run(ctx context.Context) {
|
||||
if r.retention <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-r.retention)
|
||||
rows, err := r.repo.PurgeBefore(ctx, cutoff)
|
||||
if err != nil {
|
||||
r.log.Error("audit_retention.purge", "err", err.Error())
|
||||
return
|
||||
}
|
||||
if rows > 0 {
|
||||
r.log.Info("audit_retention.purged", "count", rows, "before", cutoff.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
+240
-17
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
||||
}
|
||||
|
||||
type AcpSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
LastStopReason *string `json:"last_stop_reason"`
|
||||
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
|
||||
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
|
||||
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
|
||||
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
|
||||
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
|
||||
TerminatedReason *string `json:"terminated_reason"`
|
||||
SandboxMode *string `json:"sandbox_mode"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
}
|
||||
|
||||
type AcpSessionUsage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
SourceEventID *int64 `json:"source_event_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpTurn struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
TurnIndex int32 `json:"turn_index"`
|
||||
PromptRequestID *string `json:"prompt_request_id"`
|
||||
Status string `json:"status"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
UpdateCount int32 `json:"update_count"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -94,6 +142,81 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type ChangeRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
State string `json:"state"`
|
||||
ReviewVerdict string `json:"review_verdict"`
|
||||
CiState string `json:"ci_state"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalID *int64 `json:"external_id"`
|
||||
ExternalUrl string `json:"external_url"`
|
||||
MergeCommitSha string `json:"merge_commit_sha"`
|
||||
ReviewedBy pgtype.UUID `json:"reviewed_by"`
|
||||
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CodeChunk struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
FilePath string `json:"file_path"`
|
||||
Lang *string `json:"lang"`
|
||||
StartLine int32 `json:"start_line"`
|
||||
EndLine int32 `json:"end_line"`
|
||||
Content string `json:"content"`
|
||||
ContentSha []byte `json:"content_sha"`
|
||||
TokenCount *int32 `json:"token_count"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CodeIndexRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Status string `json:"status"`
|
||||
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
Dims int32 `json:"dims"`
|
||||
FilesIndexed int32 `json:"files_indexed"`
|
||||
ChunksIndexed int32 `json:"chunks_indexed"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CommitSummary struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Kind string `json:"kind"`
|
||||
Title *string `json:"title"`
|
||||
BodyMd string `json:"body_md"`
|
||||
Model *string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
@@ -110,6 +233,14 @@ type Conversation struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
}
|
||||
|
||||
type CryptoKey struct {
|
||||
Version int32 `json:"version"`
|
||||
Provider string `json:"provider"`
|
||||
KeyRef *string `json:"key_ref"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
@@ -119,6 +250,7 @@ type GitCredential struct {
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
@@ -135,6 +267,17 @@ type Issue struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type LlmModel struct {
|
||||
@@ -252,6 +396,50 @@ type Notification struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationPref struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
ImEnabled bool `json:"im_enabled"`
|
||||
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrchestratorRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Config []byte `json:"config"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type OrchestratorStep struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
Phase string `json:"phase"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
ParentStepID pgtype.UUID `json:"parent_step_id"`
|
||||
Depth int32 `json:"depth"`
|
||||
Status string `json:"status"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -264,6 +452,30 @@ type Project struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProjectMember struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
AddedBy pgtype.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
type ProjectMemory struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
EmbeddingModel *string `json:"embedding_model"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptTemplate struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
|
||||
SourceMessageID *int64 `json:"source_message_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type RequirementPhasePointer struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
|
||||
Reference in New Issue
Block a user