iop/apps/node/internal/workspace/observation.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

208 lines
5.6 KiB
Go

package workspace
import (
"crypto/rand"
"encoding/hex"
"sync/atomic"
"go.uber.org/zap"
iop "iop/proto/gen/iop"
)
const workspaceObservationLogKey = "node_workspace_observation"
type workspaceObservationEvent string
const (
workspaceObservationTool workspaceObservationEvent = "tool"
workspaceObservationCleanup workspaceObservationEvent = "cleanup"
)
// workspaceObservation is deliberately raw-free. It carries no request id,
// workspace ref, path, command id, environment, content, stdout, stderr, or
// error text. Correlation is generated at Open and is not derived from any
// caller-controlled identity.
type workspaceObservation struct {
event workspaceObservationEvent
correlation string
operation string
outcome string
errorCode string
durationMS int64
truncated bool
processCount int32
artifactCount int32
}
type workspaceObserver interface {
Emit(workspaceObservation) error
}
type workspaceNoopObserver struct{}
func (workspaceNoopObserver) Emit(workspaceObservation) error { return nil }
type workspaceSafeObserver struct {
inner workspaceObserver
failures atomic.Int64
}
func (o *workspaceSafeObserver) Emit(observation workspaceObservation) error {
if o == nil || o.inner == nil {
return nil
}
defer func() {
if recover() != nil {
o.failures.Add(1)
}
}()
if err := o.inner.Emit(observation); err != nil {
o.failures.Add(1)
}
return nil
}
func (o *workspaceSafeObserver) failureCount() int64 {
if o == nil {
return 0
}
return o.failures.Load()
}
type zapWorkspaceObserver struct {
logger *zap.Logger
}
func newZapWorkspaceObserver(logger *zap.Logger) workspaceObserver {
if logger == nil {
return workspaceNoopObserver{}
}
return &zapWorkspaceObserver{logger: logger}
}
func (o *zapWorkspaceObserver) Emit(observation workspaceObservation) error {
if o == nil || o.logger == nil {
return nil
}
o.logger.Info(workspaceObservationLogKey,
zap.String("correlation", observation.correlation),
zap.String("event", string(observation.event)),
zap.String("operation", observation.operation),
zap.String("outcome", observation.outcome),
zap.String("error_code", observation.errorCode),
zap.Int64("duration_ms", observation.durationMS),
zap.Bool("truncated", observation.truncated),
zap.Int32("process_count", observation.processCount),
zap.Int32("artifact_count", observation.artifactCount),
)
return nil
}
var workspaceCorrelationFallback atomic.Uint64
func newWorkspaceCorrelation() string {
var value [12]byte
if _, err := rand.Read(value[:]); err == nil {
return "ws-" + hex.EncodeToString(value[:])
}
return "ws-fallback-" + formatWorkspaceFallback(workspaceCorrelationFallback.Add(1))
}
func formatWorkspaceFallback(value uint64) string {
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
if value == 0 {
return "0"
}
var encoded [13]byte
index := len(encoded)
for value > 0 {
index--
encoded[index] = alphabet[value%36]
value /= 36
}
return string(encoded[index:])
}
func workspaceOperationName(operation iop.WorkspaceOperation) string {
switch operation {
case iop.WorkspaceOperation_WORKSPACE_OPERATION_READ:
return "read"
case iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST:
return "list"
case iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE:
return "write"
case iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE:
return "delete"
case iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND:
return "command"
default:
return "unknown"
}
}
func workspaceOutcome(status iop.WorkspaceStatus) string {
switch status {
case iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS:
return "success"
case iop.WorkspaceStatus_WORKSPACE_STATUS_TIMEOUT:
return "timeout"
case iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED:
return "cancelled"
case iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED:
return "unsupported"
case iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR:
return "error"
default:
return "unknown"
}
}
func workspaceErrorCode(code iop.WorkspaceErrorCode) string {
switch code {
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSPECIFIED:
return "none"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_READY:
return "not_ready"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSUPPORTED:
return "unsupported"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST:
return "invalid_request"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_FOUND:
return "not_found"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_TIMEOUT:
return "timeout"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED:
return "cancelled"
case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL:
return "internal"
default:
return "unknown"
}
}
func (r *Runtime) observeTool(correlation string, operation iop.WorkspaceOperation, result Result) {
if r == nil || r.observer == nil {
return
}
r.observer.Emit(workspaceObservation{
event: workspaceObservationTool, correlation: correlation,
operation: workspaceOperationName(operation), outcome: workspaceOutcome(result.Status),
errorCode: workspaceErrorCode(result.Code), durationMS: result.DurationMS, truncated: result.Truncated,
})
}
func (r *Runtime) observeCleanup(req *Request, result CleanupResult, durationMS int64) {
if r == nil || r.observer == nil {
return
}
correlation := ""
if req != nil {
correlation = req.correlation
}
r.observer.Emit(workspaceObservation{
event: workspaceObservationCleanup, correlation: correlation, operation: "cleanup",
outcome: workspaceOutcome(result.Status), errorCode: workspaceErrorCode(result.Code), durationMS: durationMS,
processCount: result.CleanedProcesses, artifactCount: result.CleanedArtifacts,
})
}