Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .agents/skills/loom-framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ consumer validation.

## Architecture Boundaries

- `execution/` owns typed activity names, options, registration/execution
contracts, and futures. `execution/adapter` owns concrete-handler reflection;
it does not own serialization. `execution/selvedge` uses native generic
definitions and captures each future's workflow scope. Selvedge owns codecs,
retry execution, storage, and app lifecycle. Agent policies and type-erased
agent JSON bridges remain in their consumer. This runtime surface has no DSL
or generated workflow API. Keep the binding model and scope tests aligned.

- `internal/patternsyntax` owns the portable Go/ECMAScript bridge. Export derives
syntax from the admitted Go tree; import admits a bounded ECMAScript grammar
before Go parsing and translates direction-specific escapes. Never use Go
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/loom-framework/references/formal-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ Rendered schema validation and actual decoder behavior are separate obligations.

| Code concern | Model |
| --- | --- |
| Activity registration publication and immutable future scopes | [Activity binding](../../../../execution/selvedge/tla/README.md); native-conflict and parent/canceled/detached scope tests, with a rejected future-rebinding alternative |
| Artifact write intent, atomic create-only installation, rollback ownership and gen-tree admission | [Artifact write intent](../../../../cmd/loom/tla/artifact_write_intent/README.md); bounded intent-loss/race controls paired with real filesystem and registered-plugin CLI tests |
| Service-error merge and history snapshot ownership | [Error ownership](../../../../pkg/tla/error_ownership/README.md); bounded aliasing counterexamples, with repeated merges and protobuf reconstruction checked in Go |
| Public message ownership, history masking, diagnostic exclusion, transparent wrappers and independent joins | [Public disclosure](../../../../pkg/tla/error_disclosure/README.md); finite owner/message model, with HTTP/gRPC/JSON-RPC unary/SSE/WebSocket wire tests |
Expand Down
2 changes: 2 additions & 0 deletions .agents/skills/loom-framework/references/repo-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ belong in the `loom` skill and the canonical guides under `docs/`.
WebSocket lifecycle, including the client connection demultiplexer
(`websocket_client.go`, modeled in `jsonrpc/tla/`); typed transport
generation; and integrations
- `execution/`: typed activity contracts, adapter reflection, and native Selvedge
activity binding; no workflow DSL or persistence implementation
- `observability/`: framework-owned tracing, metrics, logging, and transport
event contracts
- `security/authorization.go`: mandatory endpoint checks and cancellation;
Expand Down
14 changes: 14 additions & 0 deletions .agents/skills/loom/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ fields in the body, map them explicitly, or declare `ErrorNameFromStatus()` on
an HTTP error response with a unique status. Views retain their projected contract.
Generation rejects incomplete mappings; add the missing source and regenerate.

## Typed background activities

Use `execution.Activity[Input, Output]` for named, typed activity calls without
adding DSL declarations. With Selvedge, create `execution/selvedge.New(app)`,
register each handler using its typed `Register` function and native options
before `app.Start`, then execute through `activities.Scope(workflowContext)`.
Native Selvedge owns codecs, workflow definitions, app lifecycle, submission,
status, cancellation, and transactions. Futures retain their scheduling scope.
The typed native registry does not implement `ActivityRegistrar`; that interface
serves backends able to register a concrete function dynamically. See
[typed activities](../../../docs/execution.md) for the complete boundary.
Selvedge v0.7.0 needs Go 1.27.3 and a fresh database; finish old runs and retain
their database before changing worker storage.

## Core Workflow

