Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ logs/
# Codex 全局个人偏好放 ~/.codex/AGENTS.override.md,不在此处。
CLAUDE.local.md
AGENTS.override.md
.worktrees/
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@
- 需要保留未知 JSON 字段时,可以在边界使用 `map[string]json.RawMessage` 作为 envelope,但已知字段仍必须通过命名 schema 解析和校验。
- DB 层可以返回原始 JSONB 值,但不承担 HTTP/DTO 解析或领域策略;调用方应在 resource/service/policy 边界尽早完成结构化转换。

## Go 日志规范

- 生产代码统一使用标准库 `log/slog` 作为日志 API;不要直接使用标准库 `log`、`fmt.Printf` 式日志、logrus、zap、zerolog 或自建 printf 包装器。面向终端用户的 CLI 输出不属于运行日志,可以继续写入 stdout/stderr。
- logger 和 handler 统一在可执行程序的组装层通过 `internal/logging` 创建,并在启动早期调用 `slog.SetDefault`。根 logger 通过 `ServerDeps`、资源构造函数或 worker 构造函数显式注入;组装层用 `logger.With("component", "...")` 创建组件 logger,业务方法使用自身持有的 logger。`config.Config` 只承载可序列化的业务/部署数据,不得包含 `*slog.Logger` 或其他运行时依赖。构造边界使用 `logging.LoggerOrDefault` 统一兼容 nil logger,组件内部不得重复读取 `slog.Default()`;生产组装必须显式传入 logger。稳定的 DB、配置和 logger 依赖应由 Handler、Service、Enqueuer 或 Worker 持有,不要在每次业务调用中重复透传;纯解析、转换和 I/O helper 优先返回结果与 error,由拥有方决定是否记录。不要为了 logger 注入把静态 logger 塞入请求 context;只有基于 request ID、trace 等请求域数据派生的 request-scoped logger 才适合随请求传递。业务包不要各自创建 handler,也不要在启动完成后修改全局默认 logger。
- 日志采用结构化字段:消息使用稳定、简短的事件描述,动态值放入属性;新增属性名使用 `snake_case`。错误统一放在 `error` 字段,资源标识使用 `request_id`、`organization_id`、`workspace_id`、`session_id` 等明确字段,不要用 `fmt.Sprintf` 或格式化占位符拼接日志。
- 已持有 `context.Context` 的请求、worker 和后台任务路径优先使用 `DebugContext`、`InfoContext`、`WarnContext`、`ErrorContext`,以便 handler 关联请求 ID、trace 和调用域字段。
- 级别语义保持一致:`Debug` 用于默认关闭的诊断细节,`Info` 用于正常生命周期与重要状态变化,`Warn` 用于可恢复降级、预期拒绝或需要关注的异常输入,`Error` 用于操作未能完成的非预期故障。同一错误只在负责最终处理或补充关键边界信息的层记录一次。
- 应用运行日志禁止记录原始请求/响应 body、完整 query string、`Authorization`、Cookie、token、API key、secret、OAuth code/state、签名或凭据 payload。确需诊断时只记录白名单元数据、长度、摘要或脱敏且有上限的值。协议明确要求的 telemetry/capture 数据必须通过独立、显式配置的安全存储实现,不得混入 `slog` 运行日志。
- 业务包不得调用 `Fatal`、`Panic` 或 `os.Exit`;错误应向上传递。可执行程序的 `main` 在 defer 可完成的 `run` 返回后记录一次终止错误并设置退出码。panic recovery 记录 `request_id` 和 stack,但不得包含敏感 payload。
- 新增公共日志字段或修改日志 handler 时应补充针对结构化 record 的测试;优先检查 level、message 和 attrs,不要依赖 ConsoleHandler 的整行文本格式。

## 前端设计方向

- 前端实现细节位于 `web/AGENTS.md`。
Expand Down
21 changes: 16 additions & 5 deletions cmd/migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,48 @@ package main
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"

"github.com/superduck-ai/open-managed-agents/internal/config"
"github.com/superduck-ai/open-managed-agents/internal/db"
"github.com/superduck-ai/open-managed-agents/internal/logging"
)

