@pi-in-go/pigpen-herdr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ module github.com/MichaelKinsy/pigpen/components/herdr/extensions/herdr
2
+
3
+ go 1.26
4
+
5
+ // Pig resolves this requirement to the version-matched staged SDK at build time.
6
+ require github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1
@@ -0,0 +1,2 @@
1
+ github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1/go.mod h1:4j0XhCW0EPOBWvqYE7XKjTvIAahI7K1VyyW3KpjTKj8=
2
+ github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1 h1:Ql9sTkc6dvAXaYQLq5nxqmSZgqu8S0L53ZTs8MkOuxw=
@@ -0,0 +1,449 @@
1
+ package herdr_test
2
+
3
+ // Test harness: a fake herdr binary and a fake PiG host.
4
+ //
5
+ // The fake host speaks PiG's documented extension wire protocol (`pig docs show
6
+ // extension-api`) over an in-memory pipe and drives the real SDK through the
7
+ // public `Extension.RunWithConn`. So the tests cross the same boundary a real
8
+ // host does, and use no PiG-internal package.
9
+
10
+ import (
11
+ "encoding/binary"
12
+ "encoding/json"
13
+ "fmt"
14
+ "io"
15
+ "net"
16
+ "os"
17
+ "path/filepath"
18
+ "slices"
19
+ "strconv"
20
+ "strings"
21
+ "sync"
22
+ "testing"
23
+ "time"
24
+
25
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
26
+ )
27
+
28
+ // ── Fake herdr ───────────────────────────────────────────────────────────────
29
+ //
30
+ // The test binary doubles as the fake `$HERDR_BIN_PATH`: when HERDR_FAKE_LOG is
31
+ // set, it records one line of tab-separated arguments and exits. That works on
32
+ // every platform, with no shell script.
33
+
34
+ func TestMain(m *testing.M) {
35
+ if log := os.Getenv("HERDR_FAKE_LOG"); log != "" {
36
+ os.Exit(runFakeHerdr(log))
37
+ }
38
+ os.Exit(m.Run())
39
+ }
40
+
41
+ func runFakeHerdr(log string) int {
42
+ args := os.Args[1:]
43
+ // Only reports are slow, so a release that does not wait for them lands first.
44
+ if ms, _ := strconv.Atoi(os.Getenv("HERDR_FAKE_DELAY_MS")); ms > 0 && len(args) > 1 && args[1] == "report-agent" {
45
+ time.Sleep(time.Duration(ms) * time.Millisecond)
46
+ }
47
+ // A hung report never returns and never logs; only the call timeout ends it.
48
+ if os.Getenv("HERDR_FAKE_HANG") == "1" && len(args) > 1 && args[1] == "report-agent" {
49
+ time.Sleep(time.Hour)
50
+ }
51
+ f, err := os.OpenFile(log, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
52
+ if err == nil {
53
+ _, _ = f.WriteString(strings.Join(args, "\t") + "\t\n")
54
+ _ = f.Close()
55
+ }
56
+ // A resume command is refused the way real herdr refuses it. The refused call
57
+ // is logged above, so tests see the attempt and the retry.
58
+ if slices.Contains(args, "--") {
59
+ switch os.Getenv("HERDR_FAKE_RESUME") {
60
+ case "old": // herdr < 0.9.2: the CLI does not know the `--` separator
61
+ fmt.Fprintln(os.Stderr, "unknown option: --")
62
+ return 2
63
+ case "invalid": // herdr >= 0.9.2 rejecting the command; the report is not applied
64
+ fmt.Println(`{"error":{"code":"invalid_resume_argv","message":"resume_argv must not contain apostrophes"},"id":"cli:request"}`)
65
+ return 1
66
+ case "message-option": // an old CLI naming another option it does not know
67
+ fmt.Fprintln(os.Stderr, "unknown option: --message=Apply the plan?")
68
+ return 2
69
+ case "broken": // a failure that says nothing about resume support
70
+ fmt.Fprintln(os.Stderr, "error: failed to connect to herdr socket")
71
+ return 1
72
+ }
73
+ }
74
+ code, _ := strconv.Atoi(os.Getenv("HERDR_FAKE_EXIT"))
75
+ if msg := os.Getenv("HERDR_FAKE_STDERR"); msg != "" && code != 0 {
76
+ fmt.Fprintln(os.Stderr, msg)
77
+ }
78
+ return code
79
+ }
80
+
81
+ type call struct{ args []string }
82
+
83
+ type fakeHerdr struct {
84
+ t *testing.T
85
+ log string
86
+ }
87
+
88
+ // installFakeHerdr points the process environment at a fresh fake herdr pane.
89
+ func installFakeHerdr(t *testing.T) *fakeHerdr {
90
+ t.Helper()
91
+ exe, err := os.Executable()
92
+ if err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ h := &fakeHerdr{t: t, log: filepath.Join(t.TempDir(), "calls.log")}
96
+ t.Setenv("HERDR_ENV", "1")
97
+ t.Setenv("HERDR_PANE_ID", "p_7")
98
+ t.Setenv("HERDR_BIN_PATH", exe)
99
+ t.Setenv("HERDR_SOCKET_PATH", filepath.Join(t.TempDir(), "herdr.sock"))
100
+ t.Setenv("HERDR_FAKE_LOG", h.log)
101
+ t.Setenv("HERDR_FAKE_DELAY_MS", "")
102
+ t.Setenv("HERDR_FAKE_EXIT", "")
103
+ t.Setenv("HERDR_FAKE_STDERR", "")
104
+ t.Setenv("HERDR_FAKE_HANG", "")
105
+ t.Setenv("HERDR_FAKE_RESUME", "")
106
+ return h
107
+ }
108
+
109
+ func (h *fakeHerdr) slow(delay time.Duration) {
110
+ h.t.Setenv("HERDR_FAKE_DELAY_MS", strconv.Itoa(int(delay/time.Millisecond)))
111
+ }
112
+
113
+ func (h *fakeHerdr) hang() { h.t.Setenv("HERDR_FAKE_HANG", "1") }
114
+
115
+ // refuseResume makes herdr refuse any report that carries a resume command: "old"
116
+ // (herdr before 0.9.2), "invalid" (invalid_resume_argv) or "broken" (an unrelated failure).
117
+ func (h *fakeHerdr) refuseResume(kind string) { h.t.Setenv("HERDR_FAKE_RESUME", kind) }
118
+
119
+ // failWithMessage makes every call exit with code after printing msg, as herdr
120
+ // prints why it refused a request.
121
+ func (h *fakeHerdr) failWithMessage(code int, msg string) {
122
+ h.failWith(code)
123
+ h.t.Setenv("HERDR_FAKE_STDERR", msg)
124
+ }
125
+
126
+ func (h *fakeHerdr) failWith(code int) { h.t.Setenv("HERDR_FAKE_EXIT", strconv.Itoa(code)) }
127
+
128
+ func (h *fakeHerdr) calls() []call {
129
+ data, err := os.ReadFile(h.log)
130
+ if err != nil {
131
+ return nil
132
+ }
133
+ var out []call
134
+ for _, line := range strings.Split(string(data), "\n") {
135
+ if line == "" {
136
+ continue
137
+ }
138
+ out = append(out, call{args: strings.Split(strings.TrimSuffix(line, "\t"), "\t")})
139
+ }
140
+ return out
141
+ }
142
+
143
+ func (h *fakeHerdr) waitForCalls(count int) []call {
144
+ h.t.Helper()
145
+ deadline := time.Now().Add(3 * time.Second)
146
+ for {
147
+ now := h.calls()
148
+ if len(now) >= count {
149
+ return now
150
+ }
151
+ if time.Now().After(deadline) {
152
+ h.t.Fatalf("expected %d herdr calls, saw %d: %v", count, len(now), now)
153
+ }
154
+ time.Sleep(10 * time.Millisecond)
155
+ }
156
+ }
157
+
158
+ // settle gives a call that must not happen time to show up in the log.
159
+ func settle() { time.Sleep(200 * time.Millisecond) }
160
+
161
+ // flag reads `--name value` or `--name=value`.
162
+ func flag(c call, name string) (string, bool) {
163
+ for i, arg := range c.args {
164
+ if arg == name && i+1 < len(c.args) {
165
+ return c.args[i+1], true
166
+ }
167
+ if strings.HasPrefix(arg, name+"=") {
168
+ return strings.TrimPrefix(arg, name+"="), true
169
+ }
170
+ }
171
+ return "", false
172
+ }
173
+
174
+ // resume returns the command after `--`, and whether the call has one.
175
+ func (c call) resume() ([]string, bool) {
176
+ i := slices.Index(c.args, "--")
177
+ if i < 0 {
178
+ return nil, false
179
+ }
180
+ return c.args[i+1:], true
181
+ }
182
+
183
+ func (c call) verb() string {
184
+ if len(c.args) < 2 {
185
+ return ""
186
+ }
187
+ return c.args[1]
188
+ }
189
+
190
+ func states(all []call) []string {
191
+ out := make([]string, 0, len(all))
192
+ for _, c := range all {
193
+ s, _ := flag(c, "--state")
194
+ out = append(out, s)
195
+ }
196
+ return out
197
+ }
198
+
199
+ func seqs(t *testing.T, all []call) []int64 {
200
+ t.Helper()
201
+ out := make([]int64, 0, len(all))
202
+ for _, c := range all {
203
+ s, _ := flag(c, "--seq")
204
+ n, err := strconv.ParseInt(s, 10, 64)
205
+ if err != nil {
206
+ t.Fatalf("seq %q is not an integer: %v", s, err)
207
+ }
208
+ out = append(out, n)
209
+ }
210
+ return out
211
+ }
212
+
213
+ func verbs(all []call) []string {
214
+ out := make([]string, 0, len(all))
215
+ for _, c := range all {
216
+ out = append(out, c.verb())
217
+ }
218
+ return out
219
+ }
220
+
221
+ // ── Fake PiG host ────────────────────────────────────────────────────────────
222
+
223
+ type wireEnvelope struct {
224
+ Type string `json:"type"`
225
+ ID string `json:"id,omitempty"`
226
+ Register *wireRegister `json:"register,omitempty"`
227
+ Ready map[string]any `json:"ready,omitempty"`
228
+ Request map[string]any `json:"request,omitempty"`
229
+ Response *wireResponse `json:"response,omitempty"`
230
+ Call *wireCall `json:"call,omitempty"`
231
+ CallResult map[string]any `json:"call_result,omitempty"`
232
+ Shutdown map[string]any `json:"shutdown,omitempty"`
233
+ Raw json.RawMessage `json:"-"`
234
+ }
235
+
236
+ type wireRegister struct {
237
+ Name string `json:"name"`
238
+ Handlers []struct {
239
+ Event string `json:"event"`
240
+ HandlerID int `json:"handler_id"`
241
+ } `json:"handlers"`
242
+ Tools []json.RawMessage `json:"tools"`
243
+ Commands []json.RawMessage `json:"commands"`
244
+ }
245
+
246
+ type wireResponse struct {
247
+ Error *struct {
248
+ Message string `json:"message"`
249
+ } `json:"error"`
250
+ }
251
+
252
+ type wireCall struct {
253
+ Method string `json:"method"`
254
+ Args json.RawMessage `json:"args"`
255
+ }
256
+
257
+ // hostState is what the fake host answers for the session getters.
258
+ type hostState struct {
259
+ sessionFile string
260
+ sessionID string
261
+ idle bool
262
+ sessionFailure bool
263
+ }
264
+
265
+ type fakeHost struct {
266
+ t *testing.T
267
+ nc net.Conn
268
+ register wireRegister
269
+ runDone chan error
270
+
271
+ writeMu sync.Mutex
272
+
273
+ mu sync.Mutex
274
+ state hostState
275
+ pending map[string]chan *wireResponse
276
+ nextID int
277
+ handlers map[string]int
278
+ }
279
+
280
+ // startHost runs the extension against a fake host in the given mode.
281
+ func startHost(t *testing.T, ext *sdk.Extension, mode string) *fakeHost {
282
+ t.Helper()
283
+ hostSide, extSide := net.Pipe()
284
+ h := &fakeHost{
285
+ t: t,
286
+ nc: hostSide,
287
+ runDone: make(chan error, 1),
288
+ pending: map[string]chan *wireResponse{},
289
+ handlers: map[string]int{},
290
+ state: hostState{
291
+ sessionFile: "/home/u/.pig/sessions/s1.jsonl",
292
+ sessionID: "s1",
293
+ idle: true,
294
+ },
295
+ }
296
+ go func() { h.runDone <- ext.RunWithConn(extSide) }()
297
+
298
+ first := h.readFrame()
299
+ if first.Type != "register" || first.Register == nil {
300
+ t.Fatalf("expected register, got %+v", first)
301
+ }
302
+ h.register = *first.Register
303
+ for _, handler := range h.register.Handlers {
304
+ h.handlers[handler.Event] = handler.HandlerID
305
+ }
306
+ h.write(map[string]any{"type": "ready", "ready": map[string]any{"session_name": "", "cwd": t.TempDir(), "mode": mode, "width": 80}})
307
+ go h.serve()
308
+ t.Cleanup(h.stop)
309
+ return h
310
+ }
311
+
312
+ func (h *fakeHost) readFrame() wireEnvelope {
313
+ var hdr [4]byte
314
+ if _, err := io.ReadFull(h.nc, hdr[:]); err != nil {
315
+ h.t.Fatalf("read header: %v", err)
316
+ }
317
+ data := make([]byte, binary.BigEndian.Uint32(hdr[:]))
318
+ if _, err := io.ReadFull(h.nc, data); err != nil {
319
+ h.t.Fatalf("read frame: %v", err)
320
+ }
321
+ var env wireEnvelope
322
+ if err := json.Unmarshal(data, &env); err != nil {
323
+ h.t.Fatalf("decode frame %s: %v", data, err)
324
+ }
325
+ return env
326
+ }
327
+
328
+ func (h *fakeHost) write(v any) {
329
+ data, err := json.Marshal(v)
330
+ if err != nil {
331
+ h.t.Errorf("marshal: %v", err)
332
+ return
333
+ }
334
+ var hdr [4]byte
335
+ binary.BigEndian.PutUint32(hdr[:], uint32(len(data)))
336
+ h.writeMu.Lock()
337
+ defer h.writeMu.Unlock()
338
+ if _, err := h.nc.Write(hdr[:]); err != nil {
339
+ return
340
+ }
341
+ _, _ = h.nc.Write(data)
342
+ }
343
+
344
+ // serve answers the extension's host calls and routes event responses.
345
+ func (h *fakeHost) serve() {
346
+ for {
347
+ var hdr [4]byte
348
+ if _, err := io.ReadFull(h.nc, hdr[:]); err != nil {
349
+ return
350
+ }
351
+ data := make([]byte, binary.BigEndian.Uint32(hdr[:]))
352
+ if _, err := io.ReadFull(h.nc, data); err != nil {
353
+ return
354
+ }
355
+ var env wireEnvelope
356
+ if json.Unmarshal(data, &env) != nil {
357
+ continue
358
+ }
359
+ switch env.Type {
360
+ case "call":
361
+ go h.answer(env)
362
+ case "response":
363
+ h.mu.Lock()
364
+ ch := h.pending[env.ID]
365
+ delete(h.pending, env.ID)
366
+ h.mu.Unlock()
367
+ if ch != nil {
368
+ ch <- env.Response
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ func (h *fakeHost) answer(env wireEnvelope) {
375
+ h.mu.Lock()
376
+ st := h.state
377
+ h.mu.Unlock()
378
+ result := map[string]any{}
379
+ switch env.Call.Method {
380
+ case "getSessionFile", "getSessionID":
381
+ if st.sessionFailure {
382
+ h.write(map[string]any{"type": "call_result", "id": env.ID, "call_result": map[string]any{"error": map[string]any{"message": "no session"}}})
383
+ return
384
+ }
385
+ if env.Call.Method == "getSessionFile" {
386
+ result["sessionFile"] = st.sessionFile
387
+ } else {
388
+ result["sessionId"] = st.sessionID
389
+ }
390
+ case "isIdle":
391
+ result["idle"] = st.idle
392
+ }
393
+ h.write(map[string]any{"type": "call_result", "id": env.ID, "call_result": map[string]any{"result": result}})
394
+ }
395
+
396
+ func (h *fakeHost) setState(update func(*hostState)) {
397
+ h.mu.Lock()
398
+ defer h.mu.Unlock()
399
+ update(&h.state)
400
+ }
401
+
402
+ // fire delivers one event and waits for the extension's response, as PiG's host does.
403
+ // An event the extension did not register for is not delivered.
404
+ func (h *fakeHost) fire(event string, data map[string]any) {
405
+ h.t.Helper()
406
+ h.mu.Lock()
407
+ id, ok := h.handlers[event]
408
+ h.nextID++
409
+ reqID := fmt.Sprintf("r%d", h.nextID)
410
+ ch := make(chan *wireResponse, 1)
411
+ if ok {
412
+ h.pending[reqID] = ch
413
+ }
414
+ h.mu.Unlock()
415
+ if !ok {
416
+ return
417
+ }
418
+ if data == nil {
419
+ data = map[string]any{}
420
+ }
421
+ data["type"] = event
422
+ args, _ := json.Marshal(data)
423
+ h.write(map[string]any{"type": "request", "id": reqID, "request": map[string]any{"method": "event", "event": event, "handler_id": id, "args": json.RawMessage(args)}})
424
+ select {
425
+ case resp := <-ch:
426
+ if resp != nil && resp.Error != nil {
427
+ h.t.Errorf("%s handler failed: %s", event, resp.Error.Message)
428
+ }
429
+ case <-time.After(10 * time.Second):
430
+ h.t.Fatalf("no response to %s within 10s", event)
431
+ }
432
+ }
433
+
434
+ func (h *fakeHost) registered(event string) bool {
435
+ h.mu.Lock()
436
+ defer h.mu.Unlock()
437
+ _, ok := h.handlers[event]
438
+ return ok
439
+ }
440
+
441
+ func (h *fakeHost) stop() {
442
+ h.write(map[string]any{"type": "shutdown", "shutdown": map[string]any{"reason": "test"}})
443
+ select {
444
+ case <-h.runDone:
445
+ case <-time.After(10 * time.Second):
446
+ h.t.Errorf("extension did not stop after shutdown")
447
+ }
448
+ _ = h.nc.Close()
449
+ }
@@ -0,0 +1,13 @@
1
+ //go:build !windows
2
+
3
+ package herdr
4
+
5
+ import (
6
+ "context"
7
+ "os/exec"
8
+ )
9
+
10
+ // newCommand builds the herdr command.
11
+ func newCommand(ctx context.Context, bin string, args []string) *exec.Cmd {
12
+ return exec.CommandContext(ctx, bin, args...)
13
+ }
@@ -0,0 +1,16 @@
1
+ //go:build windows
2
+
3
+ package herdr
4
+
5
+ import (
6
+ "context"
7
+ "os/exec"
8
+ "syscall"
9
+ )
10
+
11
+ // newCommand builds the herdr command without a console window.
12
+ func newCommand(ctx context.Context, bin string, args []string) *exec.Cmd {
13
+ cmd := exec.CommandContext(ctx, bin, args...)
14
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
15
+ return cmd
16
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@pi-in-go/pigpen-herdr",
3
+ "version": "0.1.0",
4
+ "description": "PiG agent-state reporter (Go extension) for the herdr terminal workspace manager",
5
+ "keywords": [
6
+ "pig-package",
7
+ "pigpen",
8
+ "extension"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "Michael Kinsy",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/MichaelKinsy/pigpen.git",
15
+ "directory": "components/herdr"
16
+ },
17
+ "homepage": "https://github.com/MichaelKinsy/pigpen/tree/main/components/herdr#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/MichaelKinsy/pigpen/issues"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "extensions",
26
+ "README.md",
27
+ "LICENSE",
28
+ "provenance.json"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "extensions/herdr"
33
+ ]
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "origin": "original",
3
+ "authors": ["Michael Kinsy"],
4
+ "license": "MIT",
5
+ "licenseFile": "LICENSE",
6
+ "upstreams": []
7
+ }