1. Treat `design/*.go` as the source of truth.
Expand Down
6 changes: 3 additions & 3 deletions cmd/loom/gen_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ func TestGeneratorUsesCallerWorkspace(t *testing.T) {
root := t.TempDir()
moduleDir := filepath.Join(root, "app")
require.NoError(t, os.MkdirAll(filepath.Join(moduleDir, "design"), 0o755))
mod := []byte("module example.com/service\n\ngo 1.27.2\n\nrequire github.com/CaliLuke/loom v0.0.0\n")
mod := []byte("module example.com/service\n\ngo 1.27.3\n\nrequire github.com/CaliLuke/loom v0.0.0\n")
if workspace == "use" {
mod = []byte("module example.com/service\n\ngo 1.27.2\n")
mod = []byte("module example.com/service\n\ngo 1.27.3\n")
}
require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), mod, 0o600))
sum, err := os.ReadFile(filepath.Join(repositoryRoot, "go.sum"))
Expand All @@ -31,7 +31,7 @@ func TestGeneratorUsesCallerWorkspace(t *testing.T) {
import . "github.com/CaliLuke/loom/dsl"
var _ = API("service", func() {})
`), 0o600))
work := "go 1.27.2\n\nuse ./app\n"
work := "go 1.27.3\n\nuse ./app\n"
if workspace == "replace" {
work += fmt.Sprintf("replace github.com/CaliLuke/loom => %q\n", repositoryRoot)
} else {
Expand Down
8 changes: 4 additions & 4 deletions cmd/loom/generation_filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestGenerationStageUsesWritableOutput(t *testing.T) {
parent := t.TempDir()
module := filepath.Join(parent, "module")
require.NoError(t, os.Mkdir(module, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/app\n\ngo 1.27.2\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/app\n\ngo 1.27.3\n"), 0o600))
require.NoError(t, os.Chmod(parent, 0o500))
t.Cleanup(func() {
require.NoError(t, os.Chmod(parent, 0o700))
Expand All @@ -38,7 +38,7 @@ func TestGenerationStageModuleContext(t *testing.T) {
output := filepath.Join(module, nested)
require.NoError(t, os.MkdirAll(output, 0o750))
absoluteTarget := filepath.Join(root, "absolute")
original := []byte(fmt.Sprintf("module example.com/app\n\ngo 1.27.2\n\nreplace (\nexample.com/dep => ../dep\nexample.com/other v1.0.0 => ./other\nexample.com/absolute => %q\nexample.com/version v1.0.0 => example.com/release v1.2.3\n)\n", absoluteTarget))
original := []byte(fmt.Sprintf("module example.com/app\n\ngo 1.27.3\n\nreplace (\nexample.com/dep => ../dep\nexample.com/other v1.0.0 => ./other\nexample.com/absolute => %q\nexample.com/version v1.0.0 => example.com/release v1.2.3\n)\n", absoluteTarget))
require.NoError(t, os.WriteFile(filepath.Join(module, "go.mod"), original, 0o600))
tx, err := newGenerationTransaction(output)
require.NoError(t, err)
Expand Down Expand Up @@ -91,14 +91,14 @@ func TestGenerationMountedDestinations(t *testing.T) {
case "module":
output = mount
case "nested-output":
require.NoError(t, os.WriteFile(filepath.Join(base, "go.mod"), []byte("module example.com/app\n\ngo 1.27.2\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(base, "go.mod"), []byte("module example.com/app\n\ngo 1.27.3\n"), 0o600))
output = filepath.Join(base, "nested")
require.NoError(t, os.Symlink(mount, output))
default:
require.NoError(t, os.Symlink(mount, filepath.Join(output, "plugin")))
}
if kind == "module" {
require.NoError(t, os.WriteFile(filepath.Join(output, "go.mod"), []byte("module example.com/app\n\ngo 1.27.2\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(output, "go.mod"), []byte("module example.com/app\n\ngo 1.27.3\n"), 0o600))
}
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(filepath.Join(mount, "go.mod")))
Expand Down
2 changes: 1 addition & 1 deletion cmd/loom/provenance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
// through the package loader before embedding the origin in generation or vet.
func TestGeneratorCanonicalDesignOrigin(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/origin\n\ngo 1.27.2\n"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/origin\n\ngo 1.27.3\n"), 0600))
require.NoError(t, os.Mkdir(filepath.Join(dir, "design"), 0750))
require.NoError(t, os.WriteFile(filepath.Join(dir, "design", "design.go"), []byte("package design\n"), 0600))
t.Chdir(dir)
Expand Down
2 changes: 1 addition & 1 deletion codegen/generator/cli_argument_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestGeneratedCLIArgumentOrder(t *testing.T) {
require.NoError(t, os.MkdirAll(dir, 0o700))
}
codegen.RunDSL(t, testdata.CLIArgumentOrderDSL(tc.transport, tc.shape))
mod := fmt.Sprintf("module example.com/order\n\ngo 1.27.2\n\nrequire github.com/CaliLuke/loom v1.0.0\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
mod := fmt.Sprintf("module example.com/order\n\ngo 1.27.3\n\nrequire github.com/CaliLuke/loom v1.0.0\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o600))
_, err := Generate(dir, "gen", "github.com/CaliLuke/loom/codegen/generator", false)
require.NoError(t, err)
Expand Down
2 changes: 1 addition & 1 deletion codegen/generator/declaration_merge_artifacts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ type Moment time.Time
dir := t.TempDir()
_, err := mergeFilesByPath(files)[0].Render(dir)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/merge\n\ngo 1.27.2\n"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/merge\n\ngo 1.27.3\n"), 0600))
const test = `package shared
import ("reflect"; "testing")
func TestValues(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion codegen/generator/declaration_merge_specimens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestSharedDeclarationSpecimens(t *testing.T) {
dir = filepath.Join(root, shape)
require.NoError(t, os.MkdirAll(dir, 0750))
}
module := fmt.Sprintf("module example.com/merge\n\ngo 1.27.2\n\nrequire github.com/CaliLuke/loom v0.0.0\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
module := fmt.Sprintf("module example.com/merge\n\ngo 1.27.3\n\nrequire github.com/CaliLuke/loom v0.0.0\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0600))
_, err := Generate(dir, "gen", "github.com/CaliLuke/loom/codegen/generator", false)
require.NoError(t, err)
Expand Down
4 changes: 2 additions & 2 deletions codegen/generator/example_cli_transport_dispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
func TestExampleCLISelectsTransportByCommand(t *testing.T) {
codegen.RunDSL(t, cliTransportDispatchDesign)
dir := t.TempDir()
goMod := fmt.Sprintf("module example.com/dispatch\n\ngo 1.27.2\n\nrequire github.com/CaliLuke/loom v0.0.0\n\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
goMod := fmt.Sprintf("module example.com/dispatch\n\ngo 1.27.3\n\nrequire github.com/CaliLuke/loom v0.0.0\n\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o600))

_, err := Generate(dir, "gen", "github.com/CaliLuke/loom/codegen/generator", false)
Expand Down Expand Up @@ -89,7 +89,7 @@ func TestExampleCLISelectsTransportByCommand(t *testing.T) {
func TestHTTPOnlyExampleCLICompiles(t *testing.T) {
codegen.RunDSL(t, httpOnlyExampleCLIDesign)
dir := t.TempDir()
goMod := fmt.Sprintf("module example.com/http-only\n\ngo 1.27.2\n\nrequire github.com/CaliLuke/loom v0.0.0\n\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
goMod := fmt.Sprintf("module example.com/http-only\n\ngo 1.27.3\n\nrequire github.com/CaliLuke/loom v0.0.0\n\nreplace github.com/CaliLuke/loom => %s\n", loomModuleSource(t))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o600))

_, err := Generate(dir, "gen", "github.com/CaliLuke/loom/codegen/generator", false)
Expand Down
2 changes: 1 addition & 1 deletion codegen/generator/provenance_readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func TestGenerationProvenanceIncludesPluginState(t *testing.T) {
})
require.NoError(t, eval.Register(&provenancePluginRoot{Value: value}))
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/provenance\n\ngo 1.27.2\n"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/provenance\n\ngo 1.27.3\n"), 0600))
_, err := Generate(dir, "gen", "github.com/CaliLuke/loom/codegen/generator", false)
require.NoError(t, err)
data, err := os.ReadFile(filepath.Join(dir, codegen.Gendir, "loom.json"))
Expand Down
1 change: 1 addition & 0 deletions docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func (s *helloService) SayHello(ctx context.Context, name string) (string, error

| Guide | Description |
|-------|-------------|
| [Typed activities](execution.md) | Typed execution contracts and native Selvedge activity binding |
| [Quickstart](quickstart.md) | Install Loom and build your first service |
| [DSL Reference](dsl-reference.md) | Complete reference for Loom's design language |
| [Code Generation](code-generation.md) | Understanding Loom's code generation process |
Expand Down
72 changes: 72 additions & 0 deletions docs/execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Typed activities

`github.com/CaliLuke/loom/execution` provides a small typed activity contract for
workflow adapters. Use it when applications share typed activity definitions
and calls across execution backends. It is an ordinary Go runtime
package; it requires no DSL declarations or generated code.

An activity couples a stable name with its input and output types:

```go
readMetadata := execution.Activity[MetadataInput, MetadataOutput]{
Name: "assets.read_metadata",
}
```

Register its handler through `readMetadata.Register(ctx, registrar, options,
handler)`. Inside a workflow, `readMetadata.Execute(ctx, executor, input, options)`
returns `MetadataOutput`; `ExecuteAsync` returns `Future[MetadataOutput]`.
Check every returned error. Inputs must be non-nil, and input and output types
must be supported by the selected backend's codec. Names must remain stable
across worker restarts and replay, and names are unique within a registry.

`ActivityOptions` contains queue, retry, attempt, queue-wait, and heartbeat
settings. Zero fields leave values to registration or backend defaults; they do
not explicitly clear inherited settings. This contract does not make different
backends' retry or cancellation behavior identical.

## Selvedge

`github.com/CaliLuke/loom/execution/selvedge` adapts these activities to Selvedge
v0.7.0. Create an activity registry with `selvedge.New(app)` and register all
handlers before `app.Start`. The app remains caller-owned: the adapter never
starts or stops it. Register through the typed `selvedge.Register` function so
Selvedge retains the concrete input and output types and owns their codecs.
This registry does not implement the type-erased `ActivityRegistrar` interface.

Inside a native Selvedge workflow, pass `activities.Scope(workflowContext)` as
the executor. Scope controls cancellation and detached execution. The standard
`context.Context` parameter does not replace that native workflow scope, and a
future always waits using its original scope. Activity JSON inputs and outputs
remain plain values without an extra envelope.

The adapter's typed `selvedge.Register(activities, activity, handler,
nativeOptions)` function accepts the full native `selvedge.ActivityOptions`,
including maximum retry intervals and non-retryable error predicates. That path
preserves native defaults and error types. Agent-specific error conversion and
timeout categories belong in the consuming adapter.

Keep workflows, start options, status inspection, cancellation requests, and
transaction-bound submission on the native Selvedge API. In particular, passing
the existing transaction context to native `StartWorkflow` keeps admission in
that transaction. The activity adapter neither starts another transaction nor
turns submission into a separate background call.

Selvedge v0.7.0 requires Go 1.27.3 and a fresh database. Finish runs on the old
binary, retain its database for inspection, and configure a new database before
starting the upgraded workers. There is no migration from earlier development
schemas.

## Adapter authors

Implement `ActivityExecutor` and either a typed native registration function or
`ActivityRegistrar`, according to the backend API. Generic registration receives a
concrete handler and execution receives the concrete input plus a result pointer.
Preserve those types when the native serializer needs them. The
`execution/adapter` package provides the shared handler validation and binding
used by adapters; it is not an application scheduling API.

This package intentionally has no general workflow definition, service-method
submission DSL, scheduler, persistence engine, or agent lifecycle. Applications
can use the common activity contract while retaining the native capabilities
their workflows need.
2 changes: 1 addition & 1 deletion docs/grpc-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Loom's gRPC support includes:

### Prerequisites and Supported Toolchain

Use Go 1.27.2 and keep these protobuf tools on `PATH` when generating a
Use Go 1.27.3 and keep these protobuf tools on `PATH` when generating a
gRPC service:

| Tool | Supported version |
Expand Down
94 changes: 94 additions & 0 deletions execution/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Package execution defines typed activity contracts for workflow adapters.
// It does not own persistence, workflow scheduling, or application policy.
package execution

import "context"

type (
// ActivityRegistrar installs activities before workers begin polling.
ActivityRegistrar interface {
// RegisterActivity validates and installs a concrete handler.
RegisterActivity(context.Context, ActivityDefinition) error
}

// ActivityExecutor schedules named activities in a workflow scope.
// Results must be decoded into the supplied pointer using the backend's codec.
ActivityExecutor interface {
// ExecuteActivity schedules and waits for a result, decoded into a pointer.
ExecuteActivity(context.Context, ActivityCall, any) error
// ExecuteActivityAsync schedules a command in this executor's workflow scope.
ExecuteActivityAsync(context.Context, ActivityCall) (ActivityFuture, error)
}

// ActivityDefinition describes an activity independently of agent payload types.
ActivityDefinition struct {
// Name identifies the activity within its backend registry.
Name string
// Options supplies portable registration defaults.
Options ActivityOptions
// Handler must be func(context.Context, I) (O, error) or func(context.Context, I) error.
// Use Activity[I, O].Register for compile-time checking of input and output.
Handler any
}

// ActivityCall carries a typed input unchanged to the backend's serializer.
ActivityCall struct {
// Name identifies a registered activity.
Name string
// Input is passed to the backend codec without a wrapper envelope.
Input any
// Options overrides registration defaults according to backend rules.
Options ActivityOptions
}

// ActivityFuture is the backend result boundary. Application code should use
// Activity[I, O], which exposes Future[O] instead of result pointers.
ActivityFuture interface {
// Get waits using the captured workflow scope and decodes into a pointer.
Get(context.Context, any) error
// IsReady reports completion without blocking.
IsReady() bool
}

// Activity binds a stable activity name to its input and output types.
// Reusing the same value for registration and invocation keeps both ends typed.
Activity[I, O any] struct {
// Name must be stable across worker restarts and workflow replay.
Name string
}

typedActivityFuture[O any] struct {
future ActivityFuture
}
)

// Register installs the typed handler and its default scheduling options.
func (a Activity[I, O]) Register(ctx context.Context, registrar ActivityRegistrar, opts ActivityOptions, fn func(context.Context, I) (O, error)) error {
return registrar.RegisterActivity(ctx, ActivityDefinition{Name: a.Name, Options: opts, Handler: fn})
}

// Execute schedules the activity and waits for its typed result.
func (a Activity[I, O]) Execute(ctx context.Context, executor ActivityExecutor, input I, opts ActivityOptions) (O, error) {
var out O
err := executor.ExecuteActivity(ctx, ActivityCall{Name: a.Name, Input: input, Options: opts}, &out)
return out, err
}

// ExecuteAsync schedules the activity without waiting for completion.
func (a Activity[I, O]) ExecuteAsync(ctx context.Context, executor ActivityExecutor, input I, opts ActivityOptions) (Future[O], error) {
future, err := executor.ExecuteActivityAsync(ctx, ActivityCall{Name: a.Name, Input: input, Options: opts})
if err != nil {
return nil, err
}
return typedActivityFuture[O]{future: future}, nil
}

func (f typedActivityFuture[O]) Get(ctx context.Context) (O, error) {
var out O
err := f.future.Get(ctx, &out)
return out, err
}

func (f typedActivityFuture[O]) IsReady() bool {
return f.future.IsReady()
}
Loading
Loading