Skip to content

Commit de6e513

Browse files
committed
fix(plugin-hooks): runPluginHook returns output + audit log recording
- runPluginHook returns (stdout, stderr, exitCode, error) instead of just error - firePluginHooks records audit entries via store parameter - nil-safe cmd.ProcessState.ExitCode() on timeout - 3 unit tests (success, missing binary, timeout) - plugin_hooks.txt E2E testscript - Closes st-phw1
1 parent 4a0d09f commit de6e513

4 files changed

Lines changed: 165 additions & 32 deletions

File tree

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
// SPDX-License-Identifier: MIT
2-
// Purpose: plugin hook wiring — fires the [[hooks]] declared in plugin.toml
3-
// for the same todo events the built-in HookConfig fires. Plugin hooks run
4-
// as subprocesses (sh -c) with the same SIN_TODO_* env vars so the plugin
5-
// can react to todo state changes identically to a user-configured hook.
61
package todo
72

83
import (
@@ -11,6 +6,7 @@ import (
116
"fmt"
127
"os"
138
"os/exec"
9+
"strings"
1410
"sync"
1511
"time"
1612

@@ -30,17 +26,7 @@ func pluginRegistry() *plugins.Registry {
3026
return pluginReg
3127
}
3228

33-
// firePluginHooks runs every enabled plugin hook registered for the given
34-
// event, with the same HookContext semantics the built-in hooks use. Errors
35-
// are logged to stderr but never block the caller — the primary op already
36-
// succeeded by the time hooks fire.
37-
//
38-
// TODO(st-phw1): hook output (stdout) is currently NOT recorded in the audit log.
39-
// Track at: docs/issues/st-phw1-plugin-hook-wiring.md
40-
// Plan: docs/plans/plugin-system-completion.md
41-
// Target: v2.5.0 — append stdout/stderr to the audit log entry so users can
42-
// debug plugin hooks via `sin-code todo audit <id>`.
43-
func firePluginHooks(event HookEvent, t *Todo, from, to, note string) {
29+
func firePluginHooks(store *Store, event HookEvent, t *Todo, from, to, note string) {
4430
reg := pluginRegistry()
4531
if reg == nil {
4632
return
@@ -51,11 +37,33 @@ func firePluginHooks(event HookEvent, t *Todo, from, to, note string) {
5137
}
5238
ctx := HookContext{Event: event, Todo: t, From: from, To: to, Note: note, Actor: currentActor()}
5339
for _, h := range hooks {
54-
runPluginHook(h, ctx)
40+
stdout, stderr, exitCode, err := runPluginHook(h, ctx)
41+
note := strings.TrimSpace(stdout)
42+
if note == "" {
43+
note = strings.TrimSpace(stderr)
44+
}
45+
if note == "" {
46+
note = fmt.Sprintf("exit=%d", exitCode)
47+
}
48+
if err != nil {
49+
note += " err=" + err.Error()
50+
}
51+
_ = store.AppendAudit(AuditEntry{
52+
TodoID: t.ID,
53+
Actor: currentActor(),
54+
Action: "plugin_hook:" + h.Event,
55+
From: h.Plugin,
56+
To: note,
57+
Timestamp: time.Now(),
58+
})
59+
if err != nil {
60+
fmt.Fprintf(os.Stderr, "plugin-hook warning: plugin=%s event=%s cmd=%q err=%v stderr=%s\n",
61+
h.Plugin, h.Event, h.Command, err, stderr)
62+
}
5563
}
5664
}
5765

58-
func runPluginHook(h plugins.HookDef, ctx HookContext) {
66+
func runPluginHook(h plugins.HookDef, ctx HookContext) (stdout, stderr string, exitCode int, err error) {
5967
timeout := time.Duration(h.Timeout) * time.Second
6068
if timeout <= 0 {
6169
timeout = 30 * time.Second
@@ -66,13 +74,20 @@ func runPluginHook(h plugins.HookDef, ctx HookContext) {
6674
cmd := exec.CommandContext(execCtx, "sh", "-c", h.Command)
6775
cmd.Env = buildEnv(ctx)
6876

69-
var stdout, stderr bytes.Buffer
70-
cmd.Stdout = &stdout
71-
cmd.Stderr = &stderr
77+
var outBuf, errBuf bytes.Buffer
78+
cmd.Stdout = &outBuf
79+
cmd.Stderr = &errBuf
7280

73-
err := cmd.Run()
74-
if err != nil {
75-
fmt.Fprintf(os.Stderr, "plugin-hook warning: plugin=%s event=%s cmd=%q err=%v stderr=%s\n",
76-
h.Plugin, h.Event, h.Command, err, stderr.String())
81+
runErr := cmd.Run()
82+
stdout = outBuf.String()
83+
stderr = errBuf.String()
84+
85+
if runErr != nil {
86+
exitCode = -1
87+
if cmd.ProcessState != nil {
88+
exitCode = cmd.ProcessState.ExitCode()
89+
}
90+
return stdout, stderr, exitCode, runErr
7791
}
92+
return stdout, stderr, 0, nil
7893
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: MIT
2+
// Purpose: unit tests for plugin hook wiring: runPluginHook returns output,
3+
// firePluginHooks records audit entries.
4+
package todo
5+
6+
import (
7+
"os"
8+
"testing"
9+
"time"
10+
11+
"github.com/OpenSIN-Code/SIN-Code-Bundle/cmd/sin-code/internal/plugins"
12+
)
13+
14+
func TestRunPluginHookSuccess(t *testing.T) {
15+
stdout, stderr, exitCode, err := runPluginHook(plugins.HookDef{
16+
Plugin: "test",
17+
Event: "post_add",
18+
Command: "echo hello-world",
19+
Timeout: 5,
20+
}, HookContext{})
21+
if err != nil {
22+
t.Fatalf("unexpected error: %v", err)
23+
}
24+
if exitCode != 0 {
25+
t.Errorf("expected exit 0, got %d", exitCode)
26+
}
27+
if stdout != "hello-world\n" {
28+
t.Errorf("expected 'hello-world\\n', got %q", stdout)
29+
}
30+
if stderr != "" {
31+
t.Errorf("expected empty stderr, got %q", stderr)
32+
}
33+
}
34+
35+
func TestRunPluginHookMissingBinary(t *testing.T) {
36+
stdout, stderr, exitCode, err := runPluginHook(plugins.HookDef{
37+
Plugin: "test",
38+
Event: "post_add",
39+
Command: "nonexistent-binary-xyz",
40+
Timeout: 5,
41+
}, HookContext{})
42+
if err == nil {
43+
t.Fatal("expected error for missing binary")
44+
}
45+
if exitCode == 0 {
46+
t.Error("expected non-zero exit code")
47+
}
48+
_ = stdout
49+
_ = stderr
50+
}
51+
52+
func TestRunPluginHookTimeout(t *testing.T) {
53+
stdout, stderr, exitCode, err := runPluginHook(plugins.HookDef{
54+
Plugin: "test",
55+
Event: "post_add",
56+
Command: "sleep 10",
57+
Timeout: 1,
58+
}, HookContext{})
59+
if err == nil {
60+
t.Fatal("expected timeout error")
61+
}
62+
if exitCode == -1 {
63+
t.Log("timeout processes often have exit -1 on macOS")
64+
}
65+
_ = stdout
66+
_ = stderr
67+
_ = exitCode
68+
}
69+
70+
func TestFirePluginHooksRecordsAudit(t *testing.T) {
71+
if os.Getenv("SIN_CODE_TEST_PLUGIN_HOOKS") == "" {
72+
t.Skip("set SIN_CODE_TEST_PLUGIN_HOOKS=1 to run (requires .sin-code/plugins directory)")
73+
}
74+
store := tempStore(t)
75+
now := time.Now()
76+
todo := &Todo{
77+
ID: "st-test-" + GenerateID(),
78+
Title: "test",
79+
}
80+
firePluginHooks(store, EventPostAdd, todo, "", todo.Title, "")
81+
entries, err := store.ListAudit(todo.ID)
82+
if err != nil {
83+
t.Fatalf("ListAudit: %v", err)
84+
}
85+
if len(entries) == 0 {
86+
t.Log("no plugin hooks fired (no plugins configured) — this is OK")
87+
}
88+
_ = now
89+
}

‎cmd/sin-code/internal/todo/todo.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ var addCmd = &cobra.Command{
189189
TodoID: t.ID, Actor: currentActor(), Action: "create",
190190
To: t.Title,
191191
})
192-
fireHooks(EventPostAdd, t, "", t.Title, "")
192+
fireHooks(store, EventPostAdd, t, "", t.Title, "")
193193
notify(notifications.TypeTodoCreated, t.ID, t.Title,
194194
fmt.Sprintf("New %s %s: %s", t.Priority, t.Type, t.Title), currentActor())
195195
if todoFormat == "json" {
@@ -487,7 +487,7 @@ var claimCmd = &cobra.Command{
487487
TodoID: t.ID, Actor: actor, Action: "claim",
488488
From: old, To: actor,
489489
})
490-
fireHooks(EventPostClaim, t, old, actor, "")
490+
fireHooks(store, EventPostClaim, t, old, actor, "")
491491
fmt.Printf("Claimed %s by %s\n", t.ID, actor)
492492
return nil
493493
},
@@ -549,7 +549,7 @@ var completeCmd = &cobra.Command{
549549
TodoID: t.ID, Actor: currentActor(), Action: "complete",
550550
From: string(old), To: string(t.Status),
551551
})
552-
fireHooks(EventPostComplete, t, string(old), string(t.Status), "")
552+
fireHooks(store, EventPostComplete, t, string(old), string(t.Status), "")
553553
fmt.Printf("Completed %s: %s\n", t.ID, t.Title)
554554
return nil
555555
},
@@ -578,7 +578,7 @@ var cancelCmd = &cobra.Command{
578578
TodoID: t.ID, Actor: currentActor(), Action: "cancel",
579579
From: string(old), To: string(t.Status),
580580
})
581-
fireHooks(EventPostCancel, t, string(old), string(t.Status), "")
581+
fireHooks(store, EventPostCancel, t, string(old), string(t.Status), "")
582582
fmt.Printf("Cancelled %s: %s\n", t.ID, t.Title)
583583
return nil
584584
},
@@ -641,7 +641,7 @@ var depAddCmd = &cobra.Command{
641641
Note: fmt.Sprintf("%s -> %s (%s)", args[0], args[1], dtype),
642642
})
643643
if child, err := store.Get(args[0]); err == nil && child != nil {
644-
fireHooks(EventPostDepAdd, child, args[1], dtype, "")
644+
fireHooks(store, EventPostDepAdd, child, args[1], dtype, "")
645645
}
646646
fmt.Printf("Added %s -> %s (%s)\n", args[0], args[1], dtype)
647647
return nil
@@ -1325,7 +1325,7 @@ func getHookConfig() *HookConfig {
13251325
return hookConfig
13261326
}
13271327

1328-
func fireHooks(event HookEvent, t *Todo, from, to, note string) {
1328+
func fireHooks(store *Store, event HookEvent, t *Todo, from, to, note string) {
13291329
hc := getHookConfig()
13301330
if hc == nil {
13311331
return
@@ -1344,5 +1344,5 @@ func fireHooks(event HookEvent, t *Todo, from, to, note string) {
13441344
case "ignore":
13451345
}
13461346
}
1347-
firePluginHooks(event, t, from, to, note)
1347+
firePluginHooks(store, event, t, from, to, note)
13481348
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Plugin Hooks Integration Test
2+
#
3+
# Verifies that plugin-defined hooks fire on todo events and
4+
# that hook output is recorded in the audit log.
5+
6+
env SIN_CODE_CONFIG_DIR=$WORK/config
7+
env SIN_CODE_TEST_PLUGIN_HOOKS=1
8+
mkdir -p $WORK/config/sin-code/plugins
9+
mkdir -p $WORK/source-plugin/bin
10+
11+
# Create a plugin with a post_add hook
12+
sin-code execute --command 'printf "name = \"hook-test-plugin\"\nversion = \"1.0.0\"\n[[hooks]]\nevent = \"post_add\"\ncommand = \"printf hook-received-%s $SIN_TODO_ID\"\ntimeout = 10\n" > $WORK/source-plugin/plugin.toml'
13+
stdout 'Exit:'
14+
15+
# Install plugin
16+
sin-code execute --command 'cp -r $WORK/source-plugin $WORK/config/sin-code/plugins/hook-test-plugin'
17+
stdout 'Exit:'
18+
19+
# Verify plugin is listed
20+
sin-code plugin --path $WORK/config/sin-code/plugins list
21+
stdout 'hook-test-plugin'
22+
23+
# Add a todo item (should fire post_add hook)
24+
sin-code todo --db $WORK/todo.db add --title "Test hook trigger"
25+
stdout 'st-'
26+
27+
# Verify audit log has the hook entry
28+
sin-code todo --db $WORK/todo.db timeline
29+
stdout 'plugin_hook:post_add'

0 commit comments

Comments
 (0)