From 301f286c2faf0762e4540ca344073818349d4728 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Mon, 10 Aug 2026 23:04:47 +0800 Subject: [PATCH] feat(plugin): add durable keyed load-once hooks --- abi/proto/v1/invoke.proto | 12 + abi/proto/v1/manifest.proto | 6 + abi/v1/invoke.pb.go | 258 ++++++++++++------ abi/v1/manifest.pb.go | 19 +- ...d-once-hooks-have-durable-host-receipts.md | 40 +++ plugin/load_once.go | 26 ++ plugin/load_once_test.go | 27 ++ plugin/registration.go | 16 +- plugin/wasmguest/describe.go | 3 + plugin/wasmguest/dispatch.go | 25 ++ plugin/wasmguest/dispatch_test.go | 38 +++ 11 files changed, 382 insertions(+), 88 deletions(-) create mode 100644 docs/adr/0002-keyed-load-once-hooks-have-durable-host-receipts.md create mode 100644 plugin/load_once.go create mode 100644 plugin/load_once_test.go diff --git a/abi/proto/v1/invoke.proto b/abi/proto/v1/invoke.proto index 02095b9..adb0229 100644 --- a/abi/proto/v1/invoke.proto +++ b/abi/proto/v1/invoke.proto @@ -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 host→guest 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 {} diff --git a/abi/proto/v1/manifest.proto b/abi/proto/v1/manifest.proto index 325799b..fbc9986 100644 --- a/abi/proto/v1/manifest.proto +++ b/abi/proto/v1/manifest.proto @@ -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 diff --git a/abi/v1/invoke.pb.go b/abi/v1/invoke.pb.go index 97fae10..adf4d36 100644 --- a/abi/v1/invoke.pb.go +++ b/abi/v1/invoke.pb.go @@ -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, }, diff --git a/abi/v1/manifest.pb.go b/abi/v1/manifest.pb.go index 1547af6..d25184e 100644 --- a/abi/v1/manifest.pb.go +++ b/abi/v1/manifest.pb.go @@ -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" + diff --git a/docs/adr/0002-keyed-load-once-hooks-have-durable-host-receipts.md b/docs/adr/0002-keyed-load-once-hooks-have-durable-host-receipts.md new file mode 100644 index 0000000..c5c0c54 --- /dev/null +++ b/docs/adr/0002-keyed-load-once-hooks-have-durable-host-receipts.md @@ -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 diff --git a/plugin/load_once.go b/plugin/load_once.go new file mode 100644 index 0000000..2fb7f49 --- /dev/null +++ b/plugin/load_once.go @@ -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 +} diff --git a/plugin/load_once_test.go b/plugin/load_once_test.go new file mode 100644 index 0000000..7a574b8 --- /dev/null +++ b/plugin/load_once_test.go @@ -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") + } + }) + } +} diff --git a/plugin/registration.go b/plugin/registration.go index 0de11f2..a2dcb31 100644 --- a/plugin/registration.go +++ b/plugin/registration.go @@ -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 diff --git a/plugin/wasmguest/describe.go b/plugin/wasmguest/describe.go index 7ec7144..3959eab 100644 --- a/plugin/wasmguest/describe.go +++ b/plugin/wasmguest/describe.go @@ -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 diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go index d6a3875..89bfae5 100644 --- a/plugin/wasmguest/dispatch.go +++ b/plugin/wasmguest/dispatch.go @@ -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 { diff --git a/plugin/wasmguest/dispatch_test.go b/plugin/wasmguest/dispatch_test.go index 1e26989..73a4fa5 100644 --- a/plugin/wasmguest/dispatch_test.go +++ b/plugin/wasmguest/dispatch_test.go @@ -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()) + } +}