func main() {
slog.SetDefault(slog.New(logging.NewConsoleHandler(os.Stderr, slog.LevelInfo)))

if len(os.Args) != 2 || os.Args[1] != "up" {
fmt.Fprintf(os.Stderr, "usage: %s up\n", os.Args[0])
os.Exit(2)
}

if err := run(); err != nil {
slog.Error("database migration failed", "error", err)
os.Exit(1)
}
}

func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
return fmt.Errorf("load config: %w", err)
}

database, err := db.Open(ctx, cfg)
if err != nil {
log.Fatalf("open database: %v", err)
return fmt.Errorf("open database: %w", err)
}
defer database.Close()

if err := database.Migrate(ctx); err != nil {
log.Fatalf("migrate database: %v", err)
return fmt.Errorf("migrate database: %w", err)
}
log.Printf("database migrations applied")
slog.Info("database migrations applied")
return nil
}
36 changes: 24 additions & 12 deletions cmd/seed-builtin-skills/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,73 @@ import (
"context"
"flag"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"

"github.com/superduck-ai/open-managed-agents/internal/config"
"github.com/superduck-ai/open-managed-agents/internal/db"
"github.com/superduck-ai/open-managed-agents/internal/logging"
"github.com/superduck-ai/open-managed-agents/internal/skills"
"github.com/superduck-ai/open-managed-agents/internal/storage"
)

func main() {
logger := slog.New(logging.NewConsoleHandler(os.Stderr, slog.LevelInfo))
slog.SetDefault(logger)

dir := flag.String("dir", "", "Directory containing .skill archives to import")
versionsPath := flag.String("versions", "", "Optional JSON object or skill_id=version file mapping skill ids to platform versions")
prune := flag.Bool("prune", false, "Soft-delete builtin skills not present in --dir")
flag.Parse()

if err := run(*dir, *versionsPath, *prune, logger.With("component", "builtin_skill_seed")); err != nil {
logger.Error("seed builtin skills failed", "error", err)
os.Exit(1)
}
}

func run(dir string, versionsPath string, prune bool, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
return fmt.Errorf("load config: %w", err)
}
database, err := db.Open(ctx, cfg)
if err != nil {
log.Fatalf("open database: %v", err)
return fmt.Errorf("open database: %w", err)
}
defer database.Close()
if err := database.Migrate(ctx); err != nil {
log.Fatalf("migrate database: %v", err)
return fmt.Errorf("migrate database: %w", err)
}
client, err := storage.New(cfg.Storage)
if err != nil {
log.Fatalf("create object storage client: %v", err)
return fmt.Errorf("create object storage client: %w", err)
}
store, err := client.ForBucket(cfg.Storage.S3.Bucket)
if err != nil {
log.Fatalf("bind object storage bucket: %v", err)
return fmt.Errorf("bind object storage bucket: %w", err)
}
if err := store.Ensure(ctx); err != nil {
log.Fatalf("ensure object store bucket: %v", err)
return fmt.Errorf("ensure object store bucket: %w", err)
}

result, err := skills.SeedBuiltinSkills(ctx, database, store, skills.BuiltinSeedOptions{
Dir: *dir,
VersionsPath: *versionsPath,
Prune: *prune,
})
Dir: dir,
VersionsPath: versionsPath,
Prune: prune,
}, logger)
if err != nil {
log.Fatalf("seed builtin skills: %v", err)
return fmt.Errorf("seed builtin skills: %w", err)
}
fmt.Printf("Imported %d builtin skill(s)", result.Imported)
if result.Pruned > 0 {
fmt.Printf(", pruned %d version(s)", result.Pruned)
}
fmt.Printf(": %v\n", result.Skills)
Comment on lines 70 to 74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Emit the seed result through the injected logger.

These operational messages bypass the structured logger and serialize dynamic values into a formatted string.

