Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Install new capabilities into a project with `iii worker add`:
| Node.js | [`iii-sdk`](https://www.npmjs.com/package/iii-sdk) | `pnpm add iii-sdk` or `npm install iii-sdk` |
| Python | [`iii-sdk`](https://pypi.org/project/iii-sdk/) | `pip install iii-sdk` |
| Rust | [`iii-sdk`](https://crates.io/crates/iii-sdk) | Add to `Cargo.toml` |
| Go | [`iii`](sdk/packages/go/iii) | `go get github.com/iii-hq/iii/sdk/packages/go/iii` |
Comment thread
MarcusElwin marked this conversation as resolved.
Outdated

## Agent Skills

Expand All @@ -124,7 +125,7 @@ triggers, queues, traces, logs, and real-time state. See the
| Directory | What it is | README |
| ---------- | ------------------------------------------------------- | -------------------------------------- |
| `engine/` | iii Engine (Rust) - core runtime, modules, and protocol | [engine/README.md](engine/README.md) |
| `sdk/` | SDKs for Node.js, Python, and Rust | [sdk/README.md](sdk/README.md) |
| `sdk/` | SDKs for Node.js, Python, Rust, and Go | [sdk/README.md](sdk/README.md) |
| `console/` | Developer console (React + Rust) | [console/README.md](console/README.md) |
| `skills/` | Agent-readable reference material | [skills/README.md](skills/README.md) |
| `website/` | iii website | [website/](website/) |
Expand Down
58 changes: 49 additions & 9 deletions sdk/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# iii SDKs

These are iii official SDKs for Node, Python, and Rust. See the [engine README](../engine/README.md) for architecture details and the [documentation](https://iii.dev/docs) for full guides.
These are iii official SDKs for Node, Python, Rust, and Go. See the [engine README](../engine/README.md) for architecture details and the [documentation](https://iii.dev/docs) for full guides.

## SDKs

Expand All @@ -14,6 +14,7 @@ These are iii official SDKs for Node, Python, and Rust. See the [engine README](
| [`iii-sdk`](https://www.npmjs.com/package/iii-sdk) | Node.js / TypeScript | `pnpm add iii-sdk` or `npm install iii-sdk` | [README](./packages/node/iii/README.md) |
| [`iii-sdk`](https://pypi.org/project/iii-sdk/) | Python | `pip install iii-sdk` | [README](./packages/python/iii/README.md) |
| [`iii-sdk`](https://crates.io/crates/iii-sdk) | Rust | Add to `Cargo.toml` | [README](./packages/rust/iii/README.md) |
| [`iii`](./packages/go/iii) | Go | `go get github.com/iii-hq/iii/sdk/packages/go/iii` | [README](./packages/go/iii/README.md) |
Comment thread
MarcusElwin marked this conversation as resolved.
Outdated

## Hello World

Expand Down Expand Up @@ -86,17 +87,56 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
```

### Go

```go
package main

import (
"context"
"encoding/json"
"log"

iii "github.com/iii-hq/iii/sdk/packages/go/iii"
)

func main() {
client := iii.RegisterWorker("ws://127.0.0.1:49134")

client.RegisterFunction("hello::greet", func(ctx context.Context, data json.RawMessage) (any, error) {
var req struct {
Body struct {
Name string `json:"name"`
} `json:"body"`
}
_ = json.Unmarshal(data, &req)
return map[string]any{
"status_code": 200,
"body": map[string]string{"message": "Hello, " + req.Body.Name + "!"},
}, nil
})

client.RegisterTrigger("hello-http", "http", "hello::greet",
json.RawMessage(`{"api_path":"/greet","http_method":"POST"}`), nil)

if err := client.Connect(context.Background()); err != nil {
log.Fatal(err)
}
defer client.Close()
}
```
Comment on lines +90 to +127

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Move README inline example to docs and link it instead.

This section embeds a full Go example in sdk/README.md. The repository guideline for **/README.md says examples should live in docs/ and READMEs should link to them to avoid drift.

As per coding guidelines, “READMEs should not contain example code that is already in the docs/. READMEs should link these examples in the docs/.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/README.md` around lines 90 - 127, Remove the inline Go example block in
sdk/README.md (the code that uses RegisterWorker, RegisterFunction,
RegisterTrigger and Connect) and move that exact example into a dedicated docs
example file; then replace the README code block with a short link/reference to
the new docs example so the README points to the canonical example in docs
rather than embedding the full code.


## API

| Operation | Node.js | Python | Rust | Description |
| ------------------------ | ---------------------------------------------------- | ------------------------------------------- | -------------------------------------------- | ------------------------------------------------------ |
| Initialize | `registerWorker(url)` | `register_worker(url, options?)` | `register_worker(url, options)` | Create an SDK instance and auto-connect |
| Register function | `iii.registerFunction(id, handler, options?)` | `iii.register_function(id, handler)` | `iii.register_function(id, \|input\| ...)` | Register a function that can be invoked by name |
| Register trigger | `iii.registerTrigger({ type, function_id, config })` | `iii.register_trigger({"type": ..., "function_id": ..., "config": ...})` | `iii.register_trigger(type, fn_id, config)?` | Bind a trigger (HTTP, cron, queue, etc.) to a function |
| Invoke (await) | `await iii.trigger({ function_id, payload })` | `await iii.trigger({"function_id": id, "payload": data})` | `iii.trigger(TriggerRequest::new(id, data)).await?` | Invoke a function and wait for the result |
| Invoke (fire-and-forget) | `iii.trigger({ function_id, payload, action: TriggerAction.Void() })` | Same | Same | Invoke without waiting |
| Operation | Node.js | Python | Rust | Go | Description |
| ------------------------ | ---------------------------------------------------- | ------------------------------------------- | -------------------------------------------- | ------------------------------------------- | ------------------------------------------------------ |
| Initialize | `registerWorker(url)` | `register_worker(url, options?)` | `register_worker(url, options)` | `iii.RegisterWorker(url)` | Create an SDK instance and auto-connect |
Comment on lines +131 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix Go initialization semantics in the API table/text.

Line 133 and Line 139 state initialization auto-connects across all SDKs, but this file’s Go example (Line 122) requires explicit Connect(...). Please adjust the table/summary to avoid misleading Go users.

Proposed doc fix
-| Initialize               | `registerWorker(url)`                                | `register_worker(url, options?)`            | `register_worker(url, options)`              | `iii.RegisterWorker(url)`                   | Create an SDK instance and auto-connect                |
+| Initialize               | `registerWorker(url)`                                | `register_worker(url, options?)`            | `register_worker(url, options)`              | `iii.RegisterWorker(url)`                   | Create an SDK instance (Go connects via `Connect(ctx)`) |
-`registerWorker()` / `register_worker()` creates an SDK instance and auto-connects to the engine. It handles WebSocket communication, automatic reconnection, and OpenTelemetry instrumentation. All four SDKs expose the same API surface — register functions and triggers, then invoke them.
+`registerWorker()` / `register_worker()` creates an SDK instance. Node/Python auto-connect; Go connects explicitly via `Connect(ctx)`. SDKs expose the same core API surface — register functions and triggers, then invoke them.

Also applies to: 139-139

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/README.md` around lines 131 - 133, The table/text incorrectly claims all
SDKs auto-connect on initialization; update the README to clarify Go requires an
explicit Connect call by changing the "Initialize" cell for Go from
"iii.RegisterWorker(url)" to indicate registration only (e.g.,
"iii.RegisterWorker(url) — register only; call Connect(...) to connect") and
adjust the summary lines (around the paragraphs referencing auto-connect) to
note that Go does not auto-connect and requires an explicit iii.Connect or
Connect call after iii.RegisterWorker; ensure references to iii.RegisterWorker
and Connect are used so readers can find the Go example easily.

| Register function | `iii.registerFunction(id, handler, options?)` | `iii.register_function(id, handler)` | `iii.register_function(id, \|input\| ...)` | `client.RegisterFunction(id, handler)` | Register a function that can be invoked by name |
| Register trigger | `iii.registerTrigger({ type, function_id, config })` | `iii.register_trigger({"type": ..., "function_id": ..., "config": ...})` | `iii.register_trigger(type, fn_id, config)?` | `client.RegisterTrigger(id, type, fn, cfg, meta)` | Bind a trigger (HTTP, cron, queue, etc.) to a function |
| Invoke (await) | `await iii.trigger({ function_id, payload })` | `await iii.trigger({"function_id": id, "payload": data})` | `iii.trigger(TriggerRequest::new(id, data)).await?` | `client.Trigger(ctx, iii.TriggerRequest{...})` | Invoke a function and wait for the result |
| Invoke (fire-and-forget) | `iii.trigger({ function_id, payload, action: TriggerAction.Void() })` | Same | Same | `client.Trigger(ctx, iii.TriggerRequest{Action: iii.VoidAction()})` | Invoke without waiting |

`registerWorker()` / `register_worker()` creates an SDK instance and auto-connects to the engine. It handles WebSocket communication, automatic reconnection, and OpenTelemetry instrumentation. All three SDKs expose the same API surface — register functions and triggers, then invoke them.
`registerWorker()` / `register_worker()` creates an SDK instance and auto-connects to the engine. It handles WebSocket communication, automatic reconnection, and OpenTelemetry instrumentation. All four SDKs expose the same API surface — register functions and triggers, then invoke them.

> `call`, `callVoid`, `triggerVoid` (and Python/Rust equivalents) have been removed. Use `trigger()` for all invocations. For fire-and-forget, use `trigger({ function_id, payload, action: TriggerAction.Void() })`.

Expand Down
25 changes: 25 additions & 0 deletions sdk/packages/go/iii/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,8 @@ func (c *Client) dispatch(ctx context.Context, dec *DecodedMessage) {
c.handleInvocationResult(dec.InvocationResult)
case MsgRegisterTrigger:
go c.handleRegisterTrigger(ctx, dec.RegisterTrigger)
case MsgUnregisterTrigger:
go c.handleUnregisterTrigger(ctx, dec.UnregisterTrigger)
case MsgPing:
if frame, err := MarshalMessage(&PongMessage{}); err == nil {
c.enqueueOutboundDirect(frame)
Expand Down Expand Up @@ -778,6 +780,29 @@ func (c *Client) handleRegisterTrigger(ctx context.Context, msg *RegisterTrigger
}
}

// handleUnregisterTrigger routes an inbound UnregisterTrigger to the matching
// trigger-type handler's UnregisterTrigger hook, so a custom trigger type can tear down
// the per-instance work it started in RegisterTrigger. The engine sends this when a
// trigger instance is removed; without dispatching it, that work would leak. Mirrors
// handle_unregister_trigger in the Rust SDK and onUnregisterTrigger in the Node SDK.
//
// The wire message carries only id and an optional trigger_type. Without a trigger_type
// we can't resolve which handler owns the instance, so we skip (matching the reference
// SDKs, which require it). The TriggerConfig passed to the handler carries the id; the
// handler keys its cleanup off that.
func (c *Client) handleUnregisterTrigger(ctx context.Context, msg *UnregisterTriggerMessage) {
if msg.TriggerType == nil {
return
}
c.mu.Lock()
tt, ok := c.triggerTypes[*msg.TriggerType]
c.mu.Unlock()
if !ok {
return
}
_ = tt.handler.UnregisterTrigger(ctx, TriggerConfig{ID: msg.ID})
}

// Close shuts the client down: it stops the reconnect loop, cancels all pending Trigger
// calls with ErrNotConnected, and closes the socket. It does not send unregister frames
// (matching the reference SDKs). Close is idempotent.
Expand Down
40 changes: 38 additions & 2 deletions sdk/packages/go/iii/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,8 +492,9 @@ func TestCloseCancelsPending(t *testing.T) {

// stubTriggerHandler records register/unregister calls for assertions.
type stubTriggerHandler struct {
registered chan TriggerConfig
failWith error
registered chan TriggerConfig
unregistered chan TriggerConfig
failWith error
}

func (s *stubTriggerHandler) RegisterTrigger(ctx context.Context, cfg TriggerConfig) error {
Expand All @@ -503,6 +504,9 @@ func (s *stubTriggerHandler) RegisterTrigger(ctx context.Context, cfg TriggerCon
return s.failWith
}
func (s *stubTriggerHandler) UnregisterTrigger(ctx context.Context, cfg TriggerConfig) error {
if s.unregistered != nil {
s.unregistered <- cfg
}
return nil
}

Expand Down Expand Up @@ -554,6 +558,38 @@ func TestInboundRegisterTrigger(t *testing.T) {
}
}

// TestInboundUnregisterTrigger routes an engine UnregisterTrigger to the trigger-type
// handler's UnregisterTrigger hook, so per-instance work can be torn down (the engine
// sends this when an instance is removed). Mirrors the Rust/Node SDKs. Regression test
// for the teardown leak (iii-hq/iii#1765).
func TestInboundUnregisterTrigger(t *testing.T) {
m := newMockEngine(t)
handler := &stubTriggerHandler{unregistered: make(chan TriggerConfig, 1)}

tt := "cron"
m.onReceive = func(conn *websocket.Conn, msg map[string]json.RawMessage) {
if messageType(msg) == string(MsgRegisterTriggerType) && messageID(msg) == "cron" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = m.send(ctx, conn, &UnregisterTriggerMessage{ID: "inst-1", TriggerType: &tt})
}
}

c := connectClient(t, m)
if err := c.RegisterTriggerType("cron", "periodic", handler); err != nil {
t.Fatalf("RegisterTriggerType: %v", err)
}

select {
case cfg := <-handler.unregistered:
if cfg.ID != "inst-1" {
t.Errorf("UnregisterTrigger got id %q, want inst-1", cfg.ID)
}
case <-time.After(3 * time.Second):
t.Fatal("UnregisterTrigger hook was not called (teardown leak)")
}
}

// TestInboundRegisterTriggerUnknownType replies trigger_type_not_found when no handler
// is registered for the type.
func TestInboundRegisterTriggerUnknownType(t *testing.T) {
Expand Down
12 changes: 11 additions & 1 deletion sdk/packages/go/iii/tests/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,18 @@ func settle() { time.Sleep(300 * time.Millisecond) }
// connect builds a client against the live engine and connects it, registering cleanup.
// Each test gets its own client so registrations don't leak between tests.
func connect(t *testing.T) *iii.Client {
return connectNamed(t, "")
}

// connectNamed is connect with an explicit worker name (iii.WithName). A unique name lets
// a test correlate engine::workers::list entries to the worker it actually opened.
func connectNamed(t *testing.T, name string) *iii.Client {
t.Helper()
c := iii.New(engineWSURL())
var opts []iii.Option
if name != "" {
opts = append(opts, iii.WithName(name))
}
c := iii.New(engineWSURL(), opts...)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.Connect(ctx); err != nil {
Expand Down
23 changes: 16 additions & 7 deletions sdk/packages/go/iii/tests/worker_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ type workerInfo struct {
}

// TestWorkerRegistersWithGoRuntime confirms the worker-metadata registration reaches the
// engine: after connecting, engine::workers::list contains a connected worker tagged
// runtime "go" (the engine's own builtins are runtime "engine"). The metadata register
// is fire-and-forget, so we give it a moment to land.
// engine: after connecting, engine::workers::list contains THIS worker tagged runtime
// "go". The worker is created with a unique name (WithName) so the assertion correlates
// to the worker this test opened, rather than matching any connected go worker that may
// share the engine (which would let the test pass on the wrong worker — iii-hq/iii#1766).
// The metadata register is fire-and-forget, so we give it a moment to land.
func TestWorkerRegistersWithGoRuntime(t *testing.T) {
c := connect(t)
workerName := "test-worker-meta-" + uniqueSuffix(t)
c := connectNamed(t, workerName)
if err := c.RegisterFunction("test::worker_meta::go::probe", func(_ context.Context, _ json.RawMessage) (any, error) {
return nil, nil
}); err != nil {
Expand All @@ -53,18 +56,24 @@ func TestWorkerRegistersWithGoRuntime(t *testing.T) {
t.Fatalf("decode workers: %v\nraw: %s", err, res)
}

// Find OUR worker by its unique name, not just any connected go worker.
var ours *workerInfo
for i := range out.Workers {
w := &out.Workers[i]
if w.Runtime != nil && *w.Runtime == "go" && w.Status == "connected" {
if w.Name != nil && *w.Name == workerName {
ours = w
break
}
}
if ours == nil {
t.Fatalf("no connected worker with runtime \"go\" in engine::workers::list (worker metadata not registered?)")
t.Fatalf("this worker (%q) not found in engine::workers::list (metadata not registered?)", workerName)
}
if ours.Runtime == nil || *ours.Runtime != "go" {
t.Errorf("runtime = %v, want \"go\"", ours.Runtime)
}
if ours.Status != "connected" {
t.Errorf("status = %q, want connected", ours.Status)
}
// Sanity-check the rest of the metadata we send.
if ours.OS == nil || *ours.OS == "" {
t.Error("worker os metadata is empty")
}
Expand Down
Loading