This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+158
View File
@@ -0,0 +1,158 @@
// handler.go 暴露 admin-only 的审计日志查看接口(spec §11 步骤4)。
//
// GET /api/v1/admin/audit-logs?action=&action_prefix=&target_type=&target_id=
// &user_id=&from=&to=&limit=&offset=
//
// 鉴权:复刻 chat.adminGuard —— Auth 中间件解出 uid,再经 AdminLookup.IsAdmin
// 门禁;非 admin 返回 403。from/to 接受 RFC3339 时间字符串。
package audit
import (
"context"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"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"
)
// AdminLookup resolves whether a user is an admin (narrow interface; mirrors
// chat/acp handler pattern). user.Service satisfies it.
type AdminLookup interface {
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}
// Handler exposes the admin audit-log read endpoint.
type Handler struct {
reader Reader
resolver middleware.SessionResolver
admin AdminLookup
}
// NewHandler constructs the audit read handler.
func NewHandler(reader Reader, resolver middleware.SessionResolver, admin AdminLookup) *Handler {
return &Handler{reader: reader, resolver: resolver, admin: admin}
}
// Mount registers the admin audit routes under /api/v1/admin.
func (h *Handler) Mount(r chi.Router) {
r.Route("/api/v1/admin/audit-logs", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Use(h.adminGuard)
r.Get("/", h.list)
})
}
func (h *Handler) adminGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
h.writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized"))
return
}
isAdmin, err := h.admin.IsAdmin(r.Context(), uid)
if err != nil {
h.writeErr(w, r, err)
return
}
if !isAdmin {
h.writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
return
}
next.ServeHTTP(w, r)
})
}
type auditLogDTO struct {
ID int64 `json:"id"`
UserID *string `json:"user_id,omitempty"`
Action string `json:"action"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
IP string `json:"ip,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := AuditFilter{
Action: q.Get("action"),
ActionPrefix: q.Get("action_prefix"),
TargetType: q.Get("target_type"),
TargetID: q.Get("target_id"),
}
if v := q.Get("user_id"); v != "" {
uid, err := uuid.Parse(v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 格式错误"))
return
}
f.UserID = &uid
}
if v := q.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "from 须为 RFC3339"))
return
}
f.From = &t
}
if v := q.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "to 须为 RFC3339"))
return
}
f.To = &t
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "limit 须为非负整数"))
return
}
f.Limit = n
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "offset 须为非负整数"))
return
}
f.Offset = n
}
entries, total, err := h.reader.List(r.Context(), f)
if err != nil {
h.writeErr(w, r, err)
return
}
out := make([]auditLogDTO, 0, len(entries))
for _, e := range entries {
dto := auditLogDTO{
ID: e.ID,
Action: e.Action,
TargetType: e.TargetType,
TargetID: e.TargetID,
IP: e.IP,
Metadata: e.Metadata,
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
}
if e.UserID != nil {
s := e.UserID.String()
dto.UserID = &s
}
out = append(out, dto)
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out, "total": total})
}
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}
+136
View File
@@ -0,0 +1,136 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type fakeResolver struct{ uid uuid.UUID }
func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
if token == "" {
return uuid.Nil, false, nil
}
return f.uid, true, nil
}
type fakeAdmin struct{ admins map[uuid.UUID]bool }
func (f *fakeAdmin) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) {
return f.admins[id], nil
}
type fakeReader struct {
lastFilter AuditFilter
entries []LogEntry
total int
}
func (f *fakeReader) List(_ context.Context, flt AuditFilter) ([]LogEntry, int, error) {
f.lastFilter = flt
return f.entries, f.total, nil
}
func (f *fakeReader) PurgeBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
func mount(reader Reader, uid uuid.UUID, admins map[uuid.UUID]bool) http.Handler {
r := chi.NewRouter()
NewHandler(reader, &fakeResolver{uid: uid}, &fakeAdmin{admins: admins}).Mount(r)
return r
}
func TestAuditHandlerNonAdminForbidden(t *testing.T) {
uid := uuid.New()
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{}) // not admin
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
}
func TestAuditHandlerUnauthenticated(t *testing.T) {
uid := uuid.New()
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true})
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil) // no token
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestAuditHandlerAdminListWithFilters(t *testing.T) {
uid := uuid.New()
filterUID := uuid.New()
now := time.Now().UTC().Truncate(time.Second)
reader := &fakeReader{
entries: []LogEntry{{
ID: 7,
UserID: &filterUID,
Action: "user.login",
CreatedAt: now,
}},
total: 1,
}
h := mount(reader, uid, map[uuid.UUID]bool{uid: true})
from := now.Add(-time.Hour).Format(time.RFC3339)
url := "/api/v1/admin/audit-logs/?action=user.login&target_type=user&user_id=" +
filterUID.String() + "&from=" + from + "&limit=10&offset=5"
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// Filter parsed and forwarded to reader.
if reader.lastFilter.Action != "user.login" {
t.Errorf("action filter = %q, want user.login", reader.lastFilter.Action)
}
if reader.lastFilter.TargetType != "user" {
t.Errorf("target_type filter = %q", reader.lastFilter.TargetType)
}
if reader.lastFilter.UserID == nil || *reader.lastFilter.UserID != filterUID {
t.Errorf("user_id filter not parsed: %v", reader.lastFilter.UserID)
}
if reader.lastFilter.From == nil {
t.Errorf("from filter not parsed")
}
if reader.lastFilter.Limit != 10 || reader.lastFilter.Offset != 5 {
t.Errorf("limit/offset = %d/%d, want 10/5", reader.lastFilter.Limit, reader.lastFilter.Offset)
}
var resp struct {
Items []auditLogDTO `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 1 || len(resp.Items) != 1 || resp.Items[0].ID != 7 {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestAuditHandlerBadQueryParam(t *testing.T) {
uid := uuid.New()
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true})
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/?from=not-a-date", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
+29
View File
@@ -1,3 +1,32 @@
-- name: InsertAuditLog :exec
INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6);
-- name: ListAuditLog :many
-- Paginated read with optional filters. NULL filter args match everything
-- (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits).
SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at
FROM audit_logs
WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id'))
AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action'))
AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%')
AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type'))
AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id'))
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts'))
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts'))
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2;
-- name: CountAuditLog :one
SELECT COUNT(*)
FROM audit_logs
WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id'))
AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action'))
AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%')
AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type'))
AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id'))
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts'))
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts'));
-- name: DeleteAuditLogsBefore :execrows
DELETE FROM audit_logs WHERE created_at < $1;
+164
View File
@@ -0,0 +1,164 @@
// reader.go 提供审计日志的只读查询路径(spec §11 步骤4)。
//
// audit.Recorder 是 write-only 门面;Reader 是与之对称的 read 门面,供 admin
// 审计查看器(GET /api/v1/admin/audit-logs)与保留期清理 runner 使用。pgReader
// 复用 auditsqlc 生成的 List/Count/Delete 查询,并在 pgtype <-> 领域类型间转换。
package audit
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
auditsqlc "github.com/yan1h/agent-coding-workflow/internal/audit/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// LogEntry 是一条已落库审计记录的只读领域视图。区别于写入用的 Entry:含 ID/
// 时间戳,IP 以字符串形式返回。
type LogEntry struct {
ID int64
UserID *uuid.UUID
Action string
TargetType string
TargetID string
IP string
Metadata map[string]any
CreatedAt time.Time
}
// AuditFilter 是 List/Count 的可选过滤条件。零值字段表示不过滤。Limit<=0 时由
// Reader 兜底为默认页大小;Limit 上限由 Reader 钳制。
type AuditFilter struct {
UserID *uuid.UUID
Action string // 精确匹配
ActionPrefix string // 前缀匹配(搜索用)
TargetType string
TargetID string
From *time.Time
To *time.Time
Limit int
Offset int
}
const (
// DefaultAuditPageSize 是未指定 Limit 时的默认页大小。
DefaultAuditPageSize = 50
// MaxAuditPageSize 是单页返回的硬上限,避免一次拉全表。
MaxAuditPageSize = 500
)
// Reader 是审计读取的对外契约:分页列表 + 总数 + 保留期清理。
type Reader interface {
List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error)
PurgeBefore(ctx context.Context, before time.Time) (int64, error)
}
type pgReader struct {
q *auditsqlc.Queries
}
// NewPostgresReader 用现有 pgxpool 构造 Reader 实现。
func NewPostgresReader(pool *pgxpool.Pool) Reader {
return &pgReader{q: auditsqlc.New(pool)}
}
// List 返回符合过滤条件的审计记录页 + 满足条件的总数(用于分页)。
func (r *pgReader) List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error) {
limit := f.Limit
if limit <= 0 {
limit = DefaultAuditPageSize
}
if limit > MaxAuditPageSize {
limit = MaxAuditPageSize
}
offset := f.Offset
if offset < 0 {
offset = 0
}
rows, err := r.q.ListAuditLog(ctx, auditsqlc.ListAuditLogParams{
Limit: int32(limit),
Offset: int32(offset),
UserID: pgUUIDFromPtr(f.UserID),
Action: ptr(f.Action),
ActionPrefix: ptr(f.ActionPrefix),
TargetType: ptr(f.TargetType),
TargetID: ptr(f.TargetID),
FromTs: tsFromPtr(f.From),
ToTs: tsFromPtr(f.To),
})
if err != nil {
return nil, 0, errs.Wrap(err, errs.CodeInternal, "list audit logs")
}
total, err := r.q.CountAuditLog(ctx, auditsqlc.CountAuditLogParams{
UserID: pgUUIDFromPtr(f.UserID),
Action: ptr(f.Action),
ActionPrefix: ptr(f.ActionPrefix),
TargetType: ptr(f.TargetType),
TargetID: ptr(f.TargetID),
FromTs: tsFromPtr(f.From),
ToTs: tsFromPtr(f.To),
})
if err != nil {
return nil, 0, errs.Wrap(err, errs.CodeInternal, "count audit logs")
}
out := make([]LogEntry, 0, len(rows))
for _, row := range rows {
out = append(out, rowToEntry(row))
}
return out, int(total), nil
}
// PurgeBefore 删除 created_at < before 的全部审计记录,返回删除行数。保留期 runner 调用。
func (r *pgReader) PurgeBefore(ctx context.Context, before time.Time) (int64, error) {
n, err := r.q.DeleteAuditLogsBefore(ctx, toPgTimestamptz(before))
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "purge audit logs")
}
return n, nil
}
func rowToEntry(row auditsqlc.AuditLog) LogEntry {
e := LogEntry{
ID: row.ID,
Action: row.Action,
CreatedAt: row.CreatedAt.Time,
}
if row.UserID.Valid {
u := uuid.UUID(row.UserID.Bytes)
e.UserID = &u
}
if row.TargetType != nil {
e.TargetType = *row.TargetType
}
if row.TargetID != nil {
e.TargetID = *row.TargetID
}
if row.Ip != nil {
e.IP = row.Ip.String()
}
if len(row.Metadata) > 0 {
_ = json.Unmarshal(row.Metadata, &e.Metadata)
}
return e
}
// tsFromPtr 把 *time.Time 转为 pgtype.Timestamptz;nil 映射为 NULL(不过滤)。
func tsFromPtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: *t, Valid: true}
}
// toPgTimestamptz 把非空 time.Time 转为有效 Timestamptz。
func toPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
+116
View File
@@ -12,6 +12,55 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countAuditLog = `-- name: CountAuditLog :one
SELECT COUNT(*)
FROM audit_logs
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR action LIKE $3 || '%')
AND ($4::text IS NULL OR target_type = $4)
AND ($5::text IS NULL OR target_id = $5)
AND ($6::timestamptz IS NULL OR created_at >= $6)
AND ($7::timestamptz IS NULL OR created_at < $7)
`
type CountAuditLogParams struct {
UserID pgtype.UUID `json:"user_id"`
Action *string `json:"action"`
ActionPrefix *string `json:"action_prefix"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
FromTs pgtype.Timestamptz `json:"from_ts"`
ToTs pgtype.Timestamptz `json:"to_ts"`
}
func (q *Queries) CountAuditLog(ctx context.Context, arg CountAuditLogParams) (int64, error) {
row := q.db.QueryRow(ctx, countAuditLog,
arg.UserID,
arg.Action,
arg.ActionPrefix,
arg.TargetType,
arg.TargetID,
arg.FromTs,
arg.ToTs,
)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteAuditLogsBefore = `-- name: DeleteAuditLogsBefore :execrows
DELETE FROM audit_logs WHERE created_at < $1
`
func (q *Queries) DeleteAuditLogsBefore(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, deleteAuditLogsBefore, createdAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
@@ -37,3 +86,70 @@ func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams)
)
return err
}
const listAuditLog = `-- name: ListAuditLog :many
SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at
FROM audit_logs
WHERE ($3::uuid IS NULL OR user_id = $3)
AND ($4::text IS NULL OR action = $4)
AND ($5::text IS NULL OR action LIKE $5 || '%')
AND ($6::text IS NULL OR target_type = $6)
AND ($7::text IS NULL OR target_id = $7)
AND ($8::timestamptz IS NULL OR created_at >= $8)
AND ($9::timestamptz IS NULL OR created_at < $9)
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
`
type ListAuditLogParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
UserID pgtype.UUID `json:"user_id"`
Action *string `json:"action"`
ActionPrefix *string `json:"action_prefix"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
FromTs pgtype.Timestamptz `json:"from_ts"`
ToTs pgtype.Timestamptz `json:"to_ts"`
}
// Paginated read with optional filters. NULL filter args match everything
// (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits).
func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLog,
arg.Limit,
arg.Offset,
arg.UserID,
arg.Action,
arg.ActionPrefix,
arg.TargetType,
arg.TargetID,
arg.FromTs,
arg.ToTs,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var i AuditLog
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Action,
&i.TargetType,
&i.TargetID,
&i.Ip,
&i.Metadata,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+240 -17
View File
@@ -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 {