Proposed fix
-	fmt.Printf("Imported %d builtin skill(s)", result.Imported)
-	if result.Pruned > 0 {
-		fmt.Printf(", pruned %d version(s)", result.Pruned)
-	}
-	fmt.Printf(": %v\n", result.Skills)
+	logger.Info("builtin skills seeded",
+		"imported", result.Imported,
+		"pruned", result.Pruned,
+		"skill_ids", result.Skills,
+	)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fmt.Printf("Imported %d builtin skill(s)", result.Imported)
if result.Pruned > 0 {
fmt.Printf(", pruned %d version(s)", result.Pruned)
}
fmt.Printf(": %v\n", result.Skills)
logger.Info("builtin skills seeded",
"imported", result.Imported,
"pruned", result.Pruned,
"skill_ids", result.Skills,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/seed-builtin-skills/main.go` around lines 70 - 74, Update the seed-result
reporting after the import operation to use the injected structured logger
instead of the three fmt.Printf calls. Log the imported count, optional pruned
count, and result.Skills as separate structured fields while preserving the
existing message content and conditional pruned reporting.

Source: Coding guidelines

return nil
}
4 changes: 2 additions & 2 deletions docs/design/be/ccrv2/otlp-metrics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ Code session OTLP 端点运行时必须同时具备:
| 当前 worker lease 已过期 | 410 | `session_expired` |
| body 超过限制 | 413 | `invalid_request_error` |

调试日志会在 body 读取失败、epoch 解析失败以及 DB/epoch/lease 拒绝路径打印。日志包含 request id、signal、path/query、content type、accept、user agent、content length、body byte 数、epoch presence/value/source 和 reason;不会打印 `Authorization` 或完整原始 headers。body 会按 `maxLoggedWorkerRequestBytes` 截断:JSON/text-like 请求以 UTF-8 文本打印,protobuf/binary 请求以 base64 预览打印,并记录 `body_truncated`
body 读取失败、epoch 解析失败以及 DB/epoch/lease 拒绝路径使用 `slog` 输出结构化运行日志。日志只包含 request id、signal、method、path、code session id、content type、content length、body byte 数、epoch presence/value/source、reasonerror;不记录 query、body、`Authorization` 或完整原始 headers。成功通过认证与 activity/epoch 检查后,显式启用的本地 OTLP JSONL capture 仍按“服务端本地 JSONL 日志配置”保存有界 body preview;它使用独立安全存储,不混入应用运行日志

### 当前成功响应

Expand Down Expand Up @@ -988,7 +988,7 @@ curl -X POST http://127.0.0.1:38080/v1/code/sessions/cse_abc123/worker/otlp/metr
4. 调用 `TouchCodeSessionWorkerActivityForActiveLease()`,同时检查当前 epoch 与未过期 lease。
5. JSON 请求返回 `{}`;protobuf 请求返回 200 空 body。
6. stale epoch 返回 `409 conflict_error`;缺失或非法 epoch 返回 `400 invalid_request_error`;过期 lease 返回 `410 session_expired`。
7. 调试日志记录 OTLP 请求元数据和有界 body 预览;JSON/text-like body 以 UTF-8 打印,protobuf/binary body 以 base64 打印
7. `slog` 运行日志只记录白名单请求元数据与失败原因,不记录 query 或 body;显式启用的本地 OTLP JSONL capture 才保存有界 body preview,JSON/text-like body 以 UTF-8 保存,protobuf/binary body 以 base64 保存
8. 成功通过认证与 activity/epoch 检查后,best-effort 解码 OTLP JSON/protobuf,并写入本地 JSONL;未知 OTLP JSON 字段按兼容字段忽略,解码或文件写入失败不改变 HTTP 响应。
9. 本地 JSONL 使用安全路径段、`0700` 目录和 `0600` 文件权限,避免 session id 影响日志根目录边界并降低本机敏感 telemetry 暴露面。

Expand Down
22 changes: 22 additions & 0 deletions docs/design/be/runtime-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ YAML 使用严格字段解析,未知字段、显式 `null`、类型错误和

需要区分“未配置”和“显式配置为 `false` 或空列表”的字段,在私有 YAML 输入类型中使用 `optional[T]`。输入模型解析完成后再转换为不含指针和 Optional 的运行时 `Config`。这样 presence 是字段类型的一部分,不依赖字符串路径集合;新增派生默认值时必须显式建模,并由 YAML 输入与运行时配置字段合同测试防止两种类型发生字段漂移。

## 配置与运行时依赖分离

`config.Config` 只表示可以由 YAML 加载、校验和复现的数据,不持有 logger、数据库连接、对象存储 client 或其他进程内对象。`*slog.Logger` 属于运行时依赖:可执行程序通过 `internal/logging` 创建根 logger 后,通过 `api.ServerDeps` 和组件构造函数显式注入。HTTP Server 组装层从根 logger 派生带稳定 `component` 字段的子 logger,各 handler、service、enqueuer 和 worker 保存并使用自己的 logger;构造边界通过 `logging.LoggerOrDefault` 统一兼容 nil,组件内部不再读取全局默认 logger。

稳定依赖按生命周期归属组件,而不是在每次调用中机械透传。例如 webhook 入队由 `webhooks.Enqueuer` 持有数据库、Webhook 配置和 `component=webhooks` logger,sessions、deployments 与 vaults 只依赖其入队能力;Workbench 路由组由 `workbenchHandler` 持有 persistence store、Anthropic upstream 配置和 logger;delivery、batch、object cleanup 和 filestore cleanup 的循环状态与 logger 则由各自 Worker 持有。叶子 helper 不接受 logger,应该返回结果或 error,由拥有请求或任务上下文的组件记录。数据库、upstream 配置和静态组件 logger 不放入请求 context;request context 只承载取消信号、deadline、认证 principal、request ID、trace 等请求域数据。

```mermaid
flowchart LR
yaml["YAML"] --> config["config.Load → Config"]
main["main 组装层"] --> rootLogger["根 slog.Logger"]
config --> serverDeps["api.ServerDeps"]
rootLogger --> componentLoggers["component 子 logger"]
componentLoggers --> serverDeps
serverDeps --> handlers["Handlers / Services"]
serverDeps --> enqueuer["Webhook Enqueuer"]
enqueuer --> handlers
config --> workers["Workers"]
componentLoggers --> workers
```

该边界让配置比较、序列化和测试保持确定性,也让日志级别、输出 handler 与公共字段由进程入口统一控制。领域代码不能通过把 logger 塞进 `Config` 来绕过依赖声明,也不能自行创建另一套全局 handler。

Docker Compose 同样只挂载一份完整 YAML,不再通过 `.env` 插值业务字段,也不做 YAML merge。本地 Compose 从受跟踪的无密钥模板 `deploy/docker-compose/oma-server.yaml` 初始化 gitignored 的 `deploy/docker-compose/oma-server.local.yaml`,并只读挂载后者;密码、API key 和私钥路径只能写入本地文件。生产环境应由容器平台或 Secret Manager 将受权限保护的完整 YAML 只读挂载到同一目标路径。

## 从 `.env` 迁移
Expand Down
11 changes: 7 additions & 4 deletions internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@ import (
"encoding/json"
"errors"
"io"
"log"
"log/slog"
"net/http"
"strconv"

"github.com/superduck-ai/open-managed-agents/internal/auth"
"github.com/superduck-ai/open-managed-agents/internal/config"
"github.com/superduck-ai/open-managed-agents/internal/db"
"github.com/superduck-ai/open-managed-agents/internal/httpapi"
"github.com/superduck-ai/open-managed-agents/internal/logging"

"github.com/go-chi/chi/v5"
)

type Handler struct {
service *Service
router chi.Router
logger *slog.Logger
}

func NewHandler(cfg config.Config, database *db.DB) *Handler {
h := &Handler{service: NewService(cfg, database)}
func NewHandler(cfg config.Config, database *db.DB, logger *slog.Logger) *Handler {
logger = logging.LoggerOrDefault(logger)
h := &Handler{service: NewService(cfg, database), logger: logger}
router := chi.NewRouter()
router.NotFound(routeNotFound)
router.MethodNotAllowed(routeNotFound)
Expand Down Expand Up @@ -589,7 +592,7 @@ func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error)
httpapi.WriteError(w, r, httpapi.NewError(serviceErr.status, serviceErr.typ, serviceErr.message))
return
}
log.Printf("admin api: %v", err)
h.logger.Error("admin api", "error", err)
httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Internal server error"))
}

Expand Down
Loading