diff --git a/internal/mcp/queries/mcp_tokens.sql b/internal/mcp/queries/mcp_tokens.sql new file mode 100644 index 0000000..defacfe --- /dev/null +++ b/internal/mcp/queries/mcp_tokens.sql @@ -0,0 +1,82 @@ +-- name: CreateMcpToken :one +INSERT INTO mcp_tokens ( + id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, created_by +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at; + +-- name: GetMcpTokenByHash :one +SELECT id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +FROM mcp_tokens +WHERE token_hash = $1; + +-- name: GetMcpTokenByID :one +SELECT id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +FROM mcp_tokens +WHERE id = $1; + +-- name: ListMcpTokens :many +-- ListMcpTokens:admin 看全部;普通 user 看自己持有的 admin-issued。 +-- callerUserID 为 NULL 表示 admin(不过滤);非空表示按 (user_id=$1 AND issuer='admin') 过滤。 +-- activeOnly=true 时排除已撤销 / 已过期。 +SELECT id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +FROM mcp_tokens +WHERE + (sqlc.narg('caller_user_id')::uuid IS NULL + OR (user_id = sqlc.narg('caller_user_id')::uuid AND issuer = 'admin')) + AND + (NOT sqlc.arg('active_only')::boolean + OR (revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()))) +ORDER BY created_at DESC; + +-- name: UpdateMcpTokenLastUsed :exec +UPDATE mcp_tokens + SET last_used_at = now() + WHERE id = $1; + +-- name: RevokeMcpToken :one +UPDATE mcp_tokens + SET revoked_at = now() + WHERE id = $1 AND revoked_at IS NULL +RETURNING id; + +-- name: RevokeMcpTokensBySession :many +-- 给定一个 ACP session 的 ID,把所有引用该 session 的 system token 一次性撤销。 +-- 返回已撤销的 token id 列表(用于 audit 写入逐条)。 +UPDATE mcp_tokens + SET revoked_at = now() + WHERE acp_session_id = $1 + AND issuer = 'system' + AND revoked_at IS NULL +RETURNING id; + +-- name: ReapStaleSystemTokens :many +-- 启动期 reaper:所有 system token 中,关联的 acp_sessions 已经是 crashed/exited 的,全部撤销。 +-- ACP 自身的 reaper 必须先把 starting/running 残留转为 crashed,本 query 才能命中。 +UPDATE mcp_tokens t + SET revoked_at = now() + FROM acp_sessions s + WHERE t.acp_session_id = s.id + AND t.issuer = 'system' + AND t.revoked_at IS NULL + AND s.status IN ('crashed','exited') +RETURNING t.id; + +-- name: PurgeMcpTokensExpired :execrows +-- 后台 jobs runner 用:删 expires_at + retention 之前 / revoked_at + retention 之前的 row。 +-- 单条 query 用 OR 条件,retention 同一份。 +DELETE FROM mcp_tokens + WHERE (expires_at IS NOT NULL AND expires_at < $1) + OR (revoked_at IS NOT NULL AND revoked_at < $1); + +-- name: DeleteMcpTokenByID :exec +-- 仅供测试用(生产不允许直接物理删;走撤销)。 +DELETE FROM mcp_tokens WHERE id = $1; diff --git a/internal/mcp/sqlc/db.go b/internal/mcp/sqlc/db.go new file mode 100644 index 0000000..de46cfa --- /dev/null +++ b/internal/mcp/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package mcpsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/mcp/sqlc/mcp_tokens.sql.go b/internal/mcp/sqlc/mcp_tokens.sql.go new file mode 100644 index 0000000..f545e32 --- /dev/null +++ b/internal/mcp/sqlc/mcp_tokens.sql.go @@ -0,0 +1,290 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: mcp_tokens.sql + +package mcpsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createMcpToken = `-- name: CreateMcpToken :one +INSERT INTO mcp_tokens ( + id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, created_by +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +` + +type CreateMcpTokenParams struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedBy pgtype.UUID `json:"created_by"` +} + +func (q *Queries) CreateMcpToken(ctx context.Context, arg CreateMcpTokenParams) (McpToken, error) { + row := q.db.QueryRow(ctx, createMcpToken, + arg.ID, + arg.TokenHash, + arg.UserID, + arg.Name, + arg.Issuer, + arg.Scope, + arg.AcpSessionID, + arg.ExpiresAt, + arg.CreatedBy, + ) + var i McpToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.UserID, + &i.Name, + &i.Issuer, + &i.Scope, + &i.AcpSessionID, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const deleteMcpTokenByID = `-- name: DeleteMcpTokenByID :exec +DELETE FROM mcp_tokens WHERE id = $1 +` + +// 仅供测试用(生产不允许直接物理删;走撤销)。 +func (q *Queries) DeleteMcpTokenByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteMcpTokenByID, id) + return err +} + +const getMcpTokenByHash = `-- name: GetMcpTokenByHash :one +SELECT id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +FROM mcp_tokens +WHERE token_hash = $1 +` + +func (q *Queries) GetMcpTokenByHash(ctx context.Context, tokenHash []byte) (McpToken, error) { + row := q.db.QueryRow(ctx, getMcpTokenByHash, tokenHash) + var i McpToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.UserID, + &i.Name, + &i.Issuer, + &i.Scope, + &i.AcpSessionID, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const getMcpTokenByID = `-- name: GetMcpTokenByID :one +SELECT id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +FROM mcp_tokens +WHERE id = $1 +` + +func (q *Queries) GetMcpTokenByID(ctx context.Context, id pgtype.UUID) (McpToken, error) { + row := q.db.QueryRow(ctx, getMcpTokenByID, id) + var i McpToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.UserID, + &i.Name, + &i.Issuer, + &i.Scope, + &i.AcpSessionID, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const listMcpTokens = `-- name: ListMcpTokens :many +SELECT id, token_hash, user_id, name, issuer, scope, + acp_session_id, expires_at, last_used_at, revoked_at, + created_by, created_at +FROM mcp_tokens +WHERE + ($1::uuid IS NULL + OR (user_id = $1::uuid AND issuer = 'admin')) + AND + (NOT $2::boolean + OR (revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()))) +ORDER BY created_at DESC +` + +type ListMcpTokensParams struct { + CallerUserID pgtype.UUID `json:"caller_user_id"` + ActiveOnly bool `json:"active_only"` +} + +// ListMcpTokens:admin 看全部;普通 user 看自己持有的 admin-issued。 +// callerUserID 为 NULL 表示 admin(不过滤);非空表示按 (user_id=$1 AND issuer='admin') 过滤。 +// activeOnly=true 时排除已撤销 / 已过期。 +func (q *Queries) ListMcpTokens(ctx context.Context, arg ListMcpTokensParams) ([]McpToken, error) { + rows, err := q.db.Query(ctx, listMcpTokens, arg.CallerUserID, arg.ActiveOnly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []McpToken + for rows.Next() { + var i McpToken + if err := rows.Scan( + &i.ID, + &i.TokenHash, + &i.UserID, + &i.Name, + &i.Issuer, + &i.Scope, + &i.AcpSessionID, + &i.ExpiresAt, + &i.LastUsedAt, + &i.RevokedAt, + &i.CreatedBy, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const purgeMcpTokensExpired = `-- name: PurgeMcpTokensExpired :execrows +DELETE FROM mcp_tokens + WHERE (expires_at IS NOT NULL AND expires_at < $1) + OR (revoked_at IS NOT NULL AND revoked_at < $1) +` + +// 后台 jobs runner 用:删 expires_at + retention 之前 / revoked_at + retention 之前的 row。 +// 单条 query 用 OR 条件,retention 同一份。 +func (q *Queries) PurgeMcpTokensExpired(ctx context.Context, expiresAt pgtype.Timestamptz) (int64, error) { + result, err := q.db.Exec(ctx, purgeMcpTokensExpired, expiresAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const reapStaleSystemTokens = `-- name: ReapStaleSystemTokens :many +UPDATE mcp_tokens t + SET revoked_at = now() + FROM acp_sessions s + WHERE t.acp_session_id = s.id + AND t.issuer = 'system' + AND t.revoked_at IS NULL + AND s.status IN ('crashed','exited') +RETURNING t.id +` + +// 启动期 reaper:所有 system token 中,关联的 acp_sessions 已经是 crashed/exited 的,全部撤销。 +// ACP 自身的 reaper 必须先把 starting/running 残留转为 crashed,本 query 才能命中。 +func (q *Queries) ReapStaleSystemTokens(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, reapStaleSystemTokens) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeMcpToken = `-- name: RevokeMcpToken :one +UPDATE mcp_tokens + SET revoked_at = now() + WHERE id = $1 AND revoked_at IS NULL +RETURNING id +` + +func (q *Queries) RevokeMcpToken(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, revokeMcpToken, id) + var id_2 pgtype.UUID + err := row.Scan(&id_2) + return id_2, err +} + +const revokeMcpTokensBySession = `-- name: RevokeMcpTokensBySession :many +UPDATE mcp_tokens + SET revoked_at = now() + WHERE acp_session_id = $1 + AND issuer = 'system' + AND revoked_at IS NULL +RETURNING id +` + +// 给定一个 ACP session 的 ID,把所有引用该 session 的 system token 一次性撤销。 +// 返回已撤销的 token id 列表(用于 audit 写入逐条)。 +func (q *Queries) RevokeMcpTokensBySession(ctx context.Context, acpSessionID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, revokeMcpTokensBySession, acpSessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateMcpTokenLastUsed = `-- name: UpdateMcpTokenLastUsed :exec +UPDATE mcp_tokens + SET last_used_at = now() + WHERE id = $1 +` + +func (q *Queries) UpdateMcpTokenLastUsed(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, updateMcpTokenLastUsed, id) + return err +} diff --git a/internal/mcp/sqlc/models.go b/internal/mcp/sqlc/models.go new file mode 100644 index 0000000..a833144 --- /dev/null +++ b/internal/mcp/sqlc/models.go @@ -0,0 +1,311 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package mcpsqlc + +import ( + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" +) + +type AcpAgentKind struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type AcpEvent struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method *string `json:"method"` + Payload []byte `json:"payload"` + PayloadSize int32 `json:"payload_size"` + Truncated bool `json:"truncated"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +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"` +} + +type AuditLog struct { + ID int64 `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Issue struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + CreatedBy pgtype.UUID `json:"created_by"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type Job struct { + ID pgtype.UUID `json:"id"` + Type string `json:"type"` + Payload []byte `json:"payload"` + Status string `json:"status"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Attempts int32 `json:"attempts"` + MaxAttempts int32 `json:"max_attempts"` + LeasedAt pgtype.Timestamptz `json:"leased_at"` + LeasedBy *string `json:"leased_by"` + LastError *string `json:"last_error"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmEndpoint struct { + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseUrl string `json:"base_url"` + ApiKeyEncrypted []byte `json:"api_key_encrypted"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmModel struct { + ID pgtype.UUID `json:"id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities []byte `json:"capabilities"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmUsage struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID pgtype.UUID `json:"user_id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + ThinkingTokens int32 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type McpToken struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking"` + ToolCalls []byte `json:"tool_calls"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message"` + PromptTokens *int32 `json:"prompt_tokens"` + CompletionTokens *int32 `json:"completion_tokens"` + ThinkingTokens *int32 `json:"thinking_tokens"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageAttachment struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + MessageID *int64 `json:"message_id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + StoragePath string `json:"storage_path"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Metadata []byte `json:"metadata"` + ReadAt pgtype.Timestamptz `json:"read_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID pgtype.UUID `json:"owner_id"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type PromptTemplate struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID pgtype.UUID `json:"owner_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Requirement struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID pgtype.UUID `json:"owner_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type UserSession struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"` +}