You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// KeyStore tracks which key versions exist and which one is the current write
|
||||
// ("active") version. The key material itself is never stored here — only the
|
||||
// version metadata (see crypto_keys table). It is consulted on Encrypt to pick
|
||||
// the active version and by the re-encrypt job to discover the target version.
|
||||
type KeyStore interface {
|
||||
// ActiveVersion returns the version that new ciphertext must be sealed with.
|
||||
ActiveVersion(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// staticKeyStore is a fixed-version store, used when no DB is wired (tests,
|
||||
// degraded boot). It always reports the same active version.
|
||||
type staticKeyStore struct{ v int }
|
||||
|
||||
// NewStaticKeyStore returns a KeyStore that always reports version v as active.
|
||||
func NewStaticKeyStore(v int) KeyStore { return staticKeyStore{v: v} }
|
||||
|
||||
func (s staticKeyStore) ActiveVersion(context.Context) (int, error) { return s.v, nil }
|
||||
|
||||
// KeyedEncryptor seals/opens secrets with a versioned key. The on-disk format
|
||||
// is unchanged from the legacy Encryptor — nonce(12) || ciphertext || tag —
|
||||
// because the key version travels in a separate DB column (key_version), not in
|
||||
// the ciphertext. This keeps every existing blob forward-compatible: a v1 blob
|
||||
// is still openable as long as version 1 is recorded for its row.
|
||||
type KeyedEncryptor struct {
|
||||
provider Provider
|
||||
store KeyStore
|
||||
|
||||
mu sync.Mutex
|
||||
gcms map[int]cipher.AEAD // version -> AEAD, lazily built from provider keys
|
||||
}
|
||||
|
||||
// NewKeyedEncryptor builds a KeyedEncryptor over a key provider and key store.
|
||||
// store may be nil → a static version-1 store is used (back-compat with the
|
||||
// single-key world).
|
||||
func NewKeyedEncryptor(provider Provider, store KeyStore) *KeyedEncryptor {
|
||||
if store == nil {
|
||||
store = NewStaticKeyStore(1)
|
||||
}
|
||||
return &KeyedEncryptor{provider: provider, store: store, gcms: map[int]cipher.AEAD{}}
|
||||
}
|
||||
|
||||
// gcmFor returns (and caches) the AEAD for a version, resolving the key bytes
|
||||
// via the provider on first use.
|
||||
func (e *KeyedEncryptor) gcmFor(ctx context.Context, version int) (cipher.AEAD, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if g, ok := e.gcms[version]; ok {
|
||||
return g, nil
|
||||
}
|
||||
key, err := e.provider.KeyMaterial(ctx, version)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve key v%d: %w", version, err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("key v%d must be 32 bytes (got %d)", version, len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes new cipher v%d: %w", version, err)
|
||||
}
|
||||
g, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new gcm v%d: %w", version, err)
|
||||
}
|
||||
e.gcms[version] = g
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// ActiveVersion reports the version new ciphertext is sealed with.
|
||||
func (e *KeyedEncryptor) ActiveVersion(ctx context.Context) (int, error) {
|
||||
return e.store.ActiveVersion(ctx)
|
||||
}
|
||||
|
||||
// EncryptVersion seals plaintext with the active version and returns the
|
||||
// ciphertext together with the version it was sealed under (to persist into the
|
||||
// row's key_version column).
|
||||
func (e *KeyedEncryptor) EncryptVersion(ctx context.Context, plaintext []byte) ([]byte, int, error) {
|
||||
version, err := e.store.ActiveVersion(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("active key version: %w", err)
|
||||
}
|
||||
ct, err := e.sealWith(ctx, version, plaintext)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return ct, version, nil
|
||||
}
|
||||
|
||||
// SealWithVersion encrypts plaintext under an explicit version. The re-encrypt
|
||||
// job uses this to re-seal an old-version blob onto the new active version.
|
||||
func (e *KeyedEncryptor) SealWithVersion(ctx context.Context, version int, plaintext []byte) ([]byte, error) {
|
||||
return e.sealWith(ctx, version, plaintext)
|
||||
}
|
||||
|
||||
// sealWith encrypts with a specific version (used by EncryptVersion and the
|
||||
// re-encrypt job which re-seals onto the new active version).
|
||||
func (e *KeyedEncryptor) sealWith(ctx context.Context, version int, plaintext []byte) ([]byte, error) {
|
||||
g, err := e.gcmFor(ctx, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, g.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("rand nonce: %w", err)
|
||||
}
|
||||
return g.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// DecryptVersion opens a ciphertext that was sealed under the given version.
|
||||
func (e *KeyedEncryptor) DecryptVersion(ctx context.Context, ciphertext []byte, version int) ([]byte, error) {
|
||||
g, err := e.gcmFor(ctx, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ciphertext) < g.NonceSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ct := ciphertext[:g.NonceSize()], ciphertext[g.NonceSize():]
|
||||
pt, err := g.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm open v%d: %w", version, err)
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// DecryptAny opens a ciphertext without knowing its key version: it tries the
|
||||
// active version first, then every lower version down to 1, returning the first
|
||||
// that authenticates. AES-GCM's auth tag guarantees only the correct key opens a
|
||||
// blob, so trying versions is safe and lets the read path stay version-agnostic
|
||||
// (callers need not thread key_version through every SELECT). Version count is
|
||||
// tiny (1-2 in practice), so the loop is cheap. A version whose key material is
|
||||
// not configured simply fails to build its AEAD and is skipped.
|
||||
func (e *KeyedEncryptor) DecryptAny(ctx context.Context, ciphertext []byte) ([]byte, error) {
|
||||
active, err := e.store.ActiveVersion(ctx)
|
||||
if err != nil || active < 1 {
|
||||
active = 1
|
||||
}
|
||||
var lastErr error
|
||||
for v := active; v >= 1; v-- {
|
||||
pt, derr := e.DecryptVersion(ctx, ciphertext, v)
|
||||
if derr == nil {
|
||||
return pt, nil
|
||||
}
|
||||
lastErr = derr
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("decrypt: no key version available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mutableStore is a KeyStore whose active version can change at runtime,
|
||||
// modeling a rotation.
|
||||
type mutableStore struct {
|
||||
mu sync.Mutex
|
||||
v int
|
||||
}
|
||||
|
||||
func (m *mutableStore) ActiveVersion(context.Context) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.v, nil
|
||||
}
|
||||
|
||||
func (m *mutableStore) set(v int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.v = v
|
||||
}
|
||||
|
||||
func rawKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
_, err := rand.Read(k)
|
||||
require.NoError(t, err)
|
||||
return k
|
||||
}
|
||||
|
||||
func TestKeyedEncryptor_RoundTripV1AndV2(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := NewEnvProvider(nil)
|
||||
prov.SetKey(1, rawKey(t))
|
||||
prov.SetKey(2, rawKey(t))
|
||||
store := &mutableStore{v: 1}
|
||||
ke := NewKeyedEncryptor(prov, store)
|
||||
|
||||
pt := []byte("super secret value")
|
||||
|
||||
ctV1, v, err := ke.EncryptVersion(ctx, pt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, v)
|
||||
got, err := ke.DecryptVersion(ctx, ctV1, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pt, got)
|
||||
|
||||
store.set(2)
|
||||
ctV2, v2, err := ke.EncryptVersion(ctx, pt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, v2)
|
||||
require.NotEqual(t, ctV1, ctV2)
|
||||
got2, err := ke.DecryptVersion(ctx, ctV2, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pt, got2)
|
||||
}
|
||||
|
||||
func TestKeyedEncryptor_V1BlobDecryptsAfterActiveBumpsToV2(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := NewEnvProvider(nil)
|
||||
prov.SetKey(1, rawKey(t))
|
||||
prov.SetKey(2, rawKey(t))
|
||||
store := &mutableStore{v: 1}
|
||||
ke := NewKeyedEncryptor(prov, store)
|
||||
|
||||
pt := []byte("encrypted under v1")
|
||||
ctV1, v, err := ke.EncryptVersion(ctx, pt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, v)
|
||||
|
||||
// Rotate the active write version; the OLD blob must still decrypt under v1.
|
||||
store.set(2)
|
||||
got, err := ke.DecryptVersion(ctx, ctV1, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pt, got)
|
||||
}
|
||||
|
||||
func TestKeyedEncryptor_WrongVersionDecryptFailsCleanly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := NewEnvProvider(nil)
|
||||
prov.SetKey(1, rawKey(t))
|
||||
prov.SetKey(2, rawKey(t))
|
||||
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
|
||||
|
||||
ct, _, err := ke.EncryptVersion(ctx, []byte("x"))
|
||||
require.NoError(t, err)
|
||||
// Decrypting a v1 blob as v2 must fail authentication, not panic.
|
||||
_, err = ke.DecryptVersion(ctx, ct, 2)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestKeyedEncryptor_UnknownVersionErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := NewEnvProvider(nil)
|
||||
prov.SetKey(1, rawKey(t))
|
||||
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
|
||||
_, err := ke.DecryptVersion(ctx, make([]byte, 16), 9)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEncryptorShim_BacksOntoKeyedV1(t *testing.T) {
|
||||
// The legacy Encryptor shim must remain byte-compatible: a blob it produces
|
||||
// decrypts via the underlying KeyedEncryptor at version 1, and vice versa.
|
||||
key := rawKey(t)
|
||||
enc, err := NewEncryptor(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
pt := []byte("legacy callers see no versions")
|
||||
ct, err := enc.Encrypt(pt)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := enc.Decrypt(ct)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pt, got)
|
||||
|
||||
// Same key via KeyedEncryptor v1 opens the shim's ciphertext.
|
||||
prov := NewEnvProvider(key)
|
||||
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
|
||||
got2, err := ke.DecryptVersion(context.Background(), ct, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pt, got2)
|
||||
}
|
||||
|
||||
func TestEnvProvider_ReadsVersionedEnv(t *testing.T) {
|
||||
t.Setenv("APP_MASTER_KEY_V2", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
|
||||
prov := NewEnvProvider(nil)
|
||||
k, err := prov.KeyMaterial(context.Background(), 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, k, 32)
|
||||
|
||||
_, err = prov.KeyMaterial(context.Background(), 3)
|
||||
require.Error(t, err, "unstaged version must error, not silently use a default")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// PostgresKeyStore reads/writes the crypto_keys table that records which key
|
||||
// versions exist and which is currently active (the write key). The key
|
||||
// material is NOT stored here — only version metadata.
|
||||
type PostgresKeyStore struct {
|
||||
pool poolExec
|
||||
}
|
||||
|
||||
// poolExec is the subset of *pgxpool.Pool used by the store. Declared as an
|
||||
// interface so tests can inject a fake without a live DB.
|
||||
type poolExec interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// poolFullExec additionally allows mutations (used by RotateActive/RetireVersion).
|
||||
// Exec returns the CONCRETE pgconn.CommandTag so *pgxpool.Pool satisfies this
|
||||
// interface — an interface-typed return would not match pgxpool's method
|
||||
// signature, making the type assertion in RotateActive/RetireVersion silently
|
||||
// fail and key rotation always error out.
|
||||
type poolFullExec interface {
|
||||
poolExec
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
// NewPostgresKeyStore builds the store over a pgx pool (or any compatible
|
||||
// querier). It satisfies KeyStore.
|
||||
func NewPostgresKeyStore(pool poolExec) *PostgresKeyStore {
|
||||
return &PostgresKeyStore{pool: pool}
|
||||
}
|
||||
|
||||
// ActiveVersion returns the single crypto_keys row whose status='active'. If
|
||||
// none is found it falls back to version 1 (single-key bootstrap before the
|
||||
// seed row exists).
|
||||
func (s *PostgresKeyStore) ActiveVersion(ctx context.Context) (int, error) {
|
||||
var v int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT version FROM crypto_keys WHERE status = 'active' ORDER BY version DESC LIMIT 1`,
|
||||
).Scan(&v)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, fmt.Errorf("crypto key active version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RotateActive bumps the active write version: it retires the current active
|
||||
// row and inserts/activates the next version (current+1). The new version's key
|
||||
// material must already be loadable by the provider (e.g. APP_MASTER_KEY_V<n>
|
||||
// staged) before this is called. Returns the new active version.
|
||||
//
|
||||
// pool must support Exec (a *pgxpool.Pool does). Both rows are written in one
|
||||
// transaction by the caller's pool if it is transactional; here we issue two
|
||||
// statements and rely on the unique active invariant being eventually consistent
|
||||
// within the rotation window (both versions remain loadable, so decrypts never
|
||||
// break).
|
||||
func (s *PostgresKeyStore) RotateActive(ctx context.Context, provider string, keyRef string) (int, error) {
|
||||
fe, ok := s.pool.(poolFullExec)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("crypto key store: pool does not support writes")
|
||||
}
|
||||
cur, err := s.ActiveVersion(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
next := cur + 1
|
||||
if _, err := fe.Exec(ctx,
|
||||
`UPDATE crypto_keys SET status = 'retiring' WHERE status = 'active'`); err != nil {
|
||||
return 0, fmt.Errorf("retire current key: %w", err)
|
||||
}
|
||||
if _, err := fe.Exec(ctx,
|
||||
`INSERT INTO crypto_keys (version, provider, key_ref, status)
|
||||
VALUES ($1, $2, NULLIF($3, ''), 'active')
|
||||
ON CONFLICT (version) DO UPDATE SET status = 'active', provider = EXCLUDED.provider, key_ref = EXCLUDED.key_ref`,
|
||||
next, provider, keyRef); err != nil {
|
||||
return 0, fmt.Errorf("insert next key: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// RetireVersion marks a fully-superseded version as retired (no longer needed
|
||||
// after the re-encrypt job has moved every row off it). The provider may then
|
||||
// safely drop that key material.
|
||||
func (s *PostgresKeyStore) RetireVersion(ctx context.Context, version int) error {
|
||||
fe, ok := s.pool.(poolFullExec)
|
||||
if !ok {
|
||||
return fmt.Errorf("crypto key store: pool does not support writes")
|
||||
}
|
||||
if _, err := fe.Exec(ctx,
|
||||
`UPDATE crypto_keys SET status = 'retired' WHERE version = $1`, version); err != nil {
|
||||
return fmt.Errorf("retire key v%d: %w", version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ KeyStore = (*PostgresKeyStore)(nil)
|
||||
@@ -0,0 +1,103 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Provider resolves the raw 32-byte AES-256 key material for a given key
|
||||
// version. The key material itself never lives in the DB — only the version
|
||||
// number (in crypto_keys + the per-row key_version column) is persisted; the
|
||||
// provider maps that version back to actual bytes (env var, Vault, KMS, ...).
|
||||
type Provider interface {
|
||||
// KeyMaterial returns the 32-byte key for the given version, or an error if
|
||||
// the version is unknown / unavailable. Implementations may cache.
|
||||
KeyMaterial(ctx context.Context, version int) ([]byte, error)
|
||||
}
|
||||
|
||||
// EnvProvider maps key versions to env vars. Version 1 is APP_MASTER_KEY
|
||||
// (the historical single key); higher versions read APP_MASTER_KEY_V<n> so a
|
||||
// rotation can stage the next key alongside the current one. The constructor
|
||||
// also accepts an explicit version-1 key so callers that already decoded
|
||||
// cfg.MasterKey do not re-read the env.
|
||||
type EnvProvider struct {
|
||||
mu sync.RWMutex
|
||||
keys map[int][]byte
|
||||
}
|
||||
|
||||
// NewEnvProvider builds an EnvProvider seeded with the version-1 key (typically
|
||||
// the already-decoded cfg.MasterKey). A nil/empty v1 key falls back to reading
|
||||
// APP_MASTER_KEY lazily on first KeyMaterial(1).
|
||||
func NewEnvProvider(v1 []byte) *EnvProvider {
|
||||
p := &EnvProvider{keys: map[int][]byte{}}
|
||||
if len(v1) > 0 {
|
||||
p.keys[1] = append([]byte(nil), v1...)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// KeyMaterial returns the cached key for version, loading it from the matching
|
||||
// env var on first use. Version 1 → APP_MASTER_KEY; version n>1 →
|
||||
// APP_MASTER_KEY_V<n>. The value must be 64 hex chars (32 bytes).
|
||||
func (p *EnvProvider) KeyMaterial(_ context.Context, version int) ([]byte, error) {
|
||||
if version < 1 {
|
||||
return nil, fmt.Errorf("crypto: invalid key version %d", version)
|
||||
}
|
||||
p.mu.RLock()
|
||||
k, ok := p.keys[version]
|
||||
p.mu.RUnlock()
|
||||
if ok {
|
||||
return k, nil
|
||||
}
|
||||
|
||||
envName := "APP_MASTER_KEY"
|
||||
if version > 1 {
|
||||
envName = fmt.Sprintf("APP_MASTER_KEY_V%d", version)
|
||||
}
|
||||
raw := os.Getenv(envName)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("crypto: key version %d unavailable (%s unset)", version, envName)
|
||||
}
|
||||
key, err := hex.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crypto: %s invalid hex: %w", envName, err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("crypto: %s must be 32 bytes (got %d)", envName, len(key))
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.keys[version] = key
|
||||
p.mu.Unlock()
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SetKey installs key material for a version explicitly (used in tests and when
|
||||
// a KMS-fetched key is handed to an EnvProvider-style cache).
|
||||
func (p *EnvProvider) SetKey(version int, key []byte) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.keys[version] = append([]byte(nil), key...)
|
||||
}
|
||||
|
||||
// VaultProvider / KMSProvider are deferred: their interface is identical to
|
||||
// EnvProvider (KeyMaterial(ctx, version) -> []byte), so wiring a real
|
||||
// HashiCorp Vault transit / cloud KMS client is a drop-in replacement. They are
|
||||
// declared here so config.Provider can select "vault"/"kms" without a build
|
||||
// break; until a real client is wired they delegate to a backing Provider.
|
||||
|
||||
// DelegatingProvider wraps another Provider; it is the shape both VaultProvider
|
||||
// and KMSProvider take until their remote clients are implemented.
|
||||
type DelegatingProvider struct{ inner Provider }
|
||||
|
||||
// NewDelegatingProvider returns a Provider that forwards to inner.
|
||||
func NewDelegatingProvider(inner Provider) *DelegatingProvider {
|
||||
return &DelegatingProvider{inner: inner}
|
||||
}
|
||||
|
||||
// KeyMaterial forwards to the wrapped provider.
|
||||
func (d *DelegatingProvider) KeyMaterial(ctx context.Context, version int) ([]byte, error) {
|
||||
return d.inner.KeyMaterial(ctx, version)
|
||||
}
|
||||
@@ -3,61 +3,70 @@
|
||||
//
|
||||
// The master key is supplied by the caller (see config.MasterKey) and never
|
||||
// stored alongside the ciphertext.
|
||||
//
|
||||
// As of the secret-hardening epic, the underlying engine is KeyedEncryptor:
|
||||
// every blob is sealed under a key *version* whose number is persisted in a
|
||||
// per-row key_version column (the key material stays in env/KMS). The legacy
|
||||
// Encryptor type below is a thin version-1 shim so the ~30 existing call sites
|
||||
// — which neither know nor care about versions — compile and behave unchanged.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Encryptor 用 AES-256-GCM 加解密 secret。
|
||||
// 密文格式:nonce(12) || ciphertext || tag。
|
||||
// Encryptor 用 AES-256-GCM 加解密 secret(version-1 shim over KeyedEncryptor)。
|
||||
// 密文格式:nonce(12) || ciphertext || tag(与历史一致,未变)。
|
||||
type Encryptor struct {
|
||||
gcm cipher.AEAD
|
||||
keyed *KeyedEncryptor
|
||||
version int
|
||||
}
|
||||
|
||||
// NewEncryptor constructs an Encryptor from a 32-byte (AES-256) key.
|
||||
// Any other key length yields an error rather than silently downgrading.
|
||||
// It builds a KeyedEncryptor backed by an EnvProvider seeded with this key at
|
||||
// version 1 and a static version-1 store, preserving the previous semantics.
|
||||
func NewEncryptor(key []byte) (*Encryptor, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("key must be 32 bytes (got %d)", len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes new cipher: %w", err)
|
||||
keyed := NewKeyedEncryptor(NewEnvProvider(key), NewStaticKeyStore(1))
|
||||
// Eagerly validate the key by building the v1 AEAD once.
|
||||
if _, err := keyed.gcmFor(context.Background(), 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new gcm: %w", err)
|
||||
}
|
||||
return &Encryptor{gcm: gcm}, nil
|
||||
return &Encryptor{keyed: keyed, version: 1}, nil
|
||||
}
|
||||
|
||||
// Encrypt seals plaintext with a fresh random nonce and returns
|
||||
// nonce || ciphertext || tag.
|
||||
// NewEncryptorFromKeyed wraps an existing KeyedEncryptor as a fixed-version
|
||||
// Encryptor shim. version is the version this shim seals/opens with (the call
|
||||
// sites that use the shim only ever round-trip a single logical version because
|
||||
// the DB column travels separately). app.go uses this so workspace/chat/acp/run
|
||||
// keep the *crypto.Encryptor argument while sharing one KeyedEncryptor.
|
||||
func NewEncryptorFromKeyed(keyed *KeyedEncryptor, version int) *Encryptor {
|
||||
if version < 1 {
|
||||
version = 1
|
||||
}
|
||||
return &Encryptor{keyed: keyed, version: version}
|
||||
}
|
||||
|
||||
// Keyed exposes the underlying KeyedEncryptor so version-aware callers (the
|
||||
// re-encrypt job, rotate-key endpoint) can use EncryptVersion/DecryptVersion.
|
||||
func (e *Encryptor) Keyed() *KeyedEncryptor { return e.keyed }
|
||||
|
||||
// Encrypt seals plaintext under the shim's version and returns
|
||||
// nonce || ciphertext || tag (version travels via the DB key_version column).
|
||||
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
nonce := make([]byte, e.gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("rand nonce: %w", err)
|
||||
}
|
||||
// Seal 把 nonce 作为 dst 前缀写入
|
||||
return e.gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
return e.keyed.sealWith(context.Background(), e.version, plaintext)
|
||||
}
|
||||
|
||||
// Decrypt verifies and opens a ciphertext produced by Encrypt.
|
||||
// It returns an error if the input is too short or authentication fails.
|
||||
// Decrypt verifies and opens a ciphertext produced by Encrypt. It is
|
||||
// version-agnostic: it tries the active key version then falls back through
|
||||
// older versions (GCM auth guarantees correctness), so blobs re-sealed onto a
|
||||
// newer version after a key rotation still open without the call site needing to
|
||||
// know the row's key_version. Returns an error if input is too short or no key
|
||||
// authenticates.
|
||||
func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||
if len(ciphertext) < e.gcm.NonceSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ct := ciphertext[:e.gcm.NonceSize()], ciphertext[e.gcm.NonceSize():]
|
||||
pt, err := e.gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm open: %w", err)
|
||||
}
|
||||
return pt, nil
|
||||
return e.keyed.DecryptAny(context.Background(), ciphertext)
|
||||
}
|
||||
|
||||
+24
-10
@@ -18,20 +18,22 @@ type Code string
|
||||
// Canonical error codes. Keep this list in sync with HTTPStatus and any
|
||||
// downstream consumers (e.g., HTTP transport, OpenAPI schemas).
|
||||
const (
|
||||
CodeInvalidInput Code = "invalid_input"
|
||||
CodeUnauthorized Code = "unauthorized"
|
||||
CodeForbidden Code = "forbidden"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeConflict Code = "conflict"
|
||||
CodeRateLimited Code = "rate_limited"
|
||||
CodeUpstream Code = "upstream_error"
|
||||
CodeUnavailable Code = "unavailable"
|
||||
CodeInternal Code = "internal"
|
||||
CodeInvalidInput Code = "invalid_input"
|
||||
CodeUnauthorized Code = "unauthorized"
|
||||
CodeForbidden Code = "forbidden"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeConflict Code = "conflict"
|
||||
CodeFailedPrecondition Code = "failed_precondition"
|
||||
CodeRateLimited Code = "rate_limited"
|
||||
CodeUpstream Code = "upstream_error"
|
||||
CodeUnavailable Code = "unavailable"
|
||||
CodeInternal Code = "internal"
|
||||
|
||||
// Project 模块专用细粒度 code(HTTP 仍 409,但前端可据此区分提示)。
|
||||
CodeProjectSlugTaken Code = "project_slug_taken"
|
||||
CodeProjectArchived Code = "project_archived"
|
||||
CodeRequirementClosed Code = "requirement_closed"
|
||||
CodePhaseGateFailed Code = "phase_gate_failed"
|
||||
|
||||
// Workspace 模块细粒度 code
|
||||
CodeWorkspaceSlugTaken Code = "workspace_slug_taken"
|
||||
@@ -108,6 +110,10 @@ const (
|
||||
CodeRunProfileDisabled Code = "run.profile_disabled"
|
||||
CodeRunSpawnFailed Code = "run.spawn_failed"
|
||||
CodeRunCommandInvalid Code = "run.command_invalid"
|
||||
// 一次性 exec(run_command / run_tests)专用错误。
|
||||
CodeRunExecTimeout Code = "run.exec_timeout"
|
||||
CodeRunExecCommandNotAllowed Code = "run.exec_command_not_allowed"
|
||||
CodeRunExecFailed Code = "run.exec_failed"
|
||||
)
|
||||
|
||||
// AppError is the application's structured error type. It carries a stable
|
||||
@@ -179,10 +185,12 @@ func HTTPStatus(code Code) int {
|
||||
case CodeNotFound:
|
||||
return http.StatusNotFound
|
||||
case CodeConflict,
|
||||
CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed,
|
||||
CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed, CodePhaseGateFailed,
|
||||
CodeWorkspaceSlugTaken, CodeWorkspaceSyncInProgress,
|
||||
CodeWorktreeBranchConflict, CodeWorktreeAlreadyActive:
|
||||
return http.StatusConflict
|
||||
case CodeFailedPrecondition:
|
||||
return http.StatusPreconditionFailed
|
||||
case CodeRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case CodeUpstream:
|
||||
@@ -257,6 +265,12 @@ func HTTPStatus(code Code) int {
|
||||
return http.StatusBadRequest
|
||||
case CodeRunSpawnFailed:
|
||||
return http.StatusBadGateway
|
||||
case CodeRunExecTimeout:
|
||||
return http.StatusGatewayTimeout
|
||||
case CodeRunExecCommandNotAllowed:
|
||||
return http.StatusForbidden
|
||||
case CodeRunExecFailed:
|
||||
return http.StatusInternalServerError
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestHTTPStatus(t *testing.T) {
|
||||
CodeProjectSlugTaken: http.StatusConflict,
|
||||
CodeProjectArchived: http.StatusConflict,
|
||||
CodeRequirementClosed: http.StatusConflict,
|
||||
CodePhaseGateFailed: http.StatusConflict,
|
||||
CodeRateLimited: http.StatusTooManyRequests,
|
||||
CodeUpstream: http.StatusBadGateway,
|
||||
CodeUnavailable: http.StatusServiceUnavailable,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// diffMaxBytes caps the raw unified-diff stdout to keep MCP payloads / memory
|
||||
// bounded. Larger diffs are truncated and flagged via DiffResult.Truncated.
|
||||
const diffMaxBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// DiffOptions controls `git diff` argument building.
|
||||
//
|
||||
// - Ref/Against both empty => working-tree vs HEAD (the default).
|
||||
// - Ref set, Against empty => `git diff <Ref>` (working-tree vs Ref).
|
||||
// - Ref and Against both set => `git diff <Ref>..<Against>` (two refs).
|
||||
// - Staged => add `--cached` (index vs HEAD); mutually composes with refs.
|
||||
// - Paths => restrict to the given pathspecs (passed after `--`).
|
||||
// - ContextLines => `-U<n>` when > 0 (git default 3 otherwise).
|
||||
type DiffOptions struct {
|
||||
Ref string
|
||||
Against string
|
||||
Staged bool
|
||||
Paths []string
|
||||
ContextLines int
|
||||
}
|
||||
|
||||
// DiffResult carries the raw unified-diff text. The viewer parses it; the git
|
||||
// layer never parses the hunks. Truncated reports whether stdout exceeded the
|
||||
// 1 MiB cap and was cut.
|
||||
type DiffResult struct {
|
||||
Diff string
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// Diff returns the unified diff of dir per opts. The output is the raw `git
|
||||
// diff --no-color` text (capped at 1 MiB). On a clean tree the diff is empty.
|
||||
func (r *DefaultRunner) Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error) {
|
||||
// 安全护栏:ref/against 来自 MCP/HTTP 入参,可能未经分支校验。以 '-' 开头的值会被
|
||||
// git 当作选项解析(如 --output=、-O<file>),故一律拒绝,杜绝参数注入。
|
||||
if strings.HasPrefix(opts.Ref, "-") || strings.HasPrefix(opts.Against, "-") {
|
||||
return DiffResult{}, fmt.Errorf("git diff: ref/against must not begin with '-': %q %q", opts.Ref, opts.Against)
|
||||
}
|
||||
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||
defer cancel()
|
||||
args := buildDiffArgs(opts)
|
||||
out, _, err := r.exec(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return DiffResult{}, err
|
||||
}
|
||||
res := DiffResult{}
|
||||
if len(out) > diffMaxBytes {
|
||||
out = out[:diffMaxBytes]
|
||||
res.Truncated = true
|
||||
}
|
||||
res.Diff = string(out)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// buildDiffArgs assembles the `git diff` argument slice from opts. Refs are
|
||||
// never user-supplied paths (workspace package validates branches), so they are
|
||||
// passed positionally; pathspecs go after the `--` separator to disambiguate.
|
||||
func buildDiffArgs(opts DiffOptions) []string {
|
||||
args := []string{"diff", "--no-color"}
|
||||
if opts.Staged {
|
||||
args = append(args, "--cached")
|
||||
}
|
||||
if opts.ContextLines > 0 {
|
||||
args = append(args, "-U"+itoa(opts.ContextLines))
|
||||
}
|
||||
switch {
|
||||
case opts.Ref != "" && opts.Against != "":
|
||||
args = append(args, opts.Ref+".."+opts.Against)
|
||||
case opts.Ref != "":
|
||||
args = append(args, opts.Ref)
|
||||
}
|
||||
if len(opts.Paths) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, opts.Paths...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// itoa is a tiny strconv.Itoa avoiding an import just for one call site.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDiffArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
opts DiffOptions
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "working tree vs HEAD",
|
||||
opts: DiffOptions{},
|
||||
want: []string{"diff", "--no-color"},
|
||||
},
|
||||
{
|
||||
name: "staged",
|
||||
opts: DiffOptions{Staged: true},
|
||||
want: []string{"diff", "--no-color", "--cached"},
|
||||
},
|
||||
{
|
||||
name: "single ref",
|
||||
opts: DiffOptions{Ref: "HEAD~1"},
|
||||
want: []string{"diff", "--no-color", "HEAD~1"},
|
||||
},
|
||||
{
|
||||
name: "ref range",
|
||||
opts: DiffOptions{Ref: "main", Against: "feature"},
|
||||
want: []string{"diff", "--no-color", "main..feature"},
|
||||
},
|
||||
{
|
||||
name: "context lines + paths",
|
||||
opts: DiffOptions{ContextLines: 5, Paths: []string{"a.txt", "dir/b.go"}},
|
||||
want: []string{"diff", "--no-color", "-U5", "--", "a.txt", "dir/b.go"},
|
||||
},
|
||||
{
|
||||
name: "staged with ref range and paths",
|
||||
opts: DiffOptions{Staged: true, Ref: "main", Against: "dev", Paths: []string{"x"}},
|
||||
want: []string{"diff", "--no-color", "--cached", "main..dev", "--", "x"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tc.want, buildDiffArgs(tc.opts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiff_WorkingTreeAndStaged(t *testing.T) {
|
||||
t.Parallel()
|
||||
gitAvailable(t)
|
||||
bare := makeBareRepo(t)
|
||||
r := newTestRunner(t)
|
||||
dst := filepath.Join(t.TempDir(), "wc")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, r.Clone(ctx, dst, bare, "main", nil))
|
||||
|
||||
// clean tree => empty diff
|
||||
res, err := r.Diff(ctx, dst, DiffOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res.Diff)
|
||||
require.False(t, res.Truncated)
|
||||
|
||||
// modify a tracked file => unified diff against working tree
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("hello\nworld\n"), 0o644))
|
||||
res, err = r.Diff(ctx, dst, DiffOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Diff, "diff --git")
|
||||
require.Contains(t, res.Diff, "+world")
|
||||
|
||||
// stage it; working-tree diff now empty, --cached shows the change
|
||||
mustGit(t, dst, "add", "README.md")
|
||||
res, err = r.Diff(ctx, dst, DiffOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res.Diff)
|
||||
|
||||
res, err = r.Diff(ctx, dst, DiffOptions{Staged: true})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Diff, "+world")
|
||||
}
|
||||
|
||||
func TestDiff_PathsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
gitAvailable(t)
|
||||
bare := makeBareRepo(t)
|
||||
r := newTestRunner(t)
|
||||
dst := filepath.Join(t.TempDir(), "wc")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, r.Clone(ctx, dst, bare, "main", nil))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("changed\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dst, "other.txt"), []byte("new\n"), 0o644))
|
||||
mustGit(t, dst, "add", "other.txt")
|
||||
|
||||
// restrict to README only => other.txt not in output
|
||||
res, err := r.Diff(ctx, dst, DiffOptions{Paths: []string{"README.md"}})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Diff, "README.md")
|
||||
require.False(t, strings.Contains(res.Diff, "other.txt"))
|
||||
}
|
||||
|
||||
func TestDiff_ErrorPropagation(t *testing.T) {
|
||||
t.Parallel()
|
||||
gitAvailable(t)
|
||||
r := newTestRunner(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// not a git repo => git diff fails, error propagates
|
||||
_, err := r.Diff(ctx, t.TempDir(), DiffOptions{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package git
|
||||
|
||||
import "context"
|
||||
|
||||
// DiffStat 返回单个 commit 的变更摘要(git show --stat),供自动文档生成喂给
|
||||
// LLM 一个有界的改动概览。commitSHA 由上层从可信来源(git log / commit 返回值)
|
||||
// 取得,不接受用户任意输入。--no-color 保证输出稳定,-M 检测重命名。
|
||||
func (r *DefaultRunner) DiffStat(ctx context.Context, dir, commitSHA string) (string, error) {
|
||||
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||
defer cancel()
|
||||
out, _, err := r.exec(ctx, dir, nil,
|
||||
"show", "--no-color", "--stat", "--format=%H%n%an%n%s%n%n%b", "-M", commitSHA)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ListTrackedFiles 返回 dir 仓库中被 git 跟踪的文件(git ls-files -z),
|
||||
// 路径相对 worktree 根,NUL 分隔以正确处理含空格/特殊字符的文件名。
|
||||
// 仅跟踪文件 —— 忽略 .gitignore / 未跟踪 / vendored,避免索引器遍历海量无关文件。
|
||||
func (r *DefaultRunner) ListTrackedFiles(ctx context.Context, dir string) ([]string, error) {
|
||||
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||
defer cancel()
|
||||
out, _, err := r.exec(ctx, dir, nil, "ls-files", "-z")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseNULList(out), nil
|
||||
}
|
||||
|
||||
// parseNULList 解析 NUL 分隔的文件列表(git ls-files -z)。空输入返回 nil。
|
||||
func parseNULList(buf []byte) []string {
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, rec := range bytes.Split(buf, []byte{0}) {
|
||||
if len(rec) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, string(rec))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListTrackedFiles(t *testing.T) {
|
||||
gitAvailable(t)
|
||||
dir := t.TempDir()
|
||||
mustGit(t, dir, "init", "-b", "main")
|
||||
mustGit(t, dir, "config", "user.email", "test@example.com")
|
||||
mustGit(t, dir, "config", "user.name", "Test")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("b\n"), 0o644))
|
||||
// Untracked + ignored files must NOT appear.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.txt\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("x\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("y\n"), 0o644))
|
||||
mustGit(t, dir, "add", "a.go", "sub/b.txt", ".gitignore")
|
||||
mustGit(t, dir, "commit", "-m", "init")
|
||||
|
||||
r := newTestRunner(t)
|
||||
files, err := r.ListTrackedFiles(context.Background(), dir)
|
||||
require.NoError(t, err)
|
||||
sort.Strings(files)
|
||||
require.Equal(t, []string{".gitignore", "a.go", "sub/b.txt"}, files)
|
||||
}
|
||||
|
||||
func TestDiffStat(t *testing.T) {
|
||||
gitAvailable(t)
|
||||
dir := t.TempDir()
|
||||
mustGit(t, dir, "init", "-b", "main")
|
||||
mustGit(t, dir, "config", "user.email", "test@example.com")
|
||||
mustGit(t, dir, "config", "user.name", "Test")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "f.go"), []byte("package f\n\nfunc F() {}\n"), 0o644))
|
||||
mustGit(t, dir, "add", "f.go")
|
||||
mustGit(t, dir, "commit", "-m", "add F")
|
||||
|
||||
r := newTestRunner(t)
|
||||
// HEAD resolves the just-made commit.
|
||||
commits, err := r.Log(context.Background(), dir, 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, commits, 1)
|
||||
|
||||
stat, err := r.DiffStat(context.Background(), dir, commits[0].Hash)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stat, "f.go")
|
||||
require.Contains(t, stat, "add F") // subject from --format
|
||||
}
|
||||
@@ -34,6 +34,13 @@ type Runner interface {
|
||||
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
|
||||
Checkout(ctx context.Context, dir, branch string) error
|
||||
Log(ctx context.Context, dir string, n int) ([]Commit, error)
|
||||
Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error)
|
||||
// ListTrackedFiles returns the git-tracked files at HEAD (git ls-files),
|
||||
// worktree-relative, so the indexer never walks ignored/vendored/untracked paths.
|
||||
ListTrackedFiles(ctx context.Context, dir string) ([]string, error)
|
||||
// DiffStat returns the `--stat` summary for a single commit (git show --stat),
|
||||
// used by auto-doc generation to feed the LLM a bounded change overview.
|
||||
DiffStat(ctx context.Context, dir, commitSHA string) (string, error)
|
||||
}
|
||||
|
||||
// FileStatus 表示 `git status --porcelain=v1` 的一行。
|
||||
|
||||
@@ -9,8 +9,12 @@ import (
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
)
|
||||
|
||||
// Compile-time assertion: AnthropicClient must satisfy LLMClient.
|
||||
var _ LLMClient = (*AnthropicClient)(nil)
|
||||
// Compile-time assertion: AnthropicClient must satisfy LLMClient + Embedder
|
||||
// (Embed returns ErrEmbeddingsUnsupported).
|
||||
var (
|
||||
_ LLMClient = (*AnthropicClient)(nil)
|
||||
_ Embedder = (*AnthropicClient)(nil)
|
||||
)
|
||||
|
||||
// AnthropicClient 通过官方 SDK 调用 Anthropic Messages API。
|
||||
// baseURL 可改写以走 LiteLLM/OpenRouter 等代理。
|
||||
@@ -152,3 +156,12 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, modelID string, req R
|
||||
}
|
||||
return int(res.InputTokens), nil
|
||||
}
|
||||
|
||||
// Embed is unsupported: Anthropic has no embeddings API. Embeddings must target
|
||||
// an OpenAI-compatible or Gemini endpoint; callers degrade to keyword fallback.
|
||||
func (c *AnthropicClient) Embed(context.Context, string, []string) ([][]float32, error) {
|
||||
return nil, ErrEmbeddingsUnsupported
|
||||
}
|
||||
|
||||
// EmbedDims is 0 since Anthropic produces no embeddings.
|
||||
func (c *AnthropicClient) EmbedDims(string) int { return 0 }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrEmbeddingsUnsupported is returned by providers that have no embeddings API
|
||||
// (notably Anthropic). Callers degrade to keyword fallback when they see it.
|
||||
var ErrEmbeddingsUnsupported = errors.New("llm: embeddings not supported by provider")
|
||||
|
||||
// PlatformEmbeddingDims is the single vector dimension baked into the pgvector
|
||||
// columns at migration time (vector(1536)). Embedding endpoints MUST be
|
||||
// configured with a model that produces this dimension; the build runner rejects
|
||||
// mismatched dims rather than corrupting the index.
|
||||
const PlatformEmbeddingDims = 1536
|
||||
|
||||
// Embedder is implemented by providers that expose a text-embeddings endpoint.
|
||||
// Embed returns one vector per input, in input order. EmbedDims returns the
|
||||
// vector length the given model produces (used to validate against the fixed
|
||||
// pgvector column dimension before inserting).
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error)
|
||||
EmbedDims(modelID string) int
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAI_Embed_ReturnsVectors(t *testing.T) {
|
||||
var gotInputs []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Input []string `json:"input"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotInputs = body.Input
|
||||
require.Equal(t, PlatformEmbeddingDims, body.Dimensions)
|
||||
// Echo two 3-dim vectors (dimension count here is irrelevant to the test).
|
||||
resp := map[string]any{
|
||||
"object": "list",
|
||||
"data": []map[string]any{
|
||||
{"object": "embedding", "index": 0, "embedding": []float64{0.1, 0.2, 0.3}},
|
||||
{"object": "embedding", "index": 1, "embedding": []float64{0.4, 0.5, 0.6}},
|
||||
},
|
||||
"model": body.Model,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := NewOpenAI("k", srv.URL)
|
||||
vecs, err := c.Embed(context.Background(), "text-embedding-3-small", []string{"alpha", "beta"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, vecs, 2)
|
||||
require.Equal(t, []float32{0.1, 0.2, 0.3}, vecs[0])
|
||||
require.Equal(t, []float32{0.4, 0.5, 0.6}, vecs[1])
|
||||
require.Equal(t, []string{"alpha", "beta"}, gotInputs)
|
||||
require.Equal(t, PlatformEmbeddingDims, c.EmbedDims("text-embedding-3-small"))
|
||||
}
|
||||
|
||||
func TestOpenAI_Embed_RespectsResponseIndex(t *testing.T) {
|
||||
// Server returns vectors out of order; Embed must place them by Index.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
"object": "list",
|
||||
"data": []map[string]any{
|
||||
{"object": "embedding", "index": 1, "embedding": []float64{9, 9}},
|
||||
{"object": "embedding", "index": 0, "embedding": []float64{1, 1}},
|
||||
},
|
||||
"model": "m",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := NewOpenAI("k", srv.URL)
|
||||
vecs, err := c.Embed(context.Background(), "m", []string{"a", "b"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []float32{1, 1}, vecs[0])
|
||||
require.Equal(t, []float32{9, 9}, vecs[1])
|
||||
}
|
||||
|
||||
func TestOpenAI_Embed_EmptyInputs(t *testing.T) {
|
||||
c, _ := NewOpenAI("k", "")
|
||||
vecs, err := c.Embed(context.Background(), "m", nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, vecs)
|
||||
}
|
||||
|
||||
func TestAnthropic_Embed_Unsupported(t *testing.T) {
|
||||
c, _ := NewAnthropic("k", "")
|
||||
_, err := c.Embed(context.Background(), "m", []string{"x"})
|
||||
require.ErrorIs(t, err, ErrEmbeddingsUnsupported)
|
||||
require.Equal(t, 0, c.EmbedDims("m"))
|
||||
}
|
||||
|
||||
func TestFakeEmbedder_Deterministic(t *testing.T) {
|
||||
e := NewFakeEmbedder()
|
||||
v1, err := e.Embed(context.Background(), "m", []string{"hello"})
|
||||
require.NoError(t, err)
|
||||
v2, err := e.Embed(context.Background(), "m", []string{"hello"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v1[0], v2[0], "same input must yield identical vector")
|
||||
require.Len(t, v1[0], PlatformEmbeddingDims)
|
||||
|
||||
other, err := e.Embed(context.Background(), "m", []string{"world"})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, v1[0], other[0], "different inputs must differ")
|
||||
}
|
||||
|
||||
func TestFakeEmbedder_Error(t *testing.T) {
|
||||
e := &FakeEmbedder{Dims: 4, Err: errors.New("boom")}
|
||||
_, err := e.Embed(context.Background(), "m", []string{"x"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRegistry_GetEmbedder_TypeAsserts(t *testing.T) {
|
||||
// OpenAI client implements Embedder.
|
||||
id := uuid.New()
|
||||
reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{
|
||||
id: {ID: id, Provider: "openai", BaseURL: "http://localhost", APIKey: "k"},
|
||||
}})
|
||||
emb, err := reg.GetEmbedder(context.Background(), id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, emb)
|
||||
require.Equal(t, PlatformEmbeddingDims, emb.EmbedDims("m"))
|
||||
}
|
||||
|
||||
func TestRegistry_GetEmbedder_AnthropicSupportedButUnsupportedEmbed(t *testing.T) {
|
||||
// Anthropic client implements Embedder (Embed returns ErrEmbeddingsUnsupported).
|
||||
id := uuid.New()
|
||||
reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{
|
||||
id: {ID: id, Provider: "anthropic", BaseURL: "http://localhost", APIKey: "k"},
|
||||
}})
|
||||
emb, err := reg.GetEmbedder(context.Background(), id)
|
||||
require.NoError(t, err)
|
||||
_, eerr := emb.Embed(context.Background(), "m", []string{"x"})
|
||||
require.ErrorIs(t, eerr, ErrEmbeddingsUnsupported)
|
||||
}
|
||||
@@ -64,3 +64,62 @@ func (f *FakeClient) Stream(ctx context.Context, modelID string, req Request) (<
|
||||
func (f *FakeClient) CountTokens(ctx context.Context, modelID string, req Request) (int, error) {
|
||||
return f.TokenCount, nil
|
||||
}
|
||||
|
||||
// FakeEmbedder is a deterministic Embedder for tests. Each input maps to a
|
||||
// fixed-length vector derived from its bytes, so identical text always yields the
|
||||
// same vector (lets tests assert idempotency / ranking without a real endpoint).
|
||||
type FakeEmbedder struct {
|
||||
Dims int // output dimension; defaults to PlatformEmbeddingDims when 0
|
||||
Err error // if non-nil, Embed returns it
|
||||
mu sync.Mutex
|
||||
Calls int
|
||||
Inputs []string // accumulated inputs across calls (for assertions)
|
||||
}
|
||||
|
||||
// NewFakeEmbedder constructs a FakeEmbedder with the platform dimension.
|
||||
func NewFakeEmbedder() *FakeEmbedder { return &FakeEmbedder{Dims: PlatformEmbeddingDims} }
|
||||
|
||||
var _ Embedder = (*FakeEmbedder)(nil)
|
||||
|
||||
func (e *FakeEmbedder) dims() int {
|
||||
if e.Dims > 0 {
|
||||
return e.Dims
|
||||
}
|
||||
return PlatformEmbeddingDims
|
||||
}
|
||||
|
||||
// EmbedDims returns the configured dimension.
|
||||
func (e *FakeEmbedder) EmbedDims(string) int { return e.dims() }
|
||||
|
||||
// Embed returns one deterministic vector per input. The vector is a normalized
|
||||
// projection of a small hash of the input across the dimension, so distinct
|
||||
// inputs are distinguishable and identical inputs are identical.
|
||||
func (e *FakeEmbedder) Embed(_ context.Context, _ string, inputs []string) ([][]float32, error) {
|
||||
e.mu.Lock()
|
||||
e.Calls++
|
||||
e.Inputs = append(e.Inputs, inputs...)
|
||||
err := e.Err
|
||||
e.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := e.dims()
|
||||
out := make([][]float32, len(inputs))
|
||||
for i, s := range inputs {
|
||||
v := make([]float32, d)
|
||||
// Seed from a stable rolling hash of the bytes.
|
||||
var h uint32 = 2166136261
|
||||
for _, b := range []byte(s) {
|
||||
h ^= uint32(b)
|
||||
h *= 16777619
|
||||
}
|
||||
for j := 0; j < d; j++ {
|
||||
h ^= uint32(j) * 2654435761
|
||||
h *= 16777619
|
||||
// Map to [-1,1).
|
||||
v[j] = float32(int32(h)) / float32(1<<31)
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ type GeminiClient struct {
|
||||
sdk *genai.Client
|
||||
}
|
||||
|
||||
// Compile-time assertion: GeminiClient must satisfy LLMClient.
|
||||
var _ LLMClient = (*GeminiClient)(nil)
|
||||
// Compile-time assertion: GeminiClient must satisfy LLMClient + Embedder.
|
||||
var (
|
||||
_ LLMClient = (*GeminiClient)(nil)
|
||||
_ Embedder = (*GeminiClient)(nil)
|
||||
)
|
||||
|
||||
// NewGemini 构造 GeminiClient。apiKey 不可空;baseURL 可空(用 SDK 默认端点)。
|
||||
func NewGemini(apiKey, baseURL string) (*GeminiClient, error) {
|
||||
@@ -142,3 +145,37 @@ func (c *GeminiClient) CountTokens(ctx context.Context, modelID string, req Requ
|
||||
}
|
||||
return int(res.TotalTokens), nil
|
||||
}
|
||||
|
||||
// EmbedDims returns the platform-standard embedding dimension. gemini-embedding
|
||||
// honors OutputDimensionality so Embed pins the output to PlatformEmbeddingDims.
|
||||
func (c *GeminiClient) EmbedDims(string) int { return PlatformEmbeddingDims }
|
||||
|
||||
// Embed calls EmbedContent with all inputs batched as separate Contents, pinning
|
||||
// OutputDimensionality to the fixed pgvector column width. Returns one vector per
|
||||
// input in order.
|
||||
func (c *GeminiClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
contents := make([]*genai.Content, len(inputs))
|
||||
for i, in := range inputs {
|
||||
contents[i] = &genai.Content{Role: "user", Parts: []*genai.Part{genai.NewPartFromText(in)}}
|
||||
}
|
||||
dims := int32(PlatformEmbeddingDims)
|
||||
resp, err := c.sdk.Models.EmbedContent(ctx, modelID, contents, &genai.EmbedContentConfig{
|
||||
OutputDimensionality: &dims,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini embeddings: %w", err)
|
||||
}
|
||||
if len(resp.Embeddings) != len(inputs) {
|
||||
return nil, fmt.Errorf("gemini embeddings: got %d vectors for %d inputs", len(resp.Embeddings), len(inputs))
|
||||
}
|
||||
out := make([][]float32, len(resp.Embeddings))
|
||||
for i, e := range resp.Embeddings {
|
||||
v := make([]float32, len(e.Values))
|
||||
copy(v, e.Values)
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
tiktoken "github.com/pkoukk/tiktoken-go"
|
||||
)
|
||||
|
||||
// Compile-time assertion: OpenAIClient must satisfy LLMClient.
|
||||
var _ LLMClient = (*OpenAIClient)(nil)
|
||||
// Compile-time assertion: OpenAIClient must satisfy LLMClient + Embedder.
|
||||
var (
|
||||
_ LLMClient = (*OpenAIClient)(nil)
|
||||
_ Embedder = (*OpenAIClient)(nil)
|
||||
)
|
||||
|
||||
// OpenAIClient 通过官方 SDK 调用 Chat Completions API。
|
||||
// baseURL 改写后兼容 DeepSeek / Qwen / Ollama / vLLM / Moonshot 等 OpenAI-compatible 端点。
|
||||
@@ -54,7 +57,7 @@ func toOpenAIMessages(msgs []Message) []openai.ChatCompletionMessageParamUnion {
|
||||
parts = append(parts, openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{
|
||||
URL: dataURI,
|
||||
}))
|
||||
// document/PDF 不翻译,由上层能力检查拦截
|
||||
// document/PDF 不翻译,由上层能力检查拦截
|
||||
}
|
||||
}
|
||||
out = append(out, openai.UserMessage(parts))
|
||||
@@ -199,3 +202,40 @@ func (c *OpenAIClient) CountTokens(_ context.Context, modelID string, req Reques
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// EmbedDims returns the platform-standard embedding dimension. text-embedding-3-*
|
||||
// models honor the `dimensions` request param, so Embed always pins the output to
|
||||
// PlatformEmbeddingDims to match the fixed pgvector column width.
|
||||
func (c *OpenAIClient) EmbedDims(string) int { return PlatformEmbeddingDims }
|
||||
|
||||
// Embed calls POST /embeddings once for the whole batch (input order preserved)
|
||||
// and converts the returned []float64 vectors to []float32 for pgvector storage.
|
||||
func (c *OpenAIClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resp, err := c.sdk.Embeddings.New(ctx, openai.EmbeddingNewParams{
|
||||
Model: openai.EmbeddingModel(modelID),
|
||||
Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: inputs},
|
||||
Dimensions: param.NewOpt(int64(PlatformEmbeddingDims)),
|
||||
EncodingFormat: openai.EmbeddingNewParamsEncodingFormatFloat,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai embeddings: %w", err)
|
||||
}
|
||||
if len(resp.Data) != len(inputs) {
|
||||
return nil, fmt.Errorf("openai embeddings: got %d vectors for %d inputs", len(resp.Data), len(inputs))
|
||||
}
|
||||
out := make([][]float32, len(resp.Data))
|
||||
for _, e := range resp.Data {
|
||||
if e.Index < 0 || int(e.Index) >= len(out) {
|
||||
return nil, fmt.Errorf("openai embeddings: out-of-range index %d", e.Index)
|
||||
}
|
||||
v := make([]float32, len(e.Embedding))
|
||||
for i, f := range e.Embedding {
|
||||
v[i] = float32(f)
|
||||
}
|
||||
out[e.Index] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -63,6 +63,21 @@ func (r *Registry) GetClient(ctx context.Context, id uuid.UUID) (LLMClient, erro
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetEmbedder 返回该 endpoint 对应的 Embedder(OpenAI / Gemini 实现;Anthropic
|
||||
// 客户端虽实现接口但 Embed 返回 ErrEmbeddingsUnsupported)。底层复用 GetClient
|
||||
// 的缓存与构造,再做类型断言。endpoint 的 client 未实现 Embedder 时返回错误。
|
||||
func (r *Registry) GetEmbedder(ctx context.Context, id uuid.UUID) (Embedder, error) {
|
||||
c, err := r.GetClient(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emb, ok := c.(Embedder)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("llm registry: endpoint %s provider %q does not support embeddings", id, c.Provider())
|
||||
}
|
||||
return emb, nil
|
||||
}
|
||||
|
||||
// Invalidate 删除该 endpoint 的缓存条目。endpoint CRUD 后由 service 层调用。
|
||||
func (r *Registry) Invalidate(id uuid.UUID) {
|
||||
r.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package metrics 提供进程内的 Prometheus 指标注册中心与一组类型化的记录助手。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 用私有 *prometheus.Registry(NOT prometheus.DefaultRegisterer),避免在
|
||||
// 热重载 / 测试中重复注册默认注册表导致 panic。
|
||||
// - 通过 promhttp.HandlerFor(reg, ...) 暴露 Handler();调用方(router)负责
|
||||
// 鉴权门禁(/metrics 泄露成本/会话数,必须 admin/内网受限)。
|
||||
// - Record* 助手对外提供窄 API:其它模块只依赖这几个方法(或 acp 包内定义的
|
||||
// 窄接口),无需 import prometheus。
|
||||
//
|
||||
// 指标清单(spec §11 §1205-1207):
|
||||
// - acp_sessions_active GaugeFunc —— 当前活跃 ACP 会话数
|
||||
// - acp_session_exit_total{status} CounterVec —— 会话退出计数(exited/crashed/…)
|
||||
// - acp_permission_decisions_total{outcome} CounterVec —— 权限决策计数
|
||||
// (auto/approved/denied/expired)
|
||||
// - jobs_dead_letter_total{type} CounterVec —— 死信任务计数(按 job type)
|
||||
// - llm_cost_usd_total GaugeFunc —— 累计 LLM 成本(USD)
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// Metrics 持有私有注册表与各 collector。零值不可用,必须经 New 构造。
|
||||
type Metrics struct {
|
||||
reg *prometheus.Registry
|
||||
|
||||
sessionExits *prometheus.CounterVec
|
||||
permDecisions *prometheus.CounterVec
|
||||
deadLetters *prometheus.CounterVec
|
||||
}
|
||||
|
||||
// New 构造 Metrics。activeSessions / llmCostTotal 是供 GaugeFunc 回调的取值
|
||||
// 函数;二者可为 nil(nil 时对应 gauge 恒为 0),便于测试 / feature-off。
|
||||
func New(activeSessions func() float64, llmCostTotal func() float64) *Metrics {
|
||||
reg := prometheus.NewRegistry()
|
||||
|
||||
m := &Metrics{
|
||||
reg: reg,
|
||||
sessionExits: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "acp_session_exit_total",
|
||||
Help: "Total number of ACP agent session exits, labeled by terminal status.",
|
||||
}, []string{"status"}),
|
||||
permDecisions: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "acp_permission_decisions_total",
|
||||
Help: "Total number of ACP tool-permission decisions, labeled by outcome.",
|
||||
}, []string{"outcome"}),
|
||||
deadLetters: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "jobs_dead_letter_total",
|
||||
Help: "Total number of jobs that exhausted retries and were dead-lettered, by job type.",
|
||||
}, []string{"type"}),
|
||||
}
|
||||
|
||||
reg.MustRegister(m.sessionExits, m.permDecisions, m.deadLetters)
|
||||
|
||||
if activeSessions == nil {
|
||||
activeSessions = func() float64 { return 0 }
|
||||
}
|
||||
if llmCostTotal == nil {
|
||||
llmCostTotal = func() float64 { return 0 }
|
||||
}
|
||||
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "acp_sessions_active",
|
||||
Help: "Current number of active (starting/running) ACP agent sessions.",
|
||||
}, activeSessions))
|
||||
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "llm_cost_usd_total",
|
||||
Help: "Cumulative LLM usage cost in USD across all endpoints.",
|
||||
}, llmCostTotal))
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Handler 返回 /metrics 的 http.Handler,绑定到本 Metrics 的私有注册表。
|
||||
func (m *Metrics) Handler() http.Handler {
|
||||
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
|
||||
}
|
||||
|
||||
// Registry 暴露底层注册表,便于测试直接 Gather。
|
||||
func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
|
||||
|
||||
// RecordSessionExit 记录一次会话退出。status 取 acp.SessionStatus 字符串值
|
||||
// (exited/crashed/…)。
|
||||
func (m *Metrics) RecordSessionExit(status string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.sessionExits.WithLabelValues(status).Inc()
|
||||
}
|
||||
|
||||
// RecordPermissionDecision 记录一次权限决策。outcome 取 auto/approved/denied/expired。
|
||||
func (m *Metrics) RecordPermissionDecision(outcome string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.permDecisions.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
|
||||
// RecordDeadLetter 记录一次死信任务,按 job type 标签累加。
|
||||
func (m *Metrics) RecordDeadLetter(jobType string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.deadLetters.WithLabelValues(jobType).Inc()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func scrape(t *testing.T, m *Metrics) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
m.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("metrics handler status = %d, want 200", rec.Code)
|
||||
}
|
||||
body, _ := io.ReadAll(rec.Result().Body)
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func TestNewRegistersAllCollectors(t *testing.T) {
|
||||
m := New(func() float64 { return 3 }, func() float64 { return 12.5 })
|
||||
// CounterVecs emit no series until a label set is observed; touch each once
|
||||
// so the metric family name surfaces in the scrape.
|
||||
m.RecordSessionExit("exited")
|
||||
m.RecordPermissionDecision("auto")
|
||||
m.RecordDeadLetter("webhook.deliver")
|
||||
body := scrape(t, m)
|
||||
|
||||
for _, name := range []string{
|
||||
"acp_sessions_active",
|
||||
"acp_session_exit_total",
|
||||
"acp_permission_decisions_total",
|
||||
"jobs_dead_letter_total",
|
||||
"llm_cost_usd_total",
|
||||
} {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("metrics output missing %q\n%s", name, body)
|
||||
}
|
||||
}
|
||||
// GaugeFunc values surface immediately even without any Record* call.
|
||||
if !strings.Contains(body, "acp_sessions_active 3") {
|
||||
t.Errorf("acp_sessions_active gauge not reporting func value; body:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "llm_cost_usd_total 12.5") {
|
||||
t.Errorf("llm_cost_usd_total gauge not reporting func value; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHelpersIncrementWithLabels(t *testing.T) {
|
||||
m := New(nil, nil)
|
||||
m.RecordSessionExit("crashed")
|
||||
m.RecordSessionExit("crashed")
|
||||
m.RecordSessionExit("exited")
|
||||
m.RecordPermissionDecision("approved")
|
||||
m.RecordPermissionDecision("denied")
|
||||
m.RecordPermissionDecision("expired")
|
||||
m.RecordDeadLetter("webhook.deliver")
|
||||
|
||||
body := scrape(t, m)
|
||||
wants := []string{
|
||||
`acp_session_exit_total{status="crashed"} 2`,
|
||||
`acp_session_exit_total{status="exited"} 1`,
|
||||
`acp_permission_decisions_total{outcome="approved"} 1`,
|
||||
`acp_permission_decisions_total{outcome="denied"} 1`,
|
||||
`acp_permission_decisions_total{outcome="expired"} 1`,
|
||||
`jobs_dead_letter_total{type="webhook.deliver"} 1`,
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(body, w) {
|
||||
t.Errorf("metrics output missing %q\n%s", w, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryIsolatedNotGlobalDefault(t *testing.T) {
|
||||
// Two independent Metrics must not collide (would panic if both used the
|
||||
// global default registry).
|
||||
_ = New(nil, nil)
|
||||
_ = New(nil, nil)
|
||||
}
|
||||
|
||||
func TestNilReceiverRecordSafe(t *testing.T) {
|
||||
var m *Metrics
|
||||
// Must not panic — callers may hold a nil *Metrics when metrics disabled.
|
||||
m.RecordSessionExit("exited")
|
||||
m.RecordPermissionDecision("auto")
|
||||
m.RecordDeadLetter("x")
|
||||
}
|
||||
@@ -21,6 +21,8 @@ type Dispatcher struct {
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
wg sync.WaitGroup
|
||||
// hub 是可选的实时推送中心;落库成功后 Publish。nil 时仅持久化 + 外发通道。
|
||||
hub StreamHub
|
||||
}
|
||||
|
||||
// NewDispatcher 用 Repository 与 Logger 构造 Dispatcher。注册 Notifier 通过
|
||||
@@ -35,6 +37,11 @@ func (d *Dispatcher) Register(n Notifier) {
|
||||
d.notifiers = append(d.notifiers, n)
|
||||
}
|
||||
|
||||
// SetStreamHub 注入实时推送中心(装配期)。落库成功后会向其 Publish。
|
||||
func (d *Dispatcher) SetStreamHub(h StreamHub) {
|
||||
d.hub = h
|
||||
}
|
||||
|
||||
// Dispatch 同步落库;落库失败返回错误,不再异步投递。落库成功后,
|
||||
// fire-and-forget 派发给所有外部 notifier,单个 notifier 的失败仅记日志,
|
||||
// 不阻塞其它通道;调用方无法感知异步投递结果。
|
||||
@@ -54,6 +61,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, msg Message) error {
|
||||
if err := d.repo.Insert(persistCtx, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
// 落库成功后才推送实时事件(推送失败 / 慢订阅者不影响落库的权威 inbox)。
|
||||
if d.hub != nil {
|
||||
d.hub.Publish(msg.UserID, msg)
|
||||
}
|
||||
for _, n := range d.notifiers {
|
||||
d.wg.Add(1)
|
||||
go func(n Notifier) {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -23,12 +25,23 @@ import (
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// Encryptor 是 prefs handler 加密 im_webhook_url 所需的窄接口,由 crypto.Encryptor
|
||||
// 满足。以接口注入避免 notify 依赖 crypto 包。
|
||||
type Encryptor interface {
|
||||
Encrypt(plaintext []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// Handler 暴露 notify 模块的 HTTP 接口;依赖 Repository 与 SessionResolver。
|
||||
// 不直接持有 Dispatcher——发送通知由其它模块通过 Dispatcher 完成,handler
|
||||
// 仅服务 inbox 读取与状态变更。
|
||||
type Handler struct {
|
||||
repo Repository
|
||||
resolver middleware.SessionResolver
|
||||
// hub 为可选的实时推送中心;非 nil 时暴露 GET /stream SSE 端点。
|
||||
hub StreamHub
|
||||
// prefs/enc 为可选的偏好仓库与加密器;二者均非 nil 时暴露 GET/PUT /prefs。
|
||||
prefs PrefsRepository
|
||||
enc Encryptor
|
||||
}
|
||||
|
||||
// NewHandler 构造 notify 模块的 HTTP handler。
|
||||
@@ -36,6 +49,19 @@ func NewHandler(repo Repository, resolver middleware.SessionResolver) *Handler {
|
||||
return &Handler{repo: repo, resolver: resolver}
|
||||
}
|
||||
|
||||
// WithStreamHub 注入实时推送中心,启用 GET /api/v1/notifications/stream SSE。
|
||||
func (h *Handler) WithStreamHub(hub StreamHub) *Handler {
|
||||
h.hub = hub
|
||||
return h
|
||||
}
|
||||
|
||||
// WithPrefs 注入偏好仓库与加密器,启用 GET/PUT /api/v1/notifications/prefs。
|
||||
func (h *Handler) WithPrefs(prefs PrefsRepository, enc Encryptor) *Handler {
|
||||
h.prefs = prefs
|
||||
h.enc = enc
|
||||
return h
|
||||
}
|
||||
|
||||
// Mount 把 inbox 路由挂到给定 chi.Router 上。Auth 中间件在子路由内统一注入。
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
r.Route("/api/v1/notifications", func(r chi.Router) {
|
||||
@@ -44,9 +70,74 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Get("/unread-count", h.unreadCount)
|
||||
r.Patch("/read-all", h.readAll)
|
||||
r.Patch("/{id}/read", h.markRead)
|
||||
if h.hub != nil {
|
||||
r.Get("/stream", h.stream)
|
||||
}
|
||||
if h.prefs != nil && h.enc != nil {
|
||||
r.Get("/prefs", h.getPrefs)
|
||||
r.Put("/prefs", h.putPrefs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// stream 实现 GET /api/v1/notifications/stream:把该用户的实时通知以 SSE 推送。
|
||||
// 客户端用 EventSource 订阅;连接关闭时(r.Context().Done)自动退订。
|
||||
func (h *Handler) stream(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeUnauthorized, "未认证"))
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ch, cancel := h.hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeNotificationSSE(w, flusher, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeNotificationSSE 把一条通知序列化为 SSE 帧并 flush。
|
||||
func writeNotificationSSE(w http.ResponseWriter, f http.Flusher, m Message) {
|
||||
dto := notificationDTO{
|
||||
ID: m.ID.String(),
|
||||
Topic: m.Topic,
|
||||
Severity: string(m.Severity),
|
||||
Title: m.Title,
|
||||
Body: m.Body,
|
||||
Link: m.Link,
|
||||
Metadata: m.Metadata,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if m.ReadAt != nil {
|
||||
s := m.ReadAt.UTC().Format(time.RFC3339)
|
||||
dto.ReadAt = &s
|
||||
}
|
||||
data, _ := json.Marshal(dto)
|
||||
fmt.Fprintf(w, "event: notification\ndata: %s\n\n", data)
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// notificationDTO 是 inbox 单条通知的对外 JSON 形态。created_at/read_at
|
||||
// 统一序列化为 RFC3339 字符串;read_at 为 nil 时省略(omitempty)。
|
||||
type notificationDTO struct {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Package notify 内的 notifier_email.go 实现 EmailNotifier:通过 SMTP(net/smtp)
|
||||
// 把通知投递为邮件。它实现 Notifier 接口,由 Dispatcher 在配置启用时注册。
|
||||
//
|
||||
// 投递前置条件(任一不满足则静默跳过,返回 nil——per-channel 失败/跳过对
|
||||
// Dispatcher 非致命):
|
||||
// - 全局 cfg.Enabled 为 true;
|
||||
// - 用户 notification_prefs.email_enabled 为 true;
|
||||
// - 消息 severity 达到用户 min_severity;
|
||||
// - 能解析出该用户的收件邮箱。
|
||||
//
|
||||
// SMTP 调用经 transport 抽象注入,测试用 fake transport 断言收件人/正文形态,
|
||||
// 绝不真的发邮件;生产用 smtpTransport(net/smtp.SendMail)。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// EmailConfig 是 EmailNotifier 的配置,对应 cfg.Notify.Email。
|
||||
type EmailConfig struct {
|
||||
Enabled bool
|
||||
SMTPHost string
|
||||
Port int
|
||||
From string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// addr 返回 "host:port" 形式的 SMTP 地址。
|
||||
func (c EmailConfig) addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.SMTPHost, c.Port)
|
||||
}
|
||||
|
||||
// EmailLookup 把 userID 解析为收件邮箱。由 user.Service 适配注入,避免 notify
|
||||
// 反向依赖 user 包造成的循环导入。
|
||||
type EmailLookup interface {
|
||||
EmailForUser(ctx context.Context, userID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
// SMTPTransport 抽象一次 SMTP 发送,便于测试注入 fake。参数与 net/smtp.SendMail
|
||||
// 对齐:addr 形如 "host:port",auth 可为 nil(无认证),from 为发件人,to 为收件人
|
||||
// 列表,msg 为完整 RFC 822 报文(含头)。
|
||||
type SMTPTransport interface {
|
||||
Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error
|
||||
}
|
||||
|
||||
// smtpTransport 是 SMTPTransport 的生产实现,直接委托 net/smtp.SendMail。
|
||||
type smtpTransport struct{}
|
||||
|
||||
func (smtpTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
return smtp.SendMail(addr, auth, from, to, msg)
|
||||
}
|
||||
|
||||
// EmailNotifier 通过 SMTP 投递邮件,实现 Notifier。
|
||||
type EmailNotifier struct {
|
||||
cfg EmailConfig
|
||||
prefs PrefsRepository
|
||||
lookup EmailLookup
|
||||
tr SMTPTransport
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewEmailNotifier 构造 EmailNotifier。tr 为 nil 时用生产 net/smtp 实现;
|
||||
// log 为 nil 时回落到 slog.Default。
|
||||
func NewEmailNotifier(cfg EmailConfig, prefs PrefsRepository, lookup EmailLookup, tr SMTPTransport, log *slog.Logger) *EmailNotifier {
|
||||
if tr == nil {
|
||||
tr = smtpTransport{}
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &EmailNotifier{cfg: cfg, prefs: prefs, lookup: lookup, tr: tr, log: log}
|
||||
}
|
||||
|
||||
// Name 返回 ChannelEmail。
|
||||
func (n *EmailNotifier) Name() Channel { return ChannelEmail }
|
||||
|
||||
// Send 在满足全部前置条件时发送一封邮件;任一条件不满足静默返回 nil。
|
||||
// SMTP 调用失败返回错误(Dispatcher 仅记日志,不影响其它通道)。
|
||||
func (n *EmailNotifier) Send(ctx context.Context, msg Message) error {
|
||||
if !n.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
p, err := n.prefs.Get(ctx, msg.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.EmailEnabled {
|
||||
return nil
|
||||
}
|
||||
if !meetsMinSeverity(msg.Severity, p.MinSeverity) {
|
||||
return nil
|
||||
}
|
||||
to, err := n.lookup.EmailForUser(ctx, msg.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(to) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
if n.cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", n.cfg.Username, n.cfg.Password, n.cfg.SMTPHost)
|
||||
}
|
||||
body := buildEmailMessage(n.cfg.From, to, msg)
|
||||
if err := n.tr.Send(n.cfg.addr(), auth, n.cfg.From, []string{to}, body); err != nil {
|
||||
return fmt.Errorf("smtp send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildEmailMessage 组装一封最小 RFC 822 纯文本邮件报文(含 From/To/Subject 头)。
|
||||
// 主题前缀用 severity 便于客户端过滤;正文带标题、正文与可选链接。
|
||||
func buildEmailMessage(from, to string, msg Message) []byte {
|
||||
subject := msg.Title
|
||||
if subject == "" {
|
||||
subject = msg.Topic
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: [%s] %s\r\n", strings.ToUpper(string(msg.Severity)), subject)
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
|
||||
b.WriteString("\r\n")
|
||||
if msg.Body != "" {
|
||||
b.WriteString(msg.Body)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
if msg.Link != "" {
|
||||
fmt.Fprintf(&b, "\r\n%s\r\n", msg.Link)
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakePrefsRepo 是 PrefsRepository 的内存实现,供 notifier 测试断言偏好过滤。
|
||||
type fakePrefsRepo struct {
|
||||
mu sync.Mutex
|
||||
m map[uuid.UUID]Prefs
|
||||
err error
|
||||
}
|
||||
|
||||
func newFakePrefs() *fakePrefsRepo { return &fakePrefsRepo{m: map[uuid.UUID]Prefs{}} }
|
||||
|
||||
func (f *fakePrefsRepo) set(p Prefs) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.m[p.UserID] = p
|
||||
}
|
||||
|
||||
func (f *fakePrefsRepo) Get(_ context.Context, userID uuid.UUID) (Prefs, error) {
|
||||
if f.err != nil {
|
||||
return Prefs{}, f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if p, ok := f.m[userID]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return DefaultPrefs(userID), nil
|
||||
}
|
||||
|
||||
func (f *fakePrefsRepo) Upsert(_ context.Context, p Prefs) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.set(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureTransport 记录最后一次 SMTP 发送,便于断言收件人 / 报文形态。
|
||||
type captureTransport struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
addr string
|
||||
from string
|
||||
to []string
|
||||
msg []byte
|
||||
auth smtp.Auth
|
||||
}
|
||||
|
||||
func (c *captureTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls++
|
||||
c.addr = addr
|
||||
c.from = from
|
||||
c.to = to
|
||||
c.msg = msg
|
||||
c.auth = auth
|
||||
return nil
|
||||
}
|
||||
|
||||
// staticLookup 总是返回固定收件邮箱。
|
||||
type staticLookup struct {
|
||||
email string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticLookup) EmailForUser(context.Context, uuid.UUID) (string, error) {
|
||||
return s.email, s.err
|
||||
}
|
||||
|
||||
func emailCfg() EmailConfig {
|
||||
return EmailConfig{Enabled: true, SMTPHost: "smtp.test", Port: 587, From: "noreply@test"}
|
||||
}
|
||||
|
||||
func TestEmailNotifier_SendsWhenEnabledAndSeverityMet(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityWarning})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
err := n.Send(context.Background(), Message{
|
||||
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
|
||||
Title: "Approval needed", Body: "session X wants to run rm", Link: "/approvals",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, tr.calls)
|
||||
require.Equal(t, "smtp.test:587", tr.addr)
|
||||
require.Equal(t, "noreply@test", tr.from)
|
||||
require.Equal(t, []string{"user@test"}, tr.to)
|
||||
require.Nil(t, tr.auth, "无 username 时不应携带 auth")
|
||||
|
||||
body := string(tr.msg)
|
||||
require.Contains(t, body, "To: user@test")
|
||||
require.Contains(t, body, "Subject: [WARNING] Approval needed")
|
||||
require.Contains(t, body, "session X wants to run rm")
|
||||
require.Contains(t, body, "/approvals")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_DisabledChannelSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: false, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, tr.calls, "email_enabled=false 应跳过发送")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_BelowMinSeveritySkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityError})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
|
||||
require.Equal(t, 0, tr.calls, "低于 min_severity 应跳过")
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 1, tr.calls, "达到 min_severity 应发送")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_GloballyDisabledSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
cfg := emailCfg()
|
||||
cfg.Enabled = false
|
||||
n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, tr.calls)
|
||||
}
|
||||
|
||||
func TestEmailNotifier_NoRecipientSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: " "}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, tr.calls, "空收件人应静默跳过")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_UsesPlainAuthWhenUsernameSet(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
cfg := emailCfg()
|
||||
cfg.Username = "u"
|
||||
cfg.Password = "p"
|
||||
n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError, Title: "x"}))
|
||||
require.Equal(t, 1, tr.calls)
|
||||
require.NotNil(t, tr.auth, "配置 username 时应携带 PlainAuth")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_NameIsEmailChannel(t *testing.T) {
|
||||
n := NewEmailNotifier(emailCfg(), newFakePrefs(), staticLookup{}, &captureTransport{}, newTestLogger())
|
||||
require.Equal(t, ChannelEmail, n.Name())
|
||||
}
|
||||
|
||||
func TestBuildEmailMessage_Headers(t *testing.T) {
|
||||
body := string(buildEmailMessage("from@test", "to@test", Message{
|
||||
Topic: "t", Severity: SeverityError, Title: "Title", Body: "Body", Link: "http://l",
|
||||
}))
|
||||
require.True(t, strings.HasPrefix(body, "From: from@test\r\n"))
|
||||
require.Contains(t, body, "Subject: [ERROR] Title")
|
||||
require.Contains(t, body, "Content-Type: text/plain")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_PrefsErrorPropagates(t *testing.T) {
|
||||
prefs := newFakePrefs()
|
||||
prefs.err = errors.New("db down")
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "u@t"}, &captureTransport{}, newTestLogger())
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uuid.New(), Severity: SeverityError}))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package notify 内的 notifier_im.go 实现 IMNotifier:以通用 webhook POST
|
||||
// (Slack incoming-webhook 兼容形态)把通知投递到用户配置的 IM 地址。
|
||||
//
|
||||
// 投递地址是 per-user 的 im_webhook_url,以密文落库于 notification_prefs
|
||||
// (im_webhook_url_enc);发送时经 Decryptor 解密,明文 URL 不在领域内存层
|
||||
// 长期驻留。投递前置条件与 EmailNotifier 对称:
|
||||
// - 全局 cfg.Enabled 为 true;
|
||||
// - 用户 im_enabled 为 true 且配置了 webhook URL;
|
||||
// - 消息 severity 达到用户 min_severity。
|
||||
//
|
||||
// 任一不满足静默返回 nil;HTTP/解密失败返回错误(Dispatcher 仅记日志)。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IMConfig 是 IMNotifier 的全局配置,对应 cfg.Notify.IM。
|
||||
type IMConfig struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Decryptor 抽象 im_webhook_url 密文的解密。由 crypto.Encryptor 满足
|
||||
// (Decrypt([]byte) ([]byte, error)),以窄接口注入避免 notify 依赖 crypto 包。
|
||||
type Decryptor interface {
|
||||
Decrypt(ciphertext []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// httpDoer 抽象单次 HTTP 调用,便于测试注入 fake transport。
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// IMNotifier 通过 webhook POST 投递 IM 消息,实现 Notifier。
|
||||
type IMNotifier struct {
|
||||
cfg IMConfig
|
||||
prefs PrefsRepository
|
||||
dec Decryptor
|
||||
cl httpDoer
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewIMNotifier 构造 IMNotifier。cl 为 nil 时用默认 http.Client(含 30s 超时
|
||||
// 由 Dispatcher 的 sendCtx 兜底);log 为 nil 时回落 slog.Default。
|
||||
func NewIMNotifier(cfg IMConfig, prefs PrefsRepository, dec Decryptor, cl httpDoer, log *slog.Logger) *IMNotifier {
|
||||
if cl == nil {
|
||||
cl = http.DefaultClient
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &IMNotifier{cfg: cfg, prefs: prefs, dec: dec, cl: cl, log: log}
|
||||
}
|
||||
|
||||
// Name 返回 ChannelIM。
|
||||
func (n *IMNotifier) Name() Channel { return ChannelIM }
|
||||
|
||||
// Send 在满足全部前置条件时向用户的 IM webhook POST 一条消息。
|
||||
func (n *IMNotifier) Send(ctx context.Context, msg Message) error {
|
||||
if !n.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
p, err := n.prefs.Get(ctx, msg.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.IMEnabled || len(p.ImWebhookURLEnc) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !meetsMinSeverity(msg.Severity, p.MinSeverity) {
|
||||
return nil
|
||||
}
|
||||
urlBytes, err := n.dec.Decrypt(p.ImWebhookURLEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt im webhook url: %w", err)
|
||||
}
|
||||
url := strings.TrimSpace(string(urlBytes))
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(buildIMPayload(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := n.cl.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("im webhook post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("im webhook status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildIMPayload 把 Message 渲染为 Slack incoming-webhook 兼容的 JSON 体。
|
||||
// 主要字段是 text(含级别、标题、正文、可选链接);附加结构化字段方便富客户端。
|
||||
func buildIMPayload(msg Message) map[string]any {
|
||||
title := msg.Title
|
||||
if title == "" {
|
||||
title = msg.Topic
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[%s] %s", strings.ToUpper(string(msg.Severity)), title)
|
||||
if msg.Body != "" {
|
||||
fmt.Fprintf(&b, "\n%s", msg.Body)
|
||||
}
|
||||
if msg.Link != "" {
|
||||
fmt.Fprintf(&b, "\n%s", msg.Link)
|
||||
}
|
||||
return map[string]any{
|
||||
"text": b.String(),
|
||||
"topic": msg.Topic,
|
||||
"severity": string(msg.Severity),
|
||||
"title": title,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// reverseDecryptor 是测试用 Decryptor:把密文逐字节取反作为 "解密",与
|
||||
// reverseEncrypt 配对,确保 IMNotifier 走解密路径而非裸读明文。
|
||||
type reverseDecryptor struct{ err error }
|
||||
|
||||
func reverseBytes(b []byte) []byte {
|
||||
out := make([]byte, len(b))
|
||||
for i, c := range b {
|
||||
out[i] = ^c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d reverseDecryptor) Decrypt(ct []byte) ([]byte, error) {
|
||||
if d.err != nil {
|
||||
return nil, d.err
|
||||
}
|
||||
return reverseBytes(ct), nil
|
||||
}
|
||||
|
||||
// captureDoer 记录最后一次 HTTP 请求并返回固定响应码。
|
||||
type captureDoer struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
url string
|
||||
body []byte
|
||||
ctype string
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *captureDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
c.calls++
|
||||
c.url = req.URL.String()
|
||||
c.ctype = req.Header.Get("Content-Type")
|
||||
if req.Body != nil {
|
||||
c.body, _ = io.ReadAll(req.Body)
|
||||
}
|
||||
st := c.status
|
||||
if st == 0 {
|
||||
st = http.StatusOK
|
||||
}
|
||||
return &http.Response{StatusCode: st, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
}
|
||||
|
||||
func imPrefs(uid uuid.UUID, enabled bool, sev Severity, webhookEnc []byte) Prefs {
|
||||
return Prefs{UserID: uid, IMEnabled: enabled, MinSeverity: sev, ImWebhookURLEnc: webhookEnc}
|
||||
}
|
||||
|
||||
func TestIMNotifier_PostsDecryptedWebhookWithSlackPayload(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
enc := reverseBytes([]byte("https://hooks.slack.test/abc"))
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityWarning, enc))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
err := n.Send(context.Background(), Message{
|
||||
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
|
||||
Title: "Approval needed", Body: "run rm", Link: "/approvals",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, doer.calls)
|
||||
require.Equal(t, "https://hooks.slack.test/abc", doer.url, "应 POST 到解密后的 URL")
|
||||
require.Equal(t, "application/json", doer.ctype)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(doer.body, &payload))
|
||||
require.Equal(t, "acp.permission_pending", payload["topic"])
|
||||
require.Equal(t, "warning", payload["severity"])
|
||||
require.Equal(t, "Approval needed", payload["title"])
|
||||
text, _ := payload["text"].(string)
|
||||
require.Contains(t, text, "[WARNING] Approval needed")
|
||||
require.Contains(t, text, "run rm")
|
||||
require.Contains(t, text, "/approvals")
|
||||
}
|
||||
|
||||
func TestIMNotifier_DisabledChannelSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, false, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_NoWebhookSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, nil))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls, "未配置 webhook 应跳过")
|
||||
}
|
||||
|
||||
func TestIMNotifier_BelowMinSeveritySkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityError, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_GloballyDisabledSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: false}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_DecryptErrorReturnsErr(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, []byte("garbage")))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{err: errors.New("bad key")}, doer, newTestLogger())
|
||||
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_Non2xxReturnsErr(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{status: http.StatusInternalServerError}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 1, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_HTTPErrorReturnsErr(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{err: errors.New("dial fail")}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
}
|
||||
|
||||
func TestIMNotifier_NameIsIMChannel(t *testing.T) {
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, newFakePrefs(), reverseDecryptor{}, &captureDoer{}, newTestLogger())
|
||||
require.Equal(t, ChannelIM, n.Name())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package notify 内的 prefs.go 实现 per-user 通知偏好(notification_prefs 表)。
|
||||
//
|
||||
// 偏好控制 email / im 两条外发通道的开关与最低投递级别(min_severity)。in-app
|
||||
// inbox(持久化 + SSE)始终投递,不受偏好影响。im_webhook_url 以密文落库
|
||||
// (im_webhook_url_enc BYTEA,经 crypto.Encryptor 加密),repo 层只搬运密文,
|
||||
// 解密由 IMNotifier 在发送时按需进行——避免明文 webhook URL 进入领域内存层。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
notifysqlc "github.com/yan1h/agent-coding-workflow/internal/infra/notify/sqlc"
|
||||
)
|
||||
|
||||
// Prefs 是一个用户的通知偏好领域形态。ImWebhookURLEnc 保持密文(BYTEA 原样),
|
||||
// 解密延迟到 IMNotifier.Send。MinSeverity 取值与 Severity 枚举一致。
|
||||
type Prefs struct {
|
||||
UserID uuid.UUID
|
||||
EmailEnabled bool
|
||||
IMEnabled bool
|
||||
ImWebhookURLEnc []byte
|
||||
MinSeverity Severity
|
||||
}
|
||||
|
||||
// DefaultPrefs 是用户尚未显式设置偏好时的兜底:email / im 均关闭,最低级别
|
||||
// warning(与 notification_prefs.min_severity 列默认值一致)。
|
||||
func DefaultPrefs(userID uuid.UUID) Prefs {
|
||||
return Prefs{
|
||||
UserID: userID,
|
||||
EmailEnabled: false,
|
||||
IMEnabled: false,
|
||||
MinSeverity: SeverityWarning,
|
||||
}
|
||||
}
|
||||
|
||||
// PrefsRepository 抽象 notification_prefs 的读写。Get 在无记录时返回
|
||||
// DefaultPrefs(不报 not-found),让上层无需区分 "未设置" 与 "全关"。
|
||||
type PrefsRepository interface {
|
||||
Get(ctx context.Context, userID uuid.UUID) (Prefs, error)
|
||||
Upsert(ctx context.Context, p Prefs) error
|
||||
}
|
||||
|
||||
// pgPrefsRepo 是 PrefsRepository 的 Postgres 实现。
|
||||
type pgPrefsRepo struct {
|
||||
q *notifysqlc.Queries
|
||||
}
|
||||
|
||||
// NewPostgresPrefsRepository 用现有 pgxpool 构造偏好仓库。
|
||||
func NewPostgresPrefsRepository(pool *pgxpool.Pool) PrefsRepository {
|
||||
return &pgPrefsRepo{q: notifysqlc.New(pool)}
|
||||
}
|
||||
|
||||
// Get 读取用户偏好。无记录时返回 DefaultPrefs(userID) 与 nil error。
|
||||
func (r *pgPrefsRepo) Get(ctx context.Context, userID uuid.UUID) (Prefs, error) {
|
||||
row, err := r.q.GetNotificationPrefs(ctx, toPgUUID(userID))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultPrefs(userID), nil
|
||||
}
|
||||
return Prefs{}, errs.Wrap(err, errs.CodeInternal, "get notification prefs")
|
||||
}
|
||||
return Prefs{
|
||||
UserID: fromPgUUID(row.UserID),
|
||||
EmailEnabled: row.EmailEnabled,
|
||||
IMEnabled: row.ImEnabled,
|
||||
ImWebhookURLEnc: row.ImWebhookUrlEnc,
|
||||
MinSeverity: Severity(row.MinSeverity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upsert 写入(或更新)用户偏好。ImWebhookURLEnc 为 nil 时写 NULL。
|
||||
func (r *pgPrefsRepo) Upsert(ctx context.Context, p Prefs) error {
|
||||
if err := r.q.UpsertNotificationPrefs(ctx, notifysqlc.UpsertNotificationPrefsParams{
|
||||
UserID: toPgUUID(p.UserID),
|
||||
EmailEnabled: p.EmailEnabled,
|
||||
ImEnabled: p.IMEnabled,
|
||||
ImWebhookUrlEnc: p.ImWebhookURLEnc,
|
||||
MinSeverity: string(p.MinSeverity),
|
||||
}); err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "upsert notification prefs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// severityRank 把 Severity 映射为可比较的等级序数,用于 min_severity 过滤。
|
||||
// 未知值按最低(info)处理,确保 "至少投递" 而非误丢。
|
||||
func severityRank(s Severity) int {
|
||||
switch s {
|
||||
case SeverityError:
|
||||
return 2
|
||||
case SeverityWarning:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// meetsMinSeverity 报告消息级别是否达到(>=)用户设定的最低投递级别。
|
||||
func meetsMinSeverity(msg Severity, min Severity) bool {
|
||||
return severityRank(msg) >= severityRank(min)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package notify 内的 prefs_handler.go 实现 per-user 通知偏好的 HTTP 接口:
|
||||
//
|
||||
// GET /api/v1/notifications/prefs → 当前用户的偏好(im_webhook_url 不回显明文,
|
||||
// 仅返回 im_webhook_url_set 布尔标记是否已配置)。
|
||||
// PUT /api/v1/notifications/prefs → 整体更新偏好。im_webhook_url 提供非空字符串
|
||||
// 则加密落库;提供空字符串则清除;字段省略则保留原值。
|
||||
//
|
||||
// im_webhook_url 经 Encryptor 加密为 im_webhook_url_enc BYTEA。偏好只在 Handler
|
||||
// 注入了 prefsRepo + enc 后才挂载(WithPrefs),未配置 crypto 时该路由不暴露。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// prefsDTO 是偏好的对外 JSON 形态。im_webhook_url 绝不回显明文:读取时只暴露
|
||||
// im_webhook_url_set 标记是否已配置,写入时通过单独的可空字段提交。
|
||||
type prefsDTO struct {
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
IMEnabled bool `json:"im_enabled"`
|
||||
IMWebhookURLSet bool `json:"im_webhook_url_set"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
}
|
||||
|
||||
// prefsUpdateReq 是 PUT 请求体。IMWebhookURL 用指针区分三态:
|
||||
// - nil(字段省略) → 保留原密文不变;
|
||||
// - 指向空字符串 "" → 清除已配置的 webhook;
|
||||
// - 指向非空字符串 → 加密为新密文。
|
||||
type prefsUpdateReq struct {
|
||||
EmailEnabled *bool `json:"email_enabled"`
|
||||
IMEnabled *bool `json:"im_enabled"`
|
||||
IMWebhookURL *string `json:"im_webhook_url"`
|
||||
MinSeverity *string `json:"min_severity"`
|
||||
}
|
||||
|
||||
// getPrefs 处理 GET /api/v1/notifications/prefs。
|
||||
func (h *Handler) getPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeUnauthorized, "未认证"))
|
||||
return
|
||||
}
|
||||
p, err := h.prefs.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, prefsDTO{
|
||||
EmailEnabled: p.EmailEnabled,
|
||||
IMEnabled: p.IMEnabled,
|
||||
IMWebhookURLSet: len(p.ImWebhookURLEnc) > 0,
|
||||
MinSeverity: string(p.MinSeverity),
|
||||
})
|
||||
}
|
||||
|
||||
// putPrefs 处理 PUT /api/v1/notifications/prefs。
|
||||
func (h *Handler) putPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeUnauthorized, "未认证"))
|
||||
return
|
||||
}
|
||||
var req prefsUpdateReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeInvalidInput, "请求体格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
cur, err := h.prefs.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
if req.EmailEnabled != nil {
|
||||
cur.EmailEnabled = *req.EmailEnabled
|
||||
}
|
||||
if req.IMEnabled != nil {
|
||||
cur.IMEnabled = *req.IMEnabled
|
||||
}
|
||||
if req.MinSeverity != nil {
|
||||
sev := Severity(strings.TrimSpace(*req.MinSeverity))
|
||||
if !validSeverity(sev) {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeInvalidInput, "min_severity 取值非法(info|warning|error)"))
|
||||
return
|
||||
}
|
||||
cur.MinSeverity = sev
|
||||
}
|
||||
if req.IMWebhookURL != nil {
|
||||
url := strings.TrimSpace(*req.IMWebhookURL)
|
||||
if url == "" {
|
||||
cur.ImWebhookURLEnc = nil
|
||||
} else {
|
||||
ct, err := h.enc.Encrypt([]byte(url))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.Wrap(err, errs.CodeInternal, "加密 im_webhook_url"))
|
||||
return
|
||||
}
|
||||
cur.ImWebhookURLEnc = ct
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.prefs.Upsert(r.Context(), cur); err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, prefsDTO{
|
||||
EmailEnabled: cur.EmailEnabled,
|
||||
IMEnabled: cur.IMEnabled,
|
||||
IMWebhookURLSet: len(cur.ImWebhookURLEnc) > 0,
|
||||
MinSeverity: string(cur.MinSeverity),
|
||||
})
|
||||
}
|
||||
|
||||
// validSeverity 报告 s 是否为合法的 min_severity 取值。
|
||||
func validSeverity(s Severity) bool {
|
||||
switch s {
|
||||
case SeverityInfo, SeverityWarning, SeverityError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type prefsFakeResolver struct{ uid uuid.UUID }
|
||||
|
||||
func (f *prefsFakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
|
||||
if token == "" {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
return f.uid, true, nil
|
||||
}
|
||||
|
||||
// reverseEncryptor 是测试用 Encryptor:取反作为 "加密",与 reverseDecryptor 配对。
|
||||
type reverseEncryptor struct{}
|
||||
|
||||
func (reverseEncryptor) Encrypt(pt []byte) ([]byte, error) { return reverseBytes(pt), nil }
|
||||
|
||||
func mountPrefs(t *testing.T, uid uuid.UUID, prefs PrefsRepository) http.Handler {
|
||||
t.Helper()
|
||||
r := chi.NewRouter()
|
||||
NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).
|
||||
WithPrefs(prefs, reverseEncryptor{}).Mount(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// noopRepo 满足 inbox Repository(prefs 测试不触达 inbox 端点)。
|
||||
type noopRepo struct{}
|
||||
|
||||
func (noopRepo) Insert(context.Context, Message) error { return nil }
|
||||
func (noopRepo) List(context.Context, uuid.UUID, bool, int) ([]Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (noopRepo) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil }
|
||||
func (noopRepo) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
||||
func (noopRepo) MarkAllRead(context.Context, uuid.UUID) error { return nil }
|
||||
|
||||
func TestPrefsHandler_GetReturnsDefaultsForNewUser(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mountPrefs(t, uid, newFakePrefs())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var dto prefsDTO
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto))
|
||||
require.False(t, dto.EmailEnabled)
|
||||
require.False(t, dto.IMEnabled)
|
||||
require.False(t, dto.IMWebhookURLSet)
|
||||
require.Equal(t, "warning", dto.MinSeverity)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_GetRequiresAuth(t *testing.T) {
|
||||
h := mountPrefs(t, uuid.New(), newFakePrefs())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) // no token
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutPersistsAndEncryptsWebhook(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
h := mountPrefs(t, uid, prefs)
|
||||
|
||||
body, _ := json.Marshal(prefsUpdateReq{
|
||||
EmailEnabled: boolPtr(true),
|
||||
IMEnabled: boolPtr(true),
|
||||
IMWebhookURL: strPtr("https://hooks.slack.test/xyz"),
|
||||
MinSeverity: strPtr("error"),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var dto prefsDTO
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto))
|
||||
require.True(t, dto.EmailEnabled)
|
||||
require.True(t, dto.IMEnabled)
|
||||
require.True(t, dto.IMWebhookURLSet)
|
||||
require.Equal(t, "error", dto.MinSeverity)
|
||||
|
||||
// 落库的 webhook 应为密文(取反),解回应得回原 URL;明文绝不直接存。
|
||||
saved, err := prefs.Get(context.Background(), uid)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, saved.ImWebhookURLEnc)
|
||||
require.NotEqual(t, []byte("https://hooks.slack.test/xyz"), saved.ImWebhookURLEnc)
|
||||
require.Equal(t, "https://hooks.slack.test/xyz", string(reverseBytes(saved.ImWebhookURLEnc)))
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutPartialUpdatePreservesWebhook(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning,
|
||||
ImWebhookURLEnc: reverseBytes([]byte("https://existing"))})
|
||||
h := mountPrefs(t, uid, prefs)
|
||||
|
||||
// 仅改 email_enabled,省略 im_webhook_url -> 保留原密文。
|
||||
body, _ := json.Marshal(prefsUpdateReq{EmailEnabled: boolPtr(true)})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
saved, _ := prefs.Get(context.Background(), uid)
|
||||
require.Equal(t, "https://existing", string(reverseBytes(saved.ImWebhookURLEnc)))
|
||||
require.True(t, saved.EmailEnabled)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutEmptyWebhookClearsIt(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning,
|
||||
ImWebhookURLEnc: reverseBytes([]byte("https://existing"))})
|
||||
h := mountPrefs(t, uid, prefs)
|
||||
|
||||
body, _ := json.Marshal(prefsUpdateReq{IMWebhookURL: strPtr("")})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
saved, _ := prefs.Get(context.Background(), uid)
|
||||
require.Empty(t, saved.ImWebhookURLEnc)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutRejectsBadSeverity(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mountPrefs(t, uid, newFakePrefs())
|
||||
body, _ := json.Marshal(prefsUpdateReq{MinSeverity: strPtr("catastrophic")})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_NotMountedWithoutEncryptor(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
r := chi.NewRouter()
|
||||
// WithPrefs 未调用 -> /prefs 不应被挂载。
|
||||
NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).Mount(r)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,14 @@
|
||||
-- name: GetNotificationPrefs :one
|
||||
SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at
|
||||
FROM notification_prefs
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: UpsertNotificationPrefs :exec
|
||||
INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
im_enabled = EXCLUDED.im_enabled,
|
||||
im_webhook_url_enc = EXCLUDED.im_webhook_url_enc,
|
||||
min_severity = EXCLUDED.min_severity,
|
||||
updated_at = now();
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: prefs.sql
|
||||
|
||||
package notifysqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getNotificationPrefs = `-- name: GetNotificationPrefs :one
|
||||
SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at
|
||||
FROM notification_prefs
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetNotificationPrefs(ctx context.Context, userID pgtype.UUID) (NotificationPref, error) {
|
||||
row := q.db.QueryRow(ctx, getNotificationPrefs, userID)
|
||||
var i NotificationPref
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.EmailEnabled,
|
||||
&i.ImEnabled,
|
||||
&i.ImWebhookUrlEnc,
|
||||
&i.MinSeverity,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertNotificationPrefs = `-- name: UpsertNotificationPrefs :exec
|
||||
INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
im_enabled = EXCLUDED.im_enabled,
|
||||
im_webhook_url_enc = EXCLUDED.im_webhook_url_enc,
|
||||
min_severity = EXCLUDED.min_severity,
|
||||
updated_at = now()
|
||||
`
|
||||
|
||||
type UpsertNotificationPrefsParams 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"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertNotificationPrefs(ctx context.Context, arg UpsertNotificationPrefsParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertNotificationPrefs,
|
||||
arg.UserID,
|
||||
arg.EmailEnabled,
|
||||
arg.ImEnabled,
|
||||
arg.ImWebhookUrlEnc,
|
||||
arg.MinSeverity,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// stream.go 实现通知 inbox 的实时推送(spec §11 步骤8)。
|
||||
//
|
||||
// StreamHub 是 per-user 的订阅扇出中心:每个浏览器标签页 Subscribe 一个有界缓冲
|
||||
// channel;Dispatcher 落库成功后 Publish 到该用户的全部订阅者。慢订阅者(缓冲满)
|
||||
// 直接丢弃该消息(drop-on-slow),绝不阻塞 Publish / Dispatch,避免 goroutine /
|
||||
// 内存泄漏(镜像 chat StreamerHub 的丢弃纪律)。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// StreamHub 是 Dispatcher 推送实时通知所需的窄接口。
|
||||
type StreamHub interface {
|
||||
Subscribe(userID uuid.UUID) (<-chan Message, func())
|
||||
Publish(userID uuid.UUID, m Message)
|
||||
}
|
||||
|
||||
// streamSubBuffer 是单个订阅者的缓冲深度。满时新消息被丢弃(客户端可用 inbox
|
||||
// 列表接口补偿,与 chat WS onopen 主动拉取同理)。
|
||||
const streamSubBuffer = 16
|
||||
|
||||
// memHub 是 StreamHub 的进程内实现。
|
||||
type memHub struct {
|
||||
mu sync.Mutex
|
||||
subs map[uuid.UUID]map[int]chan Message
|
||||
next int
|
||||
}
|
||||
|
||||
// NewStreamHub 构造进程内的 per-user 通知推送中心。
|
||||
func NewStreamHub() StreamHub {
|
||||
return &memHub{subs: map[uuid.UUID]map[int]chan Message{}}
|
||||
}
|
||||
|
||||
// Subscribe 为某用户注册一个订阅者,返回只读 channel 与取消函数。取消函数幂等:
|
||||
// 关闭 channel 并从订阅表移除;调用方应在请求结束时 defer 调用。
|
||||
func (h *memHub) Subscribe(userID uuid.UUID) (<-chan Message, func()) {
|
||||
ch := make(chan Message, streamSubBuffer)
|
||||
h.mu.Lock()
|
||||
if h.subs[userID] == nil {
|
||||
h.subs[userID] = map[int]chan Message{}
|
||||
}
|
||||
id := h.next
|
||||
h.next++
|
||||
h.subs[userID][id] = ch
|
||||
h.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
h.mu.Lock()
|
||||
if m, ok := h.subs[userID]; ok {
|
||||
if c, ok := m[id]; ok {
|
||||
delete(m, id)
|
||||
close(c)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
delete(h.subs, userID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
// Publish 向某用户的全部订阅者非阻塞投递一条消息。缓冲满的订阅者被跳过。
|
||||
//
|
||||
// 关键:整个投递循环持有 h.mu。因为发送是非阻塞的(select default),持锁开销极小,
|
||||
// 但这让 Publish 与 cancel()(同样持 h.mu 后 close channel)互斥——否则快照 channel
|
||||
// 释放锁后、cancel 并发 close,会 panic "send on closed channel"。
|
||||
func (h *memHub) Publish(userID uuid.UUID, m Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, c := range h.subs[userID] {
|
||||
select {
|
||||
case c <- m:
|
||||
default:
|
||||
// drop-on-slow:缓冲满,丢弃,避免阻塞 Dispatch。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestStreamHubSubscribeReceivesPublish(t *testing.T) {
|
||||
hub := NewStreamHub()
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
want := Message{ID: uuid.New(), UserID: uid, Topic: "t", Title: "hi"}
|
||||
hub.Publish(uid, want)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.ID != want.ID {
|
||||
t.Fatalf("got %v, want %v", got.ID, want.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive published message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamHubUnsubscribeStopsDelivery(t *testing.T) {
|
||||
hub := NewStreamHub()
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
cancel() // immediate unsubscribe closes the channel
|
||||
|
||||
// channel should be closed, draining returns zero value + !ok.
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("expected channel closed after cancel")
|
||||
}
|
||||
// Publishing to an unsubscribed user must not panic.
|
||||
hub.Publish(uid, Message{UserID: uid})
|
||||
}
|
||||
|
||||
func TestStreamHubDropsSlowSubscriber(t *testing.T) {
|
||||
hub := NewStreamHub()
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
// Flood beyond buffer; Publish must not block / deadlock.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < streamSubBuffer*4; i++ {
|
||||
hub.Publish(uid, Message{UserID: uid, Topic: "flood"})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Publish deadlocked on slow subscriber")
|
||||
}
|
||||
// Buffer holds at most streamSubBuffer; the rest were dropped.
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
count++
|
||||
default:
|
||||
if count > streamSubBuffer {
|
||||
t.Fatalf("buffered %d > cap %d (drop-on-slow violated)", count, streamSubBuffer)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherPublishesAfterInsert(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
hub := NewStreamHub()
|
||||
d.SetStreamHub(hub)
|
||||
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Dispatch(context.Background(), Message{UserID: uid, Topic: "x", Title: "y"}); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.Topic != "x" {
|
||||
t.Fatalf("topic = %q", got.Topic)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("hub did not receive dispatched message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherNoPublishOnInsertFailure(t *testing.T) {
|
||||
repo := &fakeRepo{failNext: true}
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
hub := NewStreamHub()
|
||||
d.SetStreamHub(hub)
|
||||
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Dispatch(context.Background(), Message{UserID: uid}); err == nil {
|
||||
t.Fatal("expected dispatch error on insert failure")
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("hub received message despite insert failure")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// expected: no publish.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExecSpec 描述一次一次性(one-shot)命令执行的全部输入。
|
||||
// 调用方(run.ExecService)负责前置鉴权、cwd / env 构造与命令白名单校验。
|
||||
type ExecSpec struct {
|
||||
Command string
|
||||
Args []string
|
||||
Dir string
|
||||
Env []string // 完整环境(宿主白名单 + 覆盖),nil 表示空环境而非继承宿主
|
||||
// Timeout 是硬上限:超时即对整组发起 TerminateGroup(树终止)并置 TimedOut。
|
||||
// <=0 时不设独立超时,仅受 ctx 约束。
|
||||
Timeout time.Duration
|
||||
// KillGrace 是树终止时的优雅宽限(Unix SIGTERM → grace → SIGKILL)。
|
||||
KillGrace time.Duration
|
||||
// MaxOutputBytes 是 stdout / stderr 各自的捕获上限;超出截断并置 truncated。
|
||||
// <=0 时使用 defaultMaxOutputBytes。
|
||||
MaxOutputBytes int
|
||||
}
|
||||
|
||||
// ExecResult 是一次性执行的结构化结果。
|
||||
type ExecResult struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
Duration time.Duration
|
||||
TimedOut bool
|
||||
Truncated bool // stdout 或 stderr 任一被截断时为 true
|
||||
StdoutSize int // 截断前的实际字节数
|
||||
StderrSize int // 截断前的实际字节数
|
||||
}
|
||||
|
||||
const defaultMaxOutputBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// RunOnce 同步执行 spec 描述的命令直至退出(或超时 / ctx 取消),捕获 stdout /
|
||||
// stderr(各自封顶 MaxOutputBytes),返回退出码、时长、超时标志。
|
||||
//
|
||||
// 进程树管理沿用 RunSupervisor.Spawn 的次序:Prepare(Start 前)→ Start →
|
||||
// Adopt(Start 后)→ 退出或超时时 TerminateGroup 整树清理。这样测试运行器派生的
|
||||
// 子孙进程(go test 编译出的子进程、node 子进程等)在超时时也会被一并终止。
|
||||
//
|
||||
// 与长驻 profile 的差异:本函数阻塞到进程退出后才返回,不接入 relay;输出在内存
|
||||
// 缓冲并随返回值一次性交回。错误仅在无法启动进程(exec.Start 失败)时返回;命令
|
||||
// 以非零码退出不算 error,体现在 ExitCode 上。
|
||||
func RunOnce(ctx context.Context, spec ExecSpec) (ExecResult, error) {
|
||||
maxOut := spec.MaxOutputBytes
|
||||
if maxOut <= 0 {
|
||||
maxOut = defaultMaxOutputBytes
|
||||
}
|
||||
|
||||
cmd := exec.Command(spec.Command, spec.Args...)
|
||||
cmd.Dir = spec.Dir
|
||||
cmd.Env = spec.Env
|
||||
|
||||
outBuf := &cappedBuffer{limit: maxOut}
|
||||
errBuf := &cappedBuffer{limit: maxOut}
|
||||
cmd.Stdout = outBuf
|
||||
cmd.Stderr = errBuf
|
||||
|
||||
group := NewGroup()
|
||||
group.Prepare(cmd)
|
||||
|
||||
started := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return ExecResult{}, err
|
||||
}
|
||||
if err := group.Adopt(cmd); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
group.Close()
|
||||
return ExecResult{}, err
|
||||
}
|
||||
|
||||
// waitCh 携带 Wait() 的错误;done 在 Wait 返回后 close,供 TerminateGroup
|
||||
// 感知组长已被回收(语义对齐 RunSupervisor.monitorProcess 的 done channel)。
|
||||
waitCh := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
waitCh <- err
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// 超时 / ctx-cancel watchdog:触发整树终止;result 由下方 select 收尾。
|
||||
var timeoutTimer <-chan time.Time
|
||||
if spec.Timeout > 0 {
|
||||
t := time.NewTimer(spec.Timeout)
|
||||
defer t.Stop()
|
||||
timeoutTimer = t.C
|
||||
}
|
||||
|
||||
var timedOut bool
|
||||
var waitErr error
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case waitErr = <-waitCh:
|
||||
break loop
|
||||
case <-timeoutTimer:
|
||||
timedOut = true
|
||||
group.TerminateGroup(done, spec.KillGrace)
|
||||
waitErr = <-waitCh
|
||||
break loop
|
||||
case <-ctx.Done():
|
||||
group.TerminateGroup(done, spec.KillGrace)
|
||||
waitErr = <-waitCh
|
||||
break loop
|
||||
}
|
||||
}
|
||||
group.Close()
|
||||
|
||||
res := ExecResult{
|
||||
ExitCode: exitCodeOf(waitErr),
|
||||
Stdout: outBuf.String(),
|
||||
Stderr: errBuf.String(),
|
||||
Duration: time.Since(started),
|
||||
TimedOut: timedOut,
|
||||
Truncated: outBuf.truncated || errBuf.truncated,
|
||||
StdoutSize: outBuf.total,
|
||||
StderrSize: errBuf.total,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// exitCodeOf 从 Cmd.Wait 的返回错误提取退出码。正常退出(nil)= 0;
|
||||
// *exec.ExitError 取其 ExitCode(被信号杀死时为 -1);其它错误统一 -1。
|
||||
func exitCodeOf(waitErr error) int {
|
||||
if waitErr == nil {
|
||||
return 0
|
||||
}
|
||||
if ee, ok := waitErr.(*exec.ExitError); ok {
|
||||
return ee.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// cappedBuffer 是一个带上限的 io.Writer:累计写入 limit 字节后丢弃后续数据并置
|
||||
// truncated,同时仍记录 total(截断前的总字节数)便于上层提示。
|
||||
type cappedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
limit int
|
||||
total int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (c *cappedBuffer) Write(p []byte) (int, error) {
|
||||
c.total += len(p)
|
||||
if c.buf.Len() >= c.limit {
|
||||
c.truncated = true
|
||||
return len(p), nil // 谎报全写入:避免 cmd 因 short write 报错而中断
|
||||
}
|
||||
remain := c.limit - c.buf.Len()
|
||||
if len(p) > remain {
|
||||
c.buf.Write(p[:remain])
|
||||
c.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
c.buf.Write(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *cappedBuffer) String() string { return c.buf.String() }
|
||||
@@ -0,0 +1,143 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// helperShell 返回执行一段 shell 脚本的跨平台命令前缀(command, args...)。
|
||||
func helperShell(script string) (string, []string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd", []string{"/c", script}
|
||||
}
|
||||
return "sh", []string{"-c", script}
|
||||
}
|
||||
|
||||
// TestRunOnce_ExitCodeAndCapture 验证捕获 stdout / stderr 与非零退出码。
|
||||
func TestRunOnce_ExitCodeAndCapture(t *testing.T) {
|
||||
var script string
|
||||
if runtime.GOOS == "windows" {
|
||||
script = "echo out& echo err 1>&2& exit 3"
|
||||
} else {
|
||||
script = "echo out; echo err 1>&2; exit 3"
|
||||
}
|
||||
cmd, args := helperShell(script)
|
||||
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if res.ExitCode != 3 {
|
||||
t.Fatalf("exit code = %d, want 3", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "out") {
|
||||
t.Fatalf("stdout = %q, want contains 'out'", res.Stdout)
|
||||
}
|
||||
if !strings.Contains(res.Stderr, "err") {
|
||||
t.Fatalf("stderr = %q, want contains 'err'", res.Stderr)
|
||||
}
|
||||
if res.TimedOut {
|
||||
t.Fatal("TimedOut should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_Success 验证零退出码路径。
|
||||
func TestRunOnce_Success(t *testing.T) {
|
||||
cmd, args := helperShell("echo hello")
|
||||
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "hello") {
|
||||
t.Fatalf("stdout = %q", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_Truncation 验证 stdout 超过 MaxOutputBytes 时截断并置标志。
|
||||
func TestRunOnce_Truncation(t *testing.T) {
|
||||
// 打印约 1000 字节,但上限设为 100。
|
||||
var cmd string
|
||||
var args []string
|
||||
if runtime.GOOS == "windows" {
|
||||
// PowerShell 直接调用避免 cmd 转义;-NoProfile 加快启动。
|
||||
cmd, args = "powershell", []string{"-NoProfile", "-Command", "Write-Output ('a' * 1000)"}
|
||||
} else {
|
||||
cmd, args = "sh", []string{"-c", "for i in $(seq 1 1000); do printf a; done"}
|
||||
}
|
||||
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, MaxOutputBytes: 100, Timeout: 15 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if !res.Truncated {
|
||||
t.Fatalf("Truncated should be true; stdout len=%d size=%d", len(res.Stdout), res.StdoutSize)
|
||||
}
|
||||
if len(res.Stdout) > 100 {
|
||||
t.Fatalf("captured stdout len=%d exceeds limit 100", len(res.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_Timeout 验证超时触发整树终止并置 TimedOut。
|
||||
//
|
||||
// Unix:父 shell trap 掉 TERM 并 sleep,再后台派生一个 grandchild sleep;超时后
|
||||
// TerminateGroup 必须 SIGKILL 整组,确保父退出(done 被关闭)。
|
||||
// Windows:直接 timeout 长睡,Job-close 终止。
|
||||
func TestRunOnce_Timeout(t *testing.T) {
|
||||
var cmd string
|
||||
var args []string
|
||||
if runtime.GOOS == "windows" {
|
||||
// ping -n N localhost 是无需控制台的可靠延时(timeout /t 在无 stdin 下立即返回)。
|
||||
cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"}
|
||||
} else {
|
||||
// trap '' TERM 忽略 SIGTERM;后台派生 grandchild;wait 阻塞存活。
|
||||
cmd, args = "sh", []string{"-c", "trap '' TERM; sleep 30 & sleep 30; wait"}
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := RunOnce(context.Background(), ExecSpec{
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Timeout: 1 * time.Second,
|
||||
KillGrace: 500 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if !res.TimedOut {
|
||||
t.Fatal("TimedOut should be true")
|
||||
}
|
||||
// 必须在远小于 30s 内返回(超时 + grace + kill),证明整树被回收而非等自然退出。
|
||||
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||
t.Fatalf("RunOnce blocked %v, expected prompt tree-kill", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_CtxCancel 验证 ctx 取消亦触发树终止并尽快返回。
|
||||
func TestRunOnce_CtxCancel(t *testing.T) {
|
||||
cmd, args := helperShell(fmt.Sprintf("%s", sleepScript(30)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
res, err := RunOnce(ctx, ExecSpec{Command: cmd, Args: args, KillGrace: 500 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||
t.Fatalf("RunOnce blocked %v after ctx cancel", elapsed)
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
|
||||
func sleepScript(sec int) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return fmt.Sprintf("ping -n %d 127.0.0.1 >nul", sec)
|
||||
}
|
||||
return fmt.Sprintf("sleep %d", sec)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// DefaultGiteaBaseURL is the platform's self-hosted Gitea instance.
|
||||
const DefaultGiteaBaseURL = "https://git.jerryyan.net"
|
||||
|
||||
// giteaErrBodyLimit caps how much of a non-2xx response body is surfaced in the
|
||||
// wrapped error message.
|
||||
const giteaErrBodyLimit = 512
|
||||
|
||||
// GiteaProvider implements Provider against the Gitea REST v1 API. Auth uses
|
||||
// the "Authorization: token <PAT>" header with a single platform-level token.
|
||||
type GiteaProvider struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGiteaProvider constructs a GiteaProvider. baseURL empty defaults to
|
||||
// DefaultGiteaBaseURL; timeout <= 0 defaults to 15s. A fresh http.Client is
|
||||
// used (low call frequency), mirroring the webhook handler pattern.
|
||||
func NewGiteaProvider(baseURL, token string, timeout time.Duration) *GiteaProvider {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultGiteaBaseURL
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return &GiteaProvider{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Host returns the bare host (no scheme/port) this provider serves, for
|
||||
// ParseRepoRef host matching.
|
||||
func (g *GiteaProvider) Host() string {
|
||||
u, err := url.Parse(g.baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// ===== Gitea wire DTOs =====
|
||||
|
||||
type giteaPR struct {
|
||||
Number int64 `json:"number"`
|
||||
State string `json:"state"`
|
||||
Mergeable bool `json:"mergeable"`
|
||||
Merged bool `json:"merged"`
|
||||
MergedCommitID string `json:"merge_commit_sha"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Head giteaBranch `json:"head"`
|
||||
}
|
||||
|
||||
type giteaBranch struct {
|
||||
Sha string `json:"sha"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
type giteaCombinedStatus struct {
|
||||
State string `json:"state"` // pending|success|error|failure
|
||||
}
|
||||
|
||||
type giteaIssue struct {
|
||||
Number int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// ===== Provider impl =====
|
||||
|
||||
func (g *GiteaProvider) CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error) {
|
||||
body := map[string]any{
|
||||
"head": in.Head,
|
||||
"base": in.Base,
|
||||
"title": in.Title,
|
||||
"body": in.Body,
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", esc(in.Repo.Owner), esc(in.Repo.Name))
|
||||
var pr giteaPR
|
||||
if err := g.do(ctx, http.MethodPost, path, body, &pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := mapPR(pr)
|
||||
// best-effort CI state from the head commit's combined status
|
||||
out.CIState = g.ciState(ctx, in.Repo, pr.Head.Sha)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *GiteaProvider) GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", esc(repo.Owner), esc(repo.Name), number)
|
||||
var pr giteaPR
|
||||
if err := g.do(ctx, http.MethodGet, path, nil, &pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := mapPR(pr)
|
||||
out.CIState = g.ciState(ctx, repo, pr.Head.Sha)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *GiteaProvider) Comment(ctx context.Context, repo RepoRef, number int64, body string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", esc(repo.Owner), esc(repo.Name), number)
|
||||
return g.do(ctx, http.MethodPost, path, map[string]any{"body": body}, nil)
|
||||
}
|
||||
|
||||
func (g *GiteaProvider) Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (string, error) {
|
||||
method := in.Method
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
}
|
||||
body := map[string]any{"Do": method}
|
||||
if in.Title != "" {
|
||||
body["MergeTitleField"] = in.Title
|
||||
}
|
||||
if in.Message != "" {
|
||||
body["MergeMessageField"] = in.Message
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", esc(repo.Owner), esc(repo.Name), number)
|
||||
if err := g.do(ctx, http.MethodPost, path, body, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Gitea's merge endpoint returns 200 with no SHA; re-fetch to read the merge commit.
|
||||
pr, err := g.GetPR(ctx, repo, number)
|
||||
if err != nil {
|
||||
return "", nil // merge succeeded; sha lookup best-effort
|
||||
}
|
||||
return pr.MergeCommitSHA, nil
|
||||
}
|
||||
|
||||
func (g *GiteaProvider) ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error) {
|
||||
q := url.Values{}
|
||||
q.Set("type", "issues")
|
||||
if opts.State != "" {
|
||||
q.Set("state", opts.State)
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(opts.Limit))
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", esc(repo.Owner), esc(repo.Name), q.Encode())
|
||||
var rows []giteaIssue
|
||||
if err := g.do(ctx, http.MethodGet, path, nil, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Issue, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, Issue{Number: r.Number, Title: r.Title, State: r.State, HTMLURL: r.HTMLURL})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ciState fetches the combined commit status for sha and maps it to the
|
||||
// platform's ci_state vocabulary. Missing status / errors degrade to "unknown".
|
||||
func (g *GiteaProvider) ciState(ctx context.Context, repo RepoRef, sha string) string {
|
||||
if sha == "" {
|
||||
return "unknown"
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/status", esc(repo.Owner), esc(repo.Name), esc(sha))
|
||||
var cs giteaCombinedStatus
|
||||
if err := g.do(ctx, http.MethodGet, path, nil, &cs); err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
switch cs.State {
|
||||
case "success":
|
||||
return "success"
|
||||
case "pending":
|
||||
return "pending"
|
||||
case "error", "failure":
|
||||
return "failure"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTTP plumbing =====
|
||||
|
||||
// do performs a request, sets the token auth header, and decodes a 2xx JSON
|
||||
// body into out (out nil => body discarded). Non-2xx becomes a typed upstream
|
||||
// error carrying a truncated body.
|
||||
func (g *GiteaProvider) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "vcs: marshal request body")
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "vcs: build request")
|
||||
}
|
||||
if g.token != "" {
|
||||
req.Header.Set("Authorization", "token "+g.token)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := g.client.Do(req)
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodeUpstream, "vcs: gitea request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, giteaErrBodyLimit))
|
||||
code := errs.CodeUpstream
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
code = errs.CodeNotFound
|
||||
}
|
||||
return errs.New(code, fmt.Sprintf("vcs: gitea %s %s -> %d: %s",
|
||||
method, path, resp.StatusCode, strings.TrimSpace(string(snippet))))
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return errs.Wrap(err, errs.CodeUpstream, "vcs: decode gitea response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapPR(pr giteaPR) PullRequest {
|
||||
state := pr.State
|
||||
if pr.Merged {
|
||||
state = "merged"
|
||||
}
|
||||
return PullRequest{
|
||||
Number: pr.Number,
|
||||
State: state,
|
||||
Mergeable: pr.Mergeable,
|
||||
Merged: pr.Merged,
|
||||
MergeCommitSHA: pr.MergedCommitID,
|
||||
HTMLURL: pr.HTMLURL,
|
||||
}
|
||||
}
|
||||
|
||||
// esc path-escapes a single URL segment (owner/name/sha may contain odd chars).
|
||||
func esc(s string) string { return url.PathEscape(s) }
|
||||
@@ -0,0 +1,152 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
func newTestProvider(t *testing.T, h http.Handler) *GiteaProvider {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return NewGiteaProvider(srv.URL, "test-token", 5*time.Second)
|
||||
}
|
||||
|
||||
func TestGitea_CreatePR(t *testing.T) {
|
||||
repo := RepoRef{Owner: "owner", Name: "repo"}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "token test-token", r.Header.Get("Authorization"))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
assert.Equal(t, "feature", body["head"])
|
||||
assert.Equal(t, "main", body["base"])
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(giteaPR{
|
||||
Number: 42, State: "open", Mergeable: true,
|
||||
HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/42",
|
||||
Head: giteaBranch{Sha: "abc123", Ref: "feature"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/commits/abc123/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"})
|
||||
})
|
||||
|
||||
p := newTestProvider(t, mux)
|
||||
pr, err := p.CreatePR(context.Background(), CreatePRInput{
|
||||
Repo: repo, Head: "feature", Base: "main", Title: "T", Body: "B",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(42), pr.Number)
|
||||
assert.Equal(t, "open", pr.State)
|
||||
assert.Equal(t, "https://git.jerryyan.net/owner/repo/pulls/42", pr.HTMLURL)
|
||||
assert.Equal(t, "success", pr.CIState)
|
||||
}
|
||||
|
||||
func TestGitea_GetPR_MergedAndCI(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/7", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(giteaPR{
|
||||
Number: 7, State: "closed", Merged: true, MergedCommitID: "deadbeef",
|
||||
HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/7",
|
||||
Head: giteaBranch{Sha: "sha7"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/commits/sha7/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "failure"})
|
||||
})
|
||||
|
||||
p := newTestProvider(t, mux)
|
||||
pr, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 7)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, pr.Merged)
|
||||
assert.Equal(t, "merged", pr.State)
|
||||
assert.Equal(t, "deadbeef", pr.MergeCommitSHA)
|
||||
assert.Equal(t, "failure", pr.CIState)
|
||||
}
|
||||
|
||||
func TestGitea_Merge(t *testing.T) {
|
||||
var mergeCalled bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3/merge", func(w http.ResponseWriter, r *http.Request) {
|
||||
mergeCalled = true
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
assert.Equal(t, "squash", body["Do"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(giteaPR{Number: 3, State: "closed", Merged: true, MergedCommitID: "mergesha", Head: giteaBranch{Sha: "h3"}})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/commits/h3/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"})
|
||||
})
|
||||
|
||||
p := newTestProvider(t, mux)
|
||||
sha, err := p.Merge(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 3, MergeInput{Method: "squash"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mergeCalled)
|
||||
assert.Equal(t, "mergesha", sha)
|
||||
}
|
||||
|
||||
func TestGitea_Comment(t *testing.T) {
|
||||
var gotBody string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/issues/5/comments", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
gotBody, _ = body["body"].(string)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
p := newTestProvider(t, mux)
|
||||
require.NoError(t, p.Comment(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 5, "hello"))
|
||||
assert.Equal(t, "hello", gotBody)
|
||||
}
|
||||
|
||||
func TestGitea_ListIssues(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/issues", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "issues", r.URL.Query().Get("type"))
|
||||
_ = json.NewEncoder(w).Encode([]giteaIssue{
|
||||
{Number: 1, Title: "bug", State: "open", HTMLURL: "u1"},
|
||||
})
|
||||
})
|
||||
|
||||
p := newTestProvider(t, mux)
|
||||
issues, err := p.ListIssues(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, IssueListOptions{State: "open"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issues, 1)
|
||||
assert.Equal(t, "bug", issues[0].Title)
|
||||
}
|
||||
|
||||
func TestGitea_Non2xxError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/9", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
})
|
||||
|
||||
p := newTestProvider(t, mux)
|
||||
_, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 9)
|
||||
require.Error(t, err)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestGitea_Host(t *testing.T) {
|
||||
p := NewGiteaProvider("https://git.jerryyan.net:3000", "", 0)
|
||||
assert.Equal(t, "git.jerryyan.net", p.Host())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// ErrUnsupportedHost is returned by ParseRepoRef when the remote points at a
|
||||
// host other than the one the v1 Gitea provider serves.
|
||||
var ErrUnsupportedHost = errs.New(errs.CodeInvalidInput, "vcs: unsupported git host for PR operations")
|
||||
|
||||
// ParseRepoRef derives owner/name from a workspace GitRemoteURL. It accepts:
|
||||
//
|
||||
// https://host/owner/name(.git)
|
||||
// http://host/owner/name(.git)
|
||||
// ssh://git@host/owner/name(.git)
|
||||
// git@host:owner/name(.git)
|
||||
//
|
||||
// wantHost is the bare host the provider serves (e.g. "git.jerryyan.net"); when
|
||||
// non-empty the remote host must match it (port stripped, case-insensitive),
|
||||
// otherwise ErrUnsupportedHost is returned. wantHost empty skips the host
|
||||
// check (host-agnostic parse, used in tests).
|
||||
func ParseRepoRef(remoteURL, wantHost string) (RepoRef, error) {
|
||||
remoteURL = strings.TrimSpace(remoteURL)
|
||||
if remoteURL == "" {
|
||||
return RepoRef{}, errs.New(errs.CodeInvalidInput, "vcs: empty remote url")
|
||||
}
|
||||
|
||||
host, path, err := splitHostPath(remoteURL)
|
||||
if err != nil {
|
||||
return RepoRef{}, err
|
||||
}
|
||||
if wantHost != "" && !sameHost(host, wantHost) {
|
||||
return RepoRef{}, ErrUnsupportedHost
|
||||
}
|
||||
|
||||
owner, name, err := splitOwnerName(path)
|
||||
if err != nil {
|
||||
return RepoRef{}, err
|
||||
}
|
||||
return RepoRef{Owner: owner, Name: name}, nil
|
||||
}
|
||||
|
||||
// splitHostPath extracts the bare host and the repo path from a remote URL,
|
||||
// handling both scheme URLs and the scp-like git@host:owner/name form.
|
||||
func splitHostPath(remoteURL string) (host, path string, err error) {
|
||||
// scp-like: git@host:owner/name.git (no scheme, single colon before path)
|
||||
if !strings.Contains(remoteURL, "://") && strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") {
|
||||
at := strings.Index(remoteURL, "@")
|
||||
rest := remoteURL[at+1:]
|
||||
colon := strings.Index(rest, ":")
|
||||
if colon < 0 {
|
||||
return "", "", errs.New(errs.CodeInvalidInput, "vcs: malformed scp remote url")
|
||||
}
|
||||
return rest[:colon], rest[colon+1:], nil
|
||||
}
|
||||
|
||||
u, perr := url.Parse(remoteURL)
|
||||
if perr != nil || u.Host == "" {
|
||||
return "", "", errs.Wrap(perr, errs.CodeInvalidInput, "vcs: cannot parse remote url")
|
||||
}
|
||||
return u.Hostname(), u.Path, nil
|
||||
}
|
||||
|
||||
// splitOwnerName turns "/owner/name.git" or "owner/name.git" into (owner, name).
|
||||
func splitOwnerName(path string) (owner, name string, err error) {
|
||||
path = strings.Trim(path, "/")
|
||||
path = strings.TrimSuffix(path, ".git")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" {
|
||||
return "", "", errs.New(errs.CodeInvalidInput, "vcs: remote url must contain owner/name")
|
||||
}
|
||||
// Gitea repos are owner/name; deeper paths are not valid repo refs.
|
||||
return parts[0], parts[len(parts)-1], nil
|
||||
}
|
||||
|
||||
// sameHost compares hosts case-insensitively, ignoring any :port suffix.
|
||||
func sameHost(a, b string) bool {
|
||||
return strings.EqualFold(stripPort(a), stripPort(b))
|
||||
}
|
||||
|
||||
func stripPort(h string) string {
|
||||
if i := strings.LastIndex(h, ":"); i >= 0 {
|
||||
return h[:i]
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseRepoRef_HTTPS(t *testing.T) {
|
||||
cases := []struct{ url, owner, name string }{
|
||||
{"https://git.jerryyan.net/owner/repo.git", "owner", "repo"},
|
||||
{"https://git.jerryyan.net/owner/repo", "owner", "repo"},
|
||||
{"http://git.jerryyan.net/acme/widgets.git", "acme", "widgets"},
|
||||
{"https://git.jerryyan.net:3000/owner/repo.git", "owner", "repo"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ref, err := ParseRepoRef(c.url, "git.jerryyan.net")
|
||||
require.NoError(t, err, c.url)
|
||||
assert.Equal(t, c.owner, ref.Owner, c.url)
|
||||
assert.Equal(t, c.name, ref.Name, c.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoRef_SSHAndScp(t *testing.T) {
|
||||
cases := []struct{ url, owner, name string }{
|
||||
{"git@git.jerryyan.net:owner/repo.git", "owner", "repo"},
|
||||
{"git@git.jerryyan.net:owner/repo", "owner", "repo"},
|
||||
{"ssh://git@git.jerryyan.net/owner/repo.git", "owner", "repo"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ref, err := ParseRepoRef(c.url, "git.jerryyan.net")
|
||||
require.NoError(t, err, c.url)
|
||||
assert.Equal(t, c.owner, ref.Owner, c.url)
|
||||
assert.Equal(t, c.name, ref.Name, c.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoRef_UnsupportedHost(t *testing.T) {
|
||||
_, err := ParseRepoRef("https://github.com/owner/repo.git", "git.jerryyan.net")
|
||||
require.ErrorIs(t, err, ErrUnsupportedHost)
|
||||
|
||||
_, err = ParseRepoRef("git@github.com:owner/repo.git", "git.jerryyan.net")
|
||||
require.ErrorIs(t, err, ErrUnsupportedHost)
|
||||
}
|
||||
|
||||
func TestParseRepoRef_Invalid(t *testing.T) {
|
||||
for _, bad := range []string{"", "not-a-url", "https://git.jerryyan.net/only-owner"} {
|
||||
_, err := ParseRepoRef(bad, "git.jerryyan.net")
|
||||
require.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoRef_HostAgnostic(t *testing.T) {
|
||||
// wantHost empty => skip host check
|
||||
ref, err := ParseRepoRef("https://github.com/owner/repo.git", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "owner", ref.Owner)
|
||||
assert.Equal(t, "repo", ref.Name)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package vcs abstracts the git-host (VCS) REST API the platform uses to open
|
||||
// and merge pull requests. v1 ships a Gitea-first implementation (gitea.go)
|
||||
// targeting the self-hosted host git.jerryyan.net; other hosts return an
|
||||
// Unimplemented error from the RepoRef parser / provider.
|
||||
//
|
||||
// The provider is intentionally narrow: only the verbs the change-request
|
||||
// merge gate needs (CreatePR / GetPR / Comment / Merge / ListIssues). Secrets
|
||||
// (the platform Gitea token) are held by the concrete provider, never passed
|
||||
// through the interface.
|
||||
package vcs
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider is the git-host API surface consumed by the changerequest service.
|
||||
type Provider interface {
|
||||
// CreatePR opens a pull request from in.Head into in.Base.
|
||||
CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error)
|
||||
// GetPR fetches a single PR by its host-side index/number, including a
|
||||
// best-effort CI state derived from the head commit's combined status.
|
||||
GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error)
|
||||
// Comment posts a comment on the PR (Gitea treats PRs as issues for comments).
|
||||
Comment(ctx context.Context, repo RepoRef, number int64, body string) error
|
||||
// Merge merges the PR via in.Method, returning the resulting merge commit SHA.
|
||||
Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (mergeSHA string, err error)
|
||||
// ListIssues lists repository issues (not PRs) for orientation / linking.
|
||||
ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error)
|
||||
}
|
||||
|
||||
// RepoRef identifies a repository on the host as owner/name. Derived from a
|
||||
// workspace's GitRemoteURL by ParseRepoRef.
|
||||
type RepoRef struct {
|
||||
Owner string
|
||||
Name string
|
||||
}
|
||||
|
||||
// CreatePRInput is the input to Provider.CreatePR.
|
||||
type CreatePRInput struct {
|
||||
Repo RepoRef
|
||||
Head string // source branch
|
||||
Base string // target branch
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// MergeInput controls how Provider.Merge merges the PR.
|
||||
type MergeInput struct {
|
||||
Method string // "merge" | "squash" | "rebase"
|
||||
Title string // merge commit title (optional)
|
||||
Message string // merge commit message body (optional)
|
||||
}
|
||||
|
||||
// PullRequest is the host's view of a PR mapped to platform fields.
|
||||
type PullRequest struct {
|
||||
Number int64
|
||||
State string // "open" | "closed" (Gitea: PR closed covers merged too)
|
||||
Mergeable bool
|
||||
Merged bool
|
||||
MergeCommitSHA string
|
||||
HTMLURL string
|
||||
CIState string // "unknown" | "pending" | "success" | "failure"
|
||||
}
|
||||
|
||||
// Issue is a minimal host issue projection.
|
||||
type Issue struct {
|
||||
Number int64
|
||||
Title string
|
||||
State string
|
||||
HTMLURL string
|
||||
}
|
||||
|
||||
// IssueListOptions filters ListIssues. State empty => host default ("open").
|
||||
type IssueListOptions struct {
|
||||
State string // "open" | "closed" | "all"
|
||||
Limit int
|
||||
}
|
||||
Reference in New Issue
Block a user