feat(plugin): add durable keyed load-once hooks

This commit is contained in:
Alex Dunmow 2026-08-10 23:04:47 +08:00
parent 1ee4db6f0d
commit 301f286c2f
11 changed files with 382 additions and 88 deletions

View File

@ -61,6 +61,9 @@ enum Hook {
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
HOOK_DIRECTORY_PIN_DECORATOR = 15;
// Run one keyed PluginRegistration.LoadOnce callback:
// payload = LoadOnceRequest / LoadOnceResponse.
HOOK_LOAD_ONCE = 16;
}
// InvokeRequest is the hostguest call envelope.
@ -145,6 +148,15 @@ message HostConfig {
message LoadResponse {}
// LoadOnceRequest identifies the callback selected by the host from the
// manifest's load_once_keys. The host records the key only after a successful
// response; failed callbacks may be retried on a later load.
message LoadOnceRequest {
string key = 1;
}
message LoadOnceResponse {}
message UnloadRequest {}
message UnloadResponse {}

View File

@ -164,6 +164,12 @@ message PluginManifest {
// every procedure against rbac_method_roles before advertising or invoking
// it, so descriptor metadata cannot widen the plugin's authorization map.
repeated MCPToolDescriptor mcp_tools = 38;
// Stable keys for PluginRegistration.LoadOnce callbacks. The host persists
// successful (plugin,key) executions and invokes HOOK_LOAD_ONCE only for
// keys it has never completed. Changing a key is the explicit migration
// mechanism for running a replacement callback once.
repeated string load_once_keys = 39;
}
// MCPToolDescriptor is one unary ConnectRPC method prepared for the host MCP

View File

@ -78,6 +78,9 @@ const (
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
Hook_HOOK_DIRECTORY_PIN_DECORATOR Hook = 15
// Run one keyed PluginRegistration.LoadOnce callback:
// payload = LoadOnceRequest / LoadOnceResponse.
Hook_HOOK_LOAD_ONCE Hook = 16
)
// Enum value maps for Hook.
@ -99,6 +102,7 @@ var (
13: "HOOK_BRIDGE_CALL",
14: "HOOK_DIRECTORY_PANEL_SECTION",
15: "HOOK_DIRECTORY_PIN_DECORATOR",
16: "HOOK_LOAD_ONCE",
}
Hook_value = map[string]int32{
"HOOK_UNSPECIFIED": 0,
@ -117,6 +121,7 @@ var (
"HOOK_BRIDGE_CALL": 13,
"HOOK_DIRECTORY_PANEL_SECTION": 14,
"HOOK_DIRECTORY_PIN_DECORATOR": 15,
"HOOK_LOAD_ONCE": 16,
}
)
@ -627,6 +632,89 @@ func (*LoadResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{7}
}
// LoadOnceRequest identifies the callback selected by the host from the
// manifest's load_once_keys. The host records the key only after a successful
// response; failed callbacks may be retried on a later load.
type LoadOnceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoadOnceRequest) Reset() {
*x = LoadOnceRequest{}
mi := &file_v1_invoke_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoadOnceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadOnceRequest) ProtoMessage() {}
func (x *LoadOnceRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoadOnceRequest.ProtoReflect.Descriptor instead.
func (*LoadOnceRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{8}
}
func (x *LoadOnceRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
type LoadOnceResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoadOnceResponse) Reset() {
*x = LoadOnceResponse{}
mi := &file_v1_invoke_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoadOnceResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadOnceResponse) ProtoMessage() {}
func (x *LoadOnceResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoadOnceResponse.ProtoReflect.Descriptor instead.
func (*LoadOnceResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{9}
}
type UnloadRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@ -635,7 +723,7 @@ type UnloadRequest struct {
func (x *UnloadRequest) Reset() {
*x = UnloadRequest{}
mi := &file_v1_invoke_proto_msgTypes[8]
mi := &file_v1_invoke_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -647,7 +735,7 @@ func (x *UnloadRequest) String() string {
func (*UnloadRequest) ProtoMessage() {}
func (x *UnloadRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[8]
mi := &file_v1_invoke_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -660,7 +748,7 @@ func (x *UnloadRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UnloadRequest.ProtoReflect.Descriptor instead.
func (*UnloadRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{8}
return file_v1_invoke_proto_rawDescGZIP(), []int{10}
}
type UnloadResponse struct {
@ -671,7 +759,7 @@ type UnloadResponse struct {
func (x *UnloadResponse) Reset() {
*x = UnloadResponse{}
mi := &file_v1_invoke_proto_msgTypes[9]
mi := &file_v1_invoke_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -683,7 +771,7 @@ func (x *UnloadResponse) String() string {
func (*UnloadResponse) ProtoMessage() {}
func (x *UnloadResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[9]
mi := &file_v1_invoke_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -696,7 +784,7 @@ func (x *UnloadResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use UnloadResponse.ProtoReflect.Descriptor instead.
func (*UnloadResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{9}
return file_v1_invoke_proto_rawDescGZIP(), []int{11}
}
// JobRequest dispatches one background job to the guest handler registered
@ -712,7 +800,7 @@ type JobRequest struct {
func (x *JobRequest) Reset() {
*x = JobRequest{}
mi := &file_v1_invoke_proto_msgTypes[10]
mi := &file_v1_invoke_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -724,7 +812,7 @@ func (x *JobRequest) String() string {
func (*JobRequest) ProtoMessage() {}
func (x *JobRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[10]
mi := &file_v1_invoke_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -737,7 +825,7 @@ func (x *JobRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use JobRequest.ProtoReflect.Descriptor instead.
func (*JobRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{10}
return file_v1_invoke_proto_rawDescGZIP(), []int{12}
}
func (x *JobRequest) GetJobType() string {
@ -764,7 +852,7 @@ type JobResponse struct {
func (x *JobResponse) Reset() {
*x = JobResponse{}
mi := &file_v1_invoke_proto_msgTypes[11]
mi := &file_v1_invoke_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -776,7 +864,7 @@ func (x *JobResponse) String() string {
func (*JobResponse) ProtoMessage() {}
func (x *JobResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[11]
mi := &file_v1_invoke_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -789,7 +877,7 @@ func (x *JobResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use JobResponse.ProtoReflect.Descriptor instead.
func (*JobResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{11}
return file_v1_invoke_proto_rawDescGZIP(), []int{13}
}
func (x *JobResponse) GetResultJson() []byte {
@ -811,7 +899,7 @@ type RagFetchRequest struct {
func (x *RagFetchRequest) Reset() {
*x = RagFetchRequest{}
mi := &file_v1_invoke_proto_msgTypes[12]
mi := &file_v1_invoke_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -823,7 +911,7 @@ func (x *RagFetchRequest) String() string {
func (*RagFetchRequest) ProtoMessage() {}
func (x *RagFetchRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[12]
mi := &file_v1_invoke_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -836,7 +924,7 @@ func (x *RagFetchRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RagFetchRequest.ProtoReflect.Descriptor instead.
func (*RagFetchRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{12}
return file_v1_invoke_proto_rawDescGZIP(), []int{14}
}
func (x *RagFetchRequest) GetContentType() string {
@ -864,7 +952,7 @@ type RagFetchResponse struct {
func (x *RagFetchResponse) Reset() {
*x = RagFetchResponse{}
mi := &file_v1_invoke_proto_msgTypes[13]
mi := &file_v1_invoke_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -876,7 +964,7 @@ func (x *RagFetchResponse) String() string {
func (*RagFetchResponse) ProtoMessage() {}
func (x *RagFetchResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[13]
mi := &file_v1_invoke_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -889,7 +977,7 @@ func (x *RagFetchResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RagFetchResponse.ProtoReflect.Descriptor instead.
func (*RagFetchResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{13}
return file_v1_invoke_proto_rawDescGZIP(), []int{15}
}
func (x *RagFetchResponse) GetTitle() string {
@ -921,7 +1009,7 @@ type MediaHookRequest struct {
func (x *MediaHookRequest) Reset() {
*x = MediaHookRequest{}
mi := &file_v1_invoke_proto_msgTypes[14]
mi := &file_v1_invoke_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -933,7 +1021,7 @@ func (x *MediaHookRequest) String() string {
func (*MediaHookRequest) ProtoMessage() {}
func (x *MediaHookRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[14]
mi := &file_v1_invoke_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -946,7 +1034,7 @@ func (x *MediaHookRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MediaHookRequest.ProtoReflect.Descriptor instead.
func (*MediaHookRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{14}
return file_v1_invoke_proto_rawDescGZIP(), []int{16}
}
func (x *MediaHookRequest) GetEvent() isMediaHookRequest_Event {
@ -998,7 +1086,7 @@ type MediaHookResponse struct {
func (x *MediaHookResponse) Reset() {
*x = MediaHookResponse{}
mi := &file_v1_invoke_proto_msgTypes[15]
mi := &file_v1_invoke_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1010,7 +1098,7 @@ func (x *MediaHookResponse) String() string {
func (*MediaHookResponse) ProtoMessage() {}
func (x *MediaHookResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[15]
mi := &file_v1_invoke_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1023,7 +1111,7 @@ func (x *MediaHookResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MediaHookResponse.ProtoReflect.Descriptor instead.
func (*MediaHookResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{15}
return file_v1_invoke_proto_rawDescGZIP(), []int{17}
}
// MediaAnalyzedEvent mirrors plugin.MediaAnalyzedEvent (UUIDs as strings).
@ -1045,7 +1133,7 @@ type MediaAnalyzedEvent struct {
func (x *MediaAnalyzedEvent) Reset() {
*x = MediaAnalyzedEvent{}
mi := &file_v1_invoke_proto_msgTypes[16]
mi := &file_v1_invoke_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1057,7 +1145,7 @@ func (x *MediaAnalyzedEvent) String() string {
func (*MediaAnalyzedEvent) ProtoMessage() {}
func (x *MediaAnalyzedEvent) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[16]
mi := &file_v1_invoke_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1070,7 +1158,7 @@ func (x *MediaAnalyzedEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use MediaAnalyzedEvent.ProtoReflect.Descriptor instead.
func (*MediaAnalyzedEvent) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{16}
return file_v1_invoke_proto_rawDescGZIP(), []int{18}
}
func (x *MediaAnalyzedEvent) GetMediaId() string {
@ -1156,7 +1244,7 @@ type AiToolCallRequest struct {
func (x *AiToolCallRequest) Reset() {
*x = AiToolCallRequest{}
mi := &file_v1_invoke_proto_msgTypes[17]
mi := &file_v1_invoke_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1168,7 +1256,7 @@ func (x *AiToolCallRequest) String() string {
func (*AiToolCallRequest) ProtoMessage() {}
func (x *AiToolCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[17]
mi := &file_v1_invoke_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1181,7 +1269,7 @@ func (x *AiToolCallRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AiToolCallRequest.ProtoReflect.Descriptor instead.
func (*AiToolCallRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{17}
return file_v1_invoke_proto_rawDescGZIP(), []int{19}
}
func (x *AiToolCallRequest) GetSlug() string {
@ -1211,7 +1299,7 @@ type AiToolCallResponse struct {
func (x *AiToolCallResponse) Reset() {
*x = AiToolCallResponse{}
mi := &file_v1_invoke_proto_msgTypes[18]
mi := &file_v1_invoke_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1223,7 +1311,7 @@ func (x *AiToolCallResponse) String() string {
func (*AiToolCallResponse) ProtoMessage() {}
func (x *AiToolCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[18]
mi := &file_v1_invoke_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1236,7 +1324,7 @@ func (x *AiToolCallResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use AiToolCallResponse.ProtoReflect.Descriptor instead.
func (*AiToolCallResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{18}
return file_v1_invoke_proto_rawDescGZIP(), []int{20}
}
func (x *AiToolCallResponse) GetContent() string {
@ -1268,7 +1356,7 @@ type BridgeCallRequest struct {
func (x *BridgeCallRequest) Reset() {
*x = BridgeCallRequest{}
mi := &file_v1_invoke_proto_msgTypes[19]
mi := &file_v1_invoke_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1280,7 +1368,7 @@ func (x *BridgeCallRequest) String() string {
func (*BridgeCallRequest) ProtoMessage() {}
func (x *BridgeCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[19]
mi := &file_v1_invoke_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1293,7 +1381,7 @@ func (x *BridgeCallRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use BridgeCallRequest.ProtoReflect.Descriptor instead.
func (*BridgeCallRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{19}
return file_v1_invoke_proto_rawDescGZIP(), []int{21}
}
func (x *BridgeCallRequest) GetServiceName() string {
@ -1326,7 +1414,7 @@ type BridgeCallResponse struct {
func (x *BridgeCallResponse) Reset() {
*x = BridgeCallResponse{}
mi := &file_v1_invoke_proto_msgTypes[20]
mi := &file_v1_invoke_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1338,7 +1426,7 @@ func (x *BridgeCallResponse) String() string {
func (*BridgeCallResponse) ProtoMessage() {}
func (x *BridgeCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[20]
mi := &file_v1_invoke_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1351,7 +1439,7 @@ func (x *BridgeCallResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use BridgeCallResponse.ProtoReflect.Descriptor instead.
func (*BridgeCallResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{20}
return file_v1_invoke_proto_rawDescGZIP(), []int{22}
}
func (x *BridgeCallResponse) GetPayload() []byte {
@ -1375,7 +1463,7 @@ type DirectoryPanelSectionRequest struct {
func (x *DirectoryPanelSectionRequest) Reset() {
*x = DirectoryPanelSectionRequest{}
mi := &file_v1_invoke_proto_msgTypes[21]
mi := &file_v1_invoke_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1387,7 +1475,7 @@ func (x *DirectoryPanelSectionRequest) String() string {
func (*DirectoryPanelSectionRequest) ProtoMessage() {}
func (x *DirectoryPanelSectionRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[21]
mi := &file_v1_invoke_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1400,7 +1488,7 @@ func (x *DirectoryPanelSectionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPanelSectionRequest.ProtoReflect.Descriptor instead.
func (*DirectoryPanelSectionRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{21}
return file_v1_invoke_proto_rawDescGZIP(), []int{23}
}
func (x *DirectoryPanelSectionRequest) GetIndex() uint32 {
@ -1426,7 +1514,7 @@ type DirectoryPanelSectionResponse struct {
func (x *DirectoryPanelSectionResponse) Reset() {
*x = DirectoryPanelSectionResponse{}
mi := &file_v1_invoke_proto_msgTypes[22]
mi := &file_v1_invoke_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1438,7 +1526,7 @@ func (x *DirectoryPanelSectionResponse) String() string {
func (*DirectoryPanelSectionResponse) ProtoMessage() {}
func (x *DirectoryPanelSectionResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[22]
mi := &file_v1_invoke_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1451,7 +1539,7 @@ func (x *DirectoryPanelSectionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPanelSectionResponse.ProtoReflect.Descriptor instead.
func (*DirectoryPanelSectionResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{22}
return file_v1_invoke_proto_rawDescGZIP(), []int{24}
}
func (x *DirectoryPanelSectionResponse) GetHtml() string {
@ -1476,7 +1564,7 @@ type DirectoryPinDecoratorRequest struct {
func (x *DirectoryPinDecoratorRequest) Reset() {
*x = DirectoryPinDecoratorRequest{}
mi := &file_v1_invoke_proto_msgTypes[23]
mi := &file_v1_invoke_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1488,7 +1576,7 @@ func (x *DirectoryPinDecoratorRequest) String() string {
func (*DirectoryPinDecoratorRequest) ProtoMessage() {}
func (x *DirectoryPinDecoratorRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[23]
mi := &file_v1_invoke_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1501,7 +1589,7 @@ func (x *DirectoryPinDecoratorRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPinDecoratorRequest.ProtoReflect.Descriptor instead.
func (*DirectoryPinDecoratorRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{23}
return file_v1_invoke_proto_rawDescGZIP(), []int{25}
}
func (x *DirectoryPinDecoratorRequest) GetIndex() uint32 {
@ -1535,7 +1623,7 @@ type DirectoryPinDecoratorResponse struct {
func (x *DirectoryPinDecoratorResponse) Reset() {
*x = DirectoryPinDecoratorResponse{}
mi := &file_v1_invoke_proto_msgTypes[24]
mi := &file_v1_invoke_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1547,7 +1635,7 @@ func (x *DirectoryPinDecoratorResponse) String() string {
func (*DirectoryPinDecoratorResponse) ProtoMessage() {}
func (x *DirectoryPinDecoratorResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[24]
mi := &file_v1_invoke_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1560,7 +1648,7 @@ func (x *DirectoryPinDecoratorResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPinDecoratorResponse.ProtoReflect.Descriptor instead.
func (*DirectoryPinDecoratorResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{24}
return file_v1_invoke_proto_rawDescGZIP(), []int{26}
}
func (x *DirectoryPinDecoratorResponse) GetPinJson() []byte {
@ -1588,7 +1676,7 @@ type ModerationDecisionEvent struct {
func (x *ModerationDecisionEvent) Reset() {
*x = ModerationDecisionEvent{}
mi := &file_v1_invoke_proto_msgTypes[25]
mi := &file_v1_invoke_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1600,7 +1688,7 @@ func (x *ModerationDecisionEvent) String() string {
func (*ModerationDecisionEvent) ProtoMessage() {}
func (x *ModerationDecisionEvent) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[25]
mi := &file_v1_invoke_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1613,7 +1701,7 @@ func (x *ModerationDecisionEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ModerationDecisionEvent.ProtoReflect.Descriptor instead.
func (*ModerationDecisionEvent) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{25}
return file_v1_invoke_proto_rawDescGZIP(), []int{27}
}
func (x *ModerationDecisionEvent) GetMediaId() string {
@ -1707,7 +1795,10 @@ const file_v1_invoke_proto_rawDesc = "" +
"\aapp_url\x18\x01 \x01(\tR\x06appUrl\x12\x1d\n" +
"\n" +
"media_path\x18\x02 \x01(\tR\tmediaPath\"\x0e\n" +
"\fLoadResponse\"\x0f\n" +
"\fLoadResponse\"#\n" +
"\x0fLoadOnceRequest\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\"\x12\n" +
"\x10LoadOnceResponse\"\x0f\n" +
"\rUnloadRequest\"\x10\n" +
"\x0eUnloadResponse\"H\n" +
"\n" +
@ -1780,7 +1871,7 @@ const file_v1_invoke_proto_rawDesc = "" +
"sourceType\x12\"\n" +
"\rsource_ref_id\x18\a \x01(\tR\vsourceRefId\x12!\n" +
"\fmoderated_by\x18\b \x01(\tR\vmoderatedBy\x12\x12\n" +
"\x04note\x18\t \x01(\tR\x04note*\xea\x02\n" +
"\x04note\x18\t \x01(\tR\x04note*\xfe\x02\n" +
"\x04Hook\x12\x14\n" +
"\x10HOOK_UNSPECIFIED\x10\x00\x12\x15\n" +
"\x11HOOK_RENDER_BLOCK\x10\x01\x12\x18\n" +
@ -1798,7 +1889,8 @@ const file_v1_invoke_proto_rawDesc = "" +
"\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" +
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
"\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\x12 \n" +
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\x95\x02\n" +
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f\x12\x12\n" +
"\x0eHOOK_LOAD_ONCE\x10\x10*\x95\x02\n" +
"\fAbiErrorCode\x12\x1e\n" +
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +
@ -1822,7 +1914,7 @@ func file_v1_invoke_proto_rawDescGZIP() []byte {
}
var file_v1_invoke_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_v1_invoke_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
var file_v1_invoke_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
var file_v1_invoke_proto_goTypes = []any{
(Hook)(0), // 0: abi.v1.Hook
(AbiErrorCode)(0), // 1: abi.v1.AbiErrorCode
@ -1834,34 +1926,36 @@ var file_v1_invoke_proto_goTypes = []any{
(*LoadRequest)(nil), // 7: abi.v1.LoadRequest
(*HostConfig)(nil), // 8: abi.v1.HostConfig
(*LoadResponse)(nil), // 9: abi.v1.LoadResponse
(*UnloadRequest)(nil), // 10: abi.v1.UnloadRequest
(*UnloadResponse)(nil), // 11: abi.v1.UnloadResponse
(*JobRequest)(nil), // 12: abi.v1.JobRequest
(*JobResponse)(nil), // 13: abi.v1.JobResponse
(*RagFetchRequest)(nil), // 14: abi.v1.RagFetchRequest
(*RagFetchResponse)(nil), // 15: abi.v1.RagFetchResponse
(*MediaHookRequest)(nil), // 16: abi.v1.MediaHookRequest
(*MediaHookResponse)(nil), // 17: abi.v1.MediaHookResponse
(*MediaAnalyzedEvent)(nil), // 18: abi.v1.MediaAnalyzedEvent
(*AiToolCallRequest)(nil), // 19: abi.v1.AiToolCallRequest
(*AiToolCallResponse)(nil), // 20: abi.v1.AiToolCallResponse
(*BridgeCallRequest)(nil), // 21: abi.v1.BridgeCallRequest
(*BridgeCallResponse)(nil), // 22: abi.v1.BridgeCallResponse
(*DirectoryPanelSectionRequest)(nil), // 23: abi.v1.DirectoryPanelSectionRequest
(*DirectoryPanelSectionResponse)(nil), // 24: abi.v1.DirectoryPanelSectionResponse
(*DirectoryPinDecoratorRequest)(nil), // 25: abi.v1.DirectoryPinDecoratorRequest
(*DirectoryPinDecoratorResponse)(nil), // 26: abi.v1.DirectoryPinDecoratorResponse
(*ModerationDecisionEvent)(nil), // 27: abi.v1.ModerationDecisionEvent
(*PluginManifest)(nil), // 28: abi.v1.PluginManifest
(*LoadOnceRequest)(nil), // 10: abi.v1.LoadOnceRequest
(*LoadOnceResponse)(nil), // 11: abi.v1.LoadOnceResponse
(*UnloadRequest)(nil), // 12: abi.v1.UnloadRequest
(*UnloadResponse)(nil), // 13: abi.v1.UnloadResponse
(*JobRequest)(nil), // 14: abi.v1.JobRequest
(*JobResponse)(nil), // 15: abi.v1.JobResponse
(*RagFetchRequest)(nil), // 16: abi.v1.RagFetchRequest
(*RagFetchResponse)(nil), // 17: abi.v1.RagFetchResponse
(*MediaHookRequest)(nil), // 18: abi.v1.MediaHookRequest
(*MediaHookResponse)(nil), // 19: abi.v1.MediaHookResponse
(*MediaAnalyzedEvent)(nil), // 20: abi.v1.MediaAnalyzedEvent
(*AiToolCallRequest)(nil), // 21: abi.v1.AiToolCallRequest
(*AiToolCallResponse)(nil), // 22: abi.v1.AiToolCallResponse
(*BridgeCallRequest)(nil), // 23: abi.v1.BridgeCallRequest
(*BridgeCallResponse)(nil), // 24: abi.v1.BridgeCallResponse
(*DirectoryPanelSectionRequest)(nil), // 25: abi.v1.DirectoryPanelSectionRequest
(*DirectoryPanelSectionResponse)(nil), // 26: abi.v1.DirectoryPanelSectionResponse
(*DirectoryPinDecoratorRequest)(nil), // 27: abi.v1.DirectoryPinDecoratorRequest
(*DirectoryPinDecoratorResponse)(nil), // 28: abi.v1.DirectoryPinDecoratorResponse
(*ModerationDecisionEvent)(nil), // 29: abi.v1.ModerationDecisionEvent
(*PluginManifest)(nil), // 30: abi.v1.PluginManifest
}
var file_v1_invoke_proto_depIdxs = []int32{
0, // 0: abi.v1.InvokeRequest.hook:type_name -> abi.v1.Hook
4, // 1: abi.v1.InvokeResponse.error:type_name -> abi.v1.AbiError
1, // 2: abi.v1.AbiError.code:type_name -> abi.v1.AbiErrorCode
28, // 3: abi.v1.DescribeResponse.manifest:type_name -> abi.v1.PluginManifest
30, // 3: abi.v1.DescribeResponse.manifest:type_name -> abi.v1.PluginManifest
8, // 4: abi.v1.LoadRequest.host_config:type_name -> abi.v1.HostConfig
18, // 5: abi.v1.MediaHookRequest.media_analyzed:type_name -> abi.v1.MediaAnalyzedEvent
27, // 6: abi.v1.MediaHookRequest.moderation_decision:type_name -> abi.v1.ModerationDecisionEvent
20, // 5: abi.v1.MediaHookRequest.media_analyzed:type_name -> abi.v1.MediaAnalyzedEvent
29, // 6: abi.v1.MediaHookRequest.moderation_decision:type_name -> abi.v1.ModerationDecisionEvent
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
@ -1875,7 +1969,7 @@ func file_v1_invoke_proto_init() {
return
}
file_v1_manifest_proto_init()
file_v1_invoke_proto_msgTypes[14].OneofWrappers = []any{
file_v1_invoke_proto_msgTypes[16].OneofWrappers = []any{
(*MediaHookRequest_MediaAnalyzed)(nil),
(*MediaHookRequest_ModerationDecision)(nil),
}
@ -1885,7 +1979,7 @@ func file_v1_invoke_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_invoke_proto_rawDesc), len(file_v1_invoke_proto_rawDesc)),
NumEnums: 2,
NumMessages: 26,
NumMessages: 28,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -156,7 +156,12 @@ type PluginManifest struct {
// duplicate schemas or procedure metadata by hand. The host still checks
// every procedure against rbac_method_roles before advertising or invoking
// it, so descriptor metadata cannot widen the plugin's authorization map.
McpTools []*MCPToolDescriptor `protobuf:"bytes,38,rep,name=mcp_tools,json=mcpTools,proto3" json:"mcp_tools,omitempty"`
McpTools []*MCPToolDescriptor `protobuf:"bytes,38,rep,name=mcp_tools,json=mcpTools,proto3" json:"mcp_tools,omitempty"`
// Stable keys for PluginRegistration.LoadOnce callbacks. The host persists
// successful (plugin,key) executions and invokes HOOK_LOAD_ONCE only for
// keys it has never completed. Changing a key is the explicit migration
// mechanism for running a replacement callback once.
LoadOnceKeys []string `protobuf:"bytes,39,rep,name=load_once_keys,json=loadOnceKeys,proto3" json:"load_once_keys,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -457,6 +462,13 @@ func (x *PluginManifest) GetMcpTools() []*MCPToolDescriptor {
return nil
}
func (x *PluginManifest) GetLoadOnceKeys() []string {
if x != nil {
return x.LoadOnceKeys
}
return nil
}
// MCPToolDescriptor is one unary ConnectRPC method prepared for the host MCP
// catalogue. Procedure, service, method, documentation, and the request JSON
// schema are all derived from protobuf reflection during DESCRIBE.
@ -1524,7 +1536,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
const file_v1_manifest_proto_rawDesc = "" +
"\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\xe7\x0e\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8d\x0f\n" +
"\x0ePluginManifest\x12\x1f\n" +
"\vabi_version\x18\x01 \x01(\rR\n" +
"abiVersion\x12\x12\n" +
@ -1567,7 +1579,8 @@ const file_v1_manifest_proto_rawDesc = "" +
"\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x128\n" +
"\rpublic_routes\x18$ \x03(\v2\x13.abi.v1.PublicRouteR\fpublicRoutes\x12\x18\n" +
"\asitemap\x18% \x01(\bR\asitemap\x126\n" +
"\tmcp_tools\x18& \x03(\v2\x19.abi.v1.MCPToolDescriptorR\bmcpTools\x1aB\n" +
"\tmcp_tools\x18& \x03(\v2\x19.abi.v1.MCPToolDescriptorR\bmcpTools\x12$\n" +
"\x0eload_once_keys\x18' \x03(\tR\floadOnceKeys\x1aB\n" +
"\x14RbacMethodRolesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf2\x01\n" +

View File

@ -0,0 +1,40 @@
# Keyed load-once hooks have durable host receipts
Plugin `Load` callbacks are deliberately repeatable: startup, enable, update,
and provisioning-consent replay can all invoke them. Some initialization is
different. A plugin may add a suggested menu link on its first load, but must
not restore that link after an administrator later removes or renames it.
Decision: `PluginRegistration.LoadOnce` is an ordered slice of callbacks with
stable keys. DESCRIBE publishes those keys in `PluginManifest.load_once_keys`.
The host checks a durable `(plugin, key)` receipt before dispatching
`HOOK_LOAD_ONCE`; it records the receipt only after success. A completed key is
not removed when the plugin is disabled, updated, or uninstalled. A later
release that intentionally needs another one-time action declares a new key.
Load-once runs after the ordinary `Load` hook so repeatable provisioning can
create resources that a one-time callback references. Keys are lowercase,
MCP-style stable identifiers of at most 128 bytes and must be unique within a
registration. Failed callbacks may be retried because no completion receipt
exists. As with database migrations, callbacks should still make their own
external mutation idempotent to cover a process failure between the mutation
and receipt commit.
Encoding first-install booleans in plugin settings was rejected because it
mixes host lifecycle state with customer-visible configuration. Making all
provisioner operations one-shot was rejected because pages, settings defaults,
and other declarations intentionally reconcile on every load. Version-based
execution was rejected because an unrelated release must not repeat the side
effect.
Consequences:
- Native and WASM plugins share one keyed lifecycle abstraction.
- The host, not guest memory, owns durable completion state.
- Updating or re-enabling a plugin cannot recreate an administrator-removed
resource under an already completed key.
- Changing a key is an explicit request to run a new callback once.
Keywords: PluginRegistration.LoadOnce, LoadOnceHook, load_once_keys,
HOOK_LOAD_ONCE, durable receipt, first load, plugin update, idempotency,
one-time provisioning, lifecycle

26
plugin/load_once.go Normal file
View File

@ -0,0 +1,26 @@
package plugin
import (
"fmt"
"regexp"
)
const MaxLoadOnceKeyBytes = 128
var loadOnceKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
// ValidateLoadOnceKeys checks the stable identity contract shared by plugin
// authors, the packer, and the host loader.
func ValidateLoadOnceKeys(keys []string) error {
seen := make(map[string]bool, len(keys))
for _, key := range keys {
if len(key) == 0 || len(key) > MaxLoadOnceKeyBytes || !loadOnceKeyPattern.MatchString(key) {
return fmt.Errorf("invalid load-once key %q: must be 1-%d bytes of lowercase letters, digits, dot, underscore, or hyphen, starting alphanumeric", key, MaxLoadOnceKeyBytes)
}
if seen[key] {
return fmt.Errorf("duplicate load-once key %q", key)
}
seen[key] = true
}
return nil
}

27
plugin/load_once_test.go Normal file
View File

@ -0,0 +1,27 @@
package plugin
import "testing"
func TestValidateLoadOnceKeys(t *testing.T) {
tests := []struct {
name string
keys []string
ok bool
}{
{name: "valid stable keys", keys: []string{"documentation-menu.v1", "seed_defaults-2"}, ok: true},
{name: "empty", keys: []string{""}},
{name: "uppercase", keys: []string{"DocumentationMenu.v1"}},
{name: "duplicate", keys: []string{"menu.v1", "menu.v1"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateLoadOnceKeys(test.keys)
if test.ok && err != nil {
t.Fatalf("ValidateLoadOnceKeys() error = %v", err)
}
if !test.ok && err == nil {
t.Fatal("ValidateLoadOnceKeys() error = nil")
}
})
}
}

View File

@ -13,11 +13,20 @@ import (
// Plugins register their templates and blocks through the provided registries.
type RegisterFunc func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error
// LoadOnceHook is a persistent, keyed load callback. The host runs Run after
// the ordinary Load hook and records a successful execution by plugin name and
// Key. The same key is never run successfully again on that site; changing the
// key explicitly schedules a replacement callback once.
type LoadOnceHook struct {
Key string
Run func(deps CoreServices) error
}
// PluginRegistration defines a compiled-in plugin's entry points.
type PluginRegistration struct {
Name string
Register RegisterFunc
Register RegisterFunc
RegisterWithProvisioner func(tr templates.TemplateRegistry, br blocks.BlockRegistry, p Provisioner) error
Assets func() http.Handler
@ -39,8 +48,9 @@ type PluginRegistration struct {
DirectoryExtensions func() *DirectoryExtensions
MediaHooks MediaHooksProvider
Load func(deps CoreServices) error
Unload func(ctx context.Context) error
Load func(deps CoreServices) error
LoadOnce []LoadOnceHook
Unload func(ctx context.Context) error
Dependencies []Dependency
Migrations func() fs.FS

View File

@ -147,6 +147,9 @@ func (g *guest) buildManifest() *abiv1.PluginManifest {
}
}
m.RequiredIconPacks = append(m.RequiredIconPacks, reg.RequiredIconPacks...)
for _, hook := range reg.LoadOnce {
m.LoadOnceKeys = append(m.LoadOnceKeys, hook.Key)
}
// Custom template tags/filters the plugin registered via blocks.RegisterTag
// / blocks.RegisterFilter during the Register pass (runRegister reset the

View File

@ -185,6 +185,8 @@ func (g *guest) invoke(hookID uint32, req []byte) (out []byte) {
payload, abiErr = g.runJob(ctx, env.GetPayload())
case abiv1.Hook_HOOK_LOAD:
payload, abiErr = g.load(env.GetPayload())
case abiv1.Hook_HOOK_LOAD_ONCE:
payload, abiErr = g.loadOnce(env.GetPayload())
case abiv1.Hook_HOOK_UNLOAD:
payload, abiErr = g.unload(ctx)
case abiv1.Hook_HOOK_RAG_FETCH:
@ -470,6 +472,29 @@ func (g *guest) load(payload []byte) (proto.Message, *abiv1.AbiError) {
return &abiv1.LoadResponse{}, nil
}
func (g *guest) loadOnce(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.LoadOnceRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("LoadOnceRequest", err)
}
for _, hook := range g.reg.LoadOnce {
if hook.Key != req.GetKey() {
continue
}
if hook.Run == nil {
return nil, internalError("load-once hook " + hook.Key + " has no callback")
}
if err := hook.Run(g.services); err != nil {
return nil, internalError(err.Error())
}
return &abiv1.LoadOnceResponse{}, nil
}
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "unknown load-once key " + req.GetKey(),
}
}
func (g *guest) unload(ctx context.Context) (proto.Message, *abiv1.AbiError) {
if g.reg.Unload != nil {
if err := g.reg.Unload(ctx); err != nil {

View File

@ -451,3 +451,41 @@ func TestLoadUnloadHooks(t *testing.T) {
t.Fatalf("unload error: %v", resp.GetError())
}
}
func TestDescribeAndDispatchLoadOnceHook(t *testing.T) {
calls := 0
reg := fixtureRegistration()
reg.LoadOnce = []plugin.LoadOnceHook{{
Key: "documentation-menu.v1",
Run: func(plugin.CoreServices) error {
calls++
return nil
},
}}
g := newGuest(reg)
describe := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if describe.GetError() != nil {
t.Fatalf("describe error: %v", describe.GetError())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(describe.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal DescribeResponse: %v", err)
}
if got := dr.GetManifest().GetLoadOnceKeys(); len(got) != 1 || got[0] != "documentation-menu.v1" {
t.Fatalf("load_once_keys = %v", got)
}
resp := invokeHook(t, g, abiv1.Hook_HOOK_LOAD_ONCE, &abiv1.LoadOnceRequest{Key: "documentation-menu.v1"})
if resp.GetError() != nil {
t.Fatalf("load-once error: %v", resp.GetError())
}
if calls != 1 {
t.Fatalf("calls = %d, want 1", calls)
}
resp = invokeHook(t, g, abiv1.Hook_HOOK_LOAD_ONCE, &abiv1.LoadOnceRequest{Key: "unknown.v1"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("unknown key error = %v, want UNIMPLEMENTED", resp.GetError())
}
}