diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 7ffd00c..ae13a7c 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -22,6 +22,7 @@ import ( // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { projects *projectLifecycleSlot + workObserver *projectWorkObserverSlot directory *accountDirectorySlot accountGuard func(string) error allowImplicitLocal bool diff --git a/internal/app/project_work_observation.go b/internal/app/project_work_observation.go new file mode 100644 index 0000000..489ad1b --- /dev/null +++ b/internal/app/project_work_observation.go @@ -0,0 +1,56 @@ +package app + +import ( + "errors" + "sync" + + "agent-system-backend/pkg/extension" +) + +type projectWorkObserverSlot struct { + mu sync.RWMutex + provider *extension.ProjectWorkObserver +} + +func (s *projectWorkObserverSlot) get() *extension.ProjectWorkObserver { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.provider == nil { + return nil + } + copy := *s.provider + return © +} + +func (r compiledRegistry) RegisterProjectWorkObserver(extension.ProjectWorkObserver) error { + return errors.New("extension: declarative plugins cannot register project work observers") +} + +func (r pluginRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error { + if r.pluginID != "rdm" || r.caps.workObserver == nil { + return errors.New("extension: project work observer requires trusted RDM registration") + } + if p.Observe == nil || p.Preflight == nil || p.Respond == nil { + return errors.New("extension: project work observer requires all callbacks") + } + s := r.caps.workObserver + s.mu.Lock() + defer s.mu.Unlock() + if s.provider != nil { + return errors.New("extension: project work observer already registered") + } + copy := p + s.provider = © + return nil +} + +func (r recordRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error { + reg, ok := r.Registry.(extension.ProjectWorkObservationRegistry) + if !ok { + return errors.New("extension: project work observation registration unavailable") + } + return reg.RegisterProjectWorkObserver(p) +} diff --git a/internal/app/project_work_observation_test.go b/internal/app/project_work_observation_test.go new file mode 100644 index 0000000..68b5ba7 --- /dev/null +++ b/internal/app/project_work_observation_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "context" + "testing" + + "agent-system-backend/pkg/extension" +) + +func workObserverForTest() extension.ProjectWorkObserver { + return extension.ProjectWorkObserver{ + Observe: func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) { + return extension.WorkObservationResult{State: extension.WorkObservationPending}, nil + }, + Preflight: func(context.Context, extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil + }, + Respond: func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil + }, + } +} + +func TestProjectWorkObservationRegistrationIsTrustedAndUnique(t *testing.T) { + slot := &projectWorkObserverSlot{} + trusted := pluginRegistry{pluginID: "rdm", caps: hostcaps{workObserver: slot}} + other := pluginRegistry{pluginID: "other", caps: hostcaps{workObserver: slot}} + if err := other.RegisterProjectWorkObserver(workObserverForTest()); err == nil { + t.Fatal("untrusted plugin registered project observer") + } + if err := (compiledRegistry{}).RegisterProjectWorkObserver(workObserverForTest()); err == nil { + t.Fatal("declarative plugin registered live observer callback") + } + if err := trusted.RegisterProjectWorkObserver(extension.ProjectWorkObserver{}); err == nil { + t.Fatal("partial observer registered") + } + if err := trusted.RegisterProjectWorkObserver(workObserverForTest()); err != nil { + t.Fatal(err) + } + if err := trusted.RegisterProjectWorkObserver(workObserverForTest()); err == nil { + t.Fatal("second observer replaced the trusted provider") + } + if got := slot.get(); got == nil || got.Observe == nil || got.Preflight == nil || got.Respond == nil { + t.Fatal("registered observer not available to host") + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index bbfe1d1..36ba193 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -36,7 +36,9 @@ import ( // ResourceAuthzQuery.Source for per-document publication authorization. // 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process // AccountDirectoryRegistrar for scoped, current account candidate decisions. -const SurfaceVersion = "2.13.0-experimental" +// 2.14.0-experimental: optional ProjectWorkObservationRegistry; host-origin +// work-source observations and project action decisions (X3 contract). +const SurfaceVersion = "2.14.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/project_work.go b/pkg/extension/project_work.go new file mode 100644 index 0000000..7aea802 --- /dev/null +++ b/pkg/extension/project_work.go @@ -0,0 +1,128 @@ +package extension + +import ( + "context" + "time" + + core "agent-system-core" +) + +// WorkObservation is built by the host from a committed message or an actual +// retrieval, tool, or visible analysis result. Callers and models cannot supply +// Actor, ProjectID, source identity, or the source authorization epoch. +// RunID may be empty for a newly committed user message or chat command. +type WorkObservation struct { + EventID string `json:"eventId"` + Phase string `json:"phase"` + Actor string `json:"actor"` + Owner string `json:"owner"` + ProjectID core.ID `json:"projectId"` + ConversationID core.ID `json:"conversationId,omitempty"` + RunID core.ID `json:"runId,omitempty"` + SourceKind string `json:"sourceKind"` + SourceID string `json:"sourceId"` + SourceVersion string `json:"sourceVersion"` + SourceSHA256 string `json:"sourceSha256"` + PartIndex int `json:"partIndex"` + SpanStart int `json:"spanStart"` // UTF-8 byte offset in the exact source + SpanEnd int `json:"spanEnd"` + Text string `json:"text,omitempty"` // only the actual authorized fragment + Scope string `json:"scope"` // private|project|controlled + Delivery string `json:"delivery"` // complete|summary-only|partial + AuthorizationEpoch string `json:"authorizationEpoch"` + ObservedAt time.Time `json:"observedAt"` +} + +type WorkObservationState string + +const ( + WorkObservationAccepted WorkObservationState = "accepted" // durable intake, analysis may be pending + WorkObservationPending WorkObservationState = "pending" + WorkObservationDenied WorkObservationState = "denied" + WorkObservationPartial WorkObservationState = "partial" +) + +type WorkObservationResult struct { + ReceiptID string `json:"receiptId"` + State WorkObservationState `json:"state"` + Reason string `json:"reason,omitempty"` +} + +// ProjectWorkSource is one exact part consumed by the pending action. A hash +// mismatch or missing part must block Preflight rather than use a stale receipt. +type ProjectWorkSource struct { + EventID string `json:"eventId"` + SourceKind string `json:"sourceKind"` + SourceID string `json:"sourceId"` + SourceVersion string `json:"sourceVersion"` + SourceSHA256 string `json:"sourceSha256"` + PartIndex int `json:"partIndex"` +} + +// ProjectWorkAction is constructed by the host immediately before model use, +// project command dispatch, or another project-affecting action. ProjectID is +// checked against the stored conversation or run, never trusted from Params. +type ProjectWorkAction struct { + Actor string `json:"actor"` + Owner string `json:"owner"` + ProjectID core.ID `json:"projectId"` + ConversationID core.ID `json:"conversationId,omitempty"` + RunID core.ID `json:"runId,omitempty"` + Action string `json:"action"` + TargetID string `json:"targetId,omitempty"` + TargetVersion string `json:"targetVersion,omitempty"` + Manifest []ProjectWorkSource `json:"manifest"` + SourceWatermark string `json:"sourceWatermark"` +} + +type ProjectWorkDecisionState string + +const ( + ProjectWorkProceed ProjectWorkDecisionState = "proceed" + ProjectWorkClarify ProjectWorkDecisionState = "clarify" + ProjectWorkPending ProjectWorkDecisionState = "pending" + ProjectWorkDenied ProjectWorkDecisionState = "denied" +) + +// ProjectWorkDecision is a domain decision, not a replacement for a controlled +// document's separate one-time human approval. A successful Observe ACK cannot +// be interpreted as ProjectWorkProceed. +type ProjectWorkDecision struct { + State ProjectWorkDecisionState `json:"state"` + DecisionID string `json:"decisionId"` + RiskID string `json:"riskId,omitempty"` + RiskRevision string `json:"riskRevision,omitempty"` + SourceWatermark string `json:"sourceWatermark,omitempty"` + Reason string `json:"reason,omitempty"` + Question string `json:"question,omitempty"` + Options []string `json:"options,omitempty"` +} + +// ProjectRiskAnswer comes from a current authenticated session and persisted +// risk card. An answer is applied only after the provider commits a CAS receipt. +type ProjectRiskAnswer struct { + Actor string `json:"actor"` + Owner string `json:"owner"` + ProjectID core.ID `json:"projectId"` + RiskID string `json:"riskId"` + RiskRevision string `json:"riskRevision"` + Answer string `json:"answer"` + IdempotencyKey string `json:"idempotencyKey"` +} + +// ProjectWorkObserver is owned by the project domain. Backend must check a +// returned decision against the exact current source manifest and must fail +// closed on missing provider, timeout, or partial analysis when project facts +// are required. RDM remains the sole persistent risk-state owner. +type ProjectWorkObserver struct { + Observe func(context.Context, WorkObservation) (WorkObservationResult, error) + Preflight func(context.Context, ProjectWorkAction) (ProjectWorkDecision, error) + Respond func(context.Context, ProjectRiskAnswer) (ProjectWorkDecision, error) +} + +// ProjectWorkObservationRegistry is optional so older in-process extensions do +// not acquire a mandatory Registry method. Declarative plugins cannot register +// a callback. The host accepts one trusted RDM provider. +type ProjectWorkObservationRegistry interface { + RegisterProjectWorkObserver(ProjectWorkObserver) error +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index ca7118a..61c7c8c 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -64,6 +64,18 @@ ProjectHost.RenameOwnedProject ProjectLifecycle ProjectLifecycleRegistry ProjectLifecycleRegistry.RegisterProjectLifecycle +ProjectRiskAnswer +ProjectWorkAction +ProjectWorkClarify +ProjectWorkDecision +ProjectWorkDecisionState +ProjectWorkDenied +ProjectWorkObservationRegistry +ProjectWorkObservationRegistry.RegisterProjectWorkObserver +ProjectWorkObserver +ProjectWorkPending +ProjectWorkProceed +ProjectWorkSource RegisterCompiled Registry Registry.AddAdminZone @@ -88,3 +100,10 @@ SurfaceHost.SurfaceList SurfaceHost.SurfaceSave SurfaceSaveRequest SurfaceVersion +WorkObservation +WorkObservationAccepted +WorkObservationDenied +WorkObservationPartial +WorkObservationPending +WorkObservationResult +WorkObservationState