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
27 changes: 19 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,29 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Read Dex Web v2 baseline
id: dex-web-v2
shell: bash
run: echo "revision=$(cat DEX_WEB_V2_BASELINE)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
repository: superdurable/dex
ref: ${{ steps.dex-web-v2.outputs.revision }}
path: upstream-dex
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
go-version-file: upstream-dex/cli/go.mod
cache-dependency-path: |
go.sum
tools/openapi/go.sum
upstream-dex/cli/go.sum
- uses: actions/setup-node@v4
with:
node-version: 22.22.0
cache: npm
cache-dependency-path: web/package-lock.json
cache-dependency-path: |
web/package-lock.json
upstream-dex/web/package-lock.json
- name: Install Temporal CLI 1.8.1
run: |
curl --fail --silent --show-error --location \
Expand All @@ -34,14 +46,13 @@ jobs:
tar -xzf /tmp/temporal-cli.tar.gz -C "$HOME/.local/bin" temporal
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/temporal" --version
- name: Install dexcli
- name: Build pinned Dex Web v2 CLI
run: |
curl --fail --silent --show-error --location \
https://github.com/superdurable/dex/releases/download/cli-v0.10.0/dexcli_v0.10.0_linux_amd64.tar.gz \
--output /tmp/dexcli.tar.gz
echo "0cee3b0795147b581c45d2258b0c2d581cd35ce75c515027e2d9b294ce364e2d /tmp/dexcli.tar.gz" | sha256sum --check
npm --prefix upstream-dex/web ci
npm --prefix upstream-dex/web run check
npm --prefix upstream-dex/web run build
mkdir -p "$HOME/.local/bin"
tar -xzf /tmp/dexcli.tar.gz -C "$HOME/.local/bin" dexcli
GOWORK=off go build -C upstream-dex/cli -trimpath -o "$HOME/.local/bin/dexcli" ./cmd/dexcli
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install Playwright Chromium
working-directory: web
Expand Down
3 changes: 2 additions & 1 deletion .superverse/template.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"buildProfile": "go-react-v1",
"templateVersion": "1.0.0",
"templateVersion": "1.1.0",
"minimumSandboxRuntimeContractRevision": 2,
"openapiSpec": "openapi/openapi.yaml",
"agentInstructions": "AGENTS.md",
Expand All @@ -10,6 +10,7 @@
"bootstrap": "make bootstrap",
"generate": "make generate",
"checkGenerated": "make check-generated",
"checkFdgV2": "make check-fdg-v2",
"testUnit": "make test-unit",
"testIntegration": "make test-integration",
"testE2E": "make test-e2e",
Expand Down
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ approval Channel and reminder Timer. Register every durable primitive in the
Flow persistence schema. Preserve open-Flow compatibility unless the user
explicitly requests a migration.

The Flow is a Dex Web v2 / FDG 2.0 definition. Keep its indexed Attributes,
`GetDexSummary`, `GetDexDisplay`, Action RPCs, directives, input structs, and
Dex control flow in `internal/process/flow.go`. Every Step has exactly one
group and explanation. Run `make check-fdg-v2`; never fall back to rendering
schema v1.

After each edit batch, run the narrowest relevant Make target. Before calling
`commit_and_push`, run `make check` successfully and include it in verification.
If `make check` fails or cannot run, report `blocked=true`. Do not weaken, skip,
or delete a failing check.

Stable commands are `make bootstrap`, `make generate`, `make check-generated`,
`make test-unit`, `make test-integration`, `make test-e2e`, `make build`,
`make dev`, and `make check`.
`make check-fdg-v2`, `make test-unit`, `make test-integration`,
`make test-e2e`, `make build`, `make dev`, and `make check`.

When structure, commands, or required tooling changes, update this file,
`.superverse/template.json`, `README.md`, and contract tests together. Do not
Expand Down
1 change: 1 addition & 0 deletions DEX_WEB_V2_BASELINE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
78c810baf027bcc02dd556b6046f4bd8baafc402
8 changes: 5 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
SHELL := /usr/bin/env bash

.PHONY: bootstrap generate check-generated test-unit test-integration test-e2e build dev check
.PHONY: bootstrap generate check-generated check-fdg-v2 test-unit test-integration test-e2e build dev check
bootstrap:
go mod download
go -C tools/openapi mod download
Expand All @@ -10,6 +10,8 @@ generate:
npm --prefix web run generate
check-generated:
./scripts/check-generated.sh
check-fdg-v2:
./scripts/check-fdg-v2.sh
test-unit:
go test ./...
npm --prefix web test
Expand All @@ -22,8 +24,8 @@ build:
go build -o bin/basic-process ./cmd/server
dev:
./scripts/with-dex.sh bash -c 'npm --prefix web run build && go run ./cmd/server'
check: bootstrap check-generated
@test -z "$$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*'))" || { gofmt -d $$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*')); exit 1; }
check: bootstrap check-generated check-fdg-v2
@test -z "$$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*' -not -path './upstream-dex/*'))" || { gofmt -d $$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*' -not -path './upstream-dex/*')); exit 1; }
go mod tidy -diff
go vet ./...
$(MAKE) test-unit
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ When the Timer fires, `EmitReminder` increments the durable reminder count and
returns to the waiting step. Approval advances to the execution and completion
steps.

The Flow is also a complete Dex Web v2 definition. Indexed title and state
Attributes drive list/search and Action eligibility. `GetDexSummary` and
`GetDexDisplay` provide the read-only Web views, while `ApproveProcess` is a
native Web v2 Action. Every Step has an FDG 2.0 group and explanation.

## Start locally

```bash
Expand All @@ -38,6 +43,7 @@ cleans it up on exit.
```bash
make generate
make check-generated
make check-fdg-v2
```

Generated files are committed so a checkout is immediately understandable.
Expand All @@ -56,11 +62,17 @@ Integration tests start a real Dex Server with `dexcli dev`. Playwright drives
the production UI and uses `dexcli flow skip-timer` to exercise the reminder
branch without waiting fifteen minutes. Every poll has a deadline.

`make check-fdg-v2` validates `internal/process/flow.go` with rendering schema
2.0 and requires a diagnostic-free graph with `valid: true`. The required
preview `dexcli` source is pinned in `DEX_WEB_V2_BASELINE`; schema v1 is not an
accepted fallback.

`make check` is the required completion gate for coding agents and CI.

The supported sandbox runtime is contract revision 2. It provides Go, Node.js,
npm, Python 3, `dexcli`, Ogen's cached module dependencies, and Chromium
Headless Shell. JavaScript packages remain pinned by `web/package-lock.json`.
npm, Python 3, an FDG 2.0-capable `dexcli`, Ogen's cached module dependencies,
and Chromium Headless Shell. JavaScript packages remain pinned by
`web/package-lock.json`.

## Dex skill

Expand Down
105 changes: 78 additions & 27 deletions internal/process/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,22 @@ type Snapshot struct {
}

var (
ProcessTitle = dex.DefineAttribute[string]("process-title")
ProcessState = dex.DefineAttribute[State]("process-state")
// dex:indexed-attribute attribute-key:process-title index-key:process-title index-type:fulltext value-type:string description:"Process title"
ProcessTitle = dex.DefineAttribute[string](
"process-title",
dex.Indexed(dex.AttributeIndex{Type: dex.IndexFullText}),
)
// dex:indexed-attribute attribute-key:process-state index-key:process-state index-type:keyword value-type:string description:"Current process state"
ProcessState = dex.DefineAttribute[string](
"process-state",
dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}),
)
ProcessReminderCount = dex.DefineAttribute[int64]("process-reminder-count")
ProcessApprovals = dex.DefineChannel[bool]("process-approvals")
)

// dex:group group-id:intake group-label:"Intake"
// dex:explanation text:"Store the request and initialize its durable process state."
type StartProcess struct {
dex.StepDefaultsNoWaitFor[Input]
}
Expand All @@ -62,12 +72,14 @@ func (StartProcess) Execute(ctx dex.Context, input Input) (*dex.StepDecision, er
if err := ProcessReminderCount.Set(ctx, 0); err != nil {
return nil, err
}
if err := ProcessState.Set(ctx, StateStarted); err != nil {
if err := ProcessState.Set(ctx, string(StateStarted)); err != nil {
return nil, err
}
return dex.GoTo(ValidateRequest{}, input), nil
}

// dex:group group-id:intake group-label:"Intake"
// dex:explanation text:"Validate the request before opening it for approval."
type ValidateRequest struct {
dex.StepDefaultsNoWaitFor[Input]
}
Expand All @@ -77,18 +89,20 @@ func (ValidateRequest) Execute(ctx dex.Context, input Input) (*dex.StepDecision,
if title == "" || len(title) > 120 {
return nil, fmt.Errorf("title must contain between 1 and 120 bytes")
}
if err := ProcessState.Set(ctx, StateValidated); err != nil {
if err := ProcessState.Set(ctx, string(StateValidated)); err != nil {
return nil, err
}
if err := ProcessState.Set(ctx, string(StateWaitingForApproval)); err != nil {
return nil, err
}
return dex.GoTo(WaitForApproval{}, input), nil
}

// dex:group group-id:review group-label:"Review"
// dex:explanation text:"Wait durably for an approval or the next reminder deadline."
type WaitForApproval struct{ dex.StepDefaults }

func (WaitForApproval) WaitFor(ctx dex.Context, _ Input) (*dex.Wait, error) {
if err := ProcessState.Set(ctx, StateWaitingForApproval); err != nil {
return nil, err
}
func (WaitForApproval) WaitFor(_ dex.Context, _ Input) (*dex.Wait, error) {
return dex.AnyOf(
ProcessApprovals.ForOne(),
dex.Timer(ReminderInterval, dex.WithConditionID(ReminderTimerConditionID)),
Expand All @@ -101,7 +115,7 @@ func (WaitForApproval) Execute(ctx dex.Context, input Input) (*dex.StepDecision,
return nil, err
}
if len(approvals) == 1 && approvals[0] {
if err := ProcessState.Set(ctx, StateApproved); err != nil {
if err := ProcessState.Set(ctx, string(StateApproved)); err != nil {
return nil, err
}
return dex.GoTo(ExecuteApprovedAutomation{}, input), nil
Expand All @@ -112,6 +126,8 @@ func (WaitForApproval) Execute(ctx dex.Context, input Input) (*dex.StepDecision,
return nil, fmt.Errorf("approval wait completed without approval or reminder")
}

// dex:group group-id:review group-label:"Review"
// dex:explanation text:"Record a reminder before returning to the approval wait."
type EmitReminder struct {
dex.StepDefaultsNoWaitFor[Input]
}
Expand All @@ -124,23 +140,27 @@ func (EmitReminder) Execute(ctx dex.Context, input Input) (*dex.StepDecision, er
if err := ProcessReminderCount.Set(ctx, count+1); err != nil {
return nil, err
}
if err := ProcessState.Set(ctx, StateReminderEmitted); err != nil {
if err := ProcessState.Set(ctx, string(StateReminderEmitted)); err != nil {
return nil, err
}
return dex.GoTo(WaitForApproval{}, input), nil
}

// dex:group group-id:execution group-label:"Execution"
// dex:explanation text:"Execute the automation after a manager approves the request."
type ExecuteApprovedAutomation struct {
dex.StepDefaultsNoWaitFor[Input]
}

func (ExecuteApprovedAutomation) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) {
if err := ProcessState.Set(ctx, StateExecuting); err != nil {
if err := ProcessState.Set(ctx, string(StateExecuting)); err != nil {
return nil, err
}
return dex.GoTo(CompleteProcess{}, input), nil
}

// dex:group group-id:close group-label:"Close"
// dex:explanation text:"Complete the process with its final durable result."
type CompleteProcess struct {
dex.StepDefaultsNoWaitFor[Input]
}
Expand All @@ -150,7 +170,7 @@ func (CompleteProcess) Execute(ctx dex.Context, input Input) (*dex.StepDecision,
if err != nil {
return nil, err
}
if err := ProcessState.Set(ctx, StateCompleted); err != nil {
if err := ProcessState.Set(ctx, string(StateCompleted)); err != nil {
return nil, err
}
return dex.GracefulComplete(Result{
Expand All @@ -174,17 +194,52 @@ func (BasicProcessFlow) GetSteps() []dex.StepDef {

func (flow BasicProcessFlow) GetRPCs() []dex.RPCDef {
return []dex.RPCDef{
dex.DefineRPC(flow.GetDexSummary, nil),
dex.DefineRPC(flow.GetDexDisplay, nil),
dex.DefineRPC(flow.DescribeProcess, &dex.RPCOptions{}),
dex.DefineRPC(flow.ApproveProcess, &dex.RPCOptions{
IsTransactional: true,
LockAttributes: []dex.AttributeLock{
dex.LockAttribute(ProcessState),
dex.LockAttribute(ProcessReminderCount),
},
}),
}
}

// dex:field attribute-key:process-reminder-count value-type:int64 editable:false description:"Reminder count"
func (BasicProcessFlow) GetDexSummary(ctx dex.Context, _ dex.None) (*dex.RPCResult[map[string]any], error) {
count, err := ProcessReminderCount.Get(ctx)
if err != nil {
return nil, err
}
return &dex.RPCResult[map[string]any]{Output: map[string]any{
"process-reminder-count": count,
}}, nil
}

// dex:field attribute-key:process-title value-type:string editable:false description:"Process title"
// dex:field attribute-key:process-state value-type:string editable:false description:"Current process state"
// dex:field attribute-key:process-reminder-count value-type:int64 editable:false description:"Reminder count"
func (BasicProcessFlow) GetDexDisplay(ctx dex.Context, _ dex.None) (*dex.RPCResult[map[string]any], error) {
title, err := ProcessTitle.Get(ctx)
if err != nil {
return nil, err
}
state, err := ProcessState.Get(ctx)
if err != nil {
return nil, err
}
count, err := ProcessReminderCount.Get(ctx)
if err != nil {
return nil, err
}
return &dex.RPCResult[map[string]any]{Output: map[string]any{
"process-title": title,
"process-state": state,
"process-reminder-count": count,
}}, nil
}

func (BasicProcessFlow) GetPersistenceSchema() dex.PersistenceSchema {
return dex.PersistenceSchema{
Attributes: []dex.AttributeDef{ProcessTitle, ProcessState, ProcessReminderCount},
Expand All @@ -205,32 +260,26 @@ func (BasicProcessFlow) DescribeProcess(ctx dex.Context, _ dex.None) (*dex.RPCRe
if err != nil {
return nil, err
}
return &dex.RPCResult[Snapshot]{Output: Snapshot{Title: title, State: state, ReminderCount: count}}, nil
return &dex.RPCResult[Snapshot]{Output: Snapshot{Title: title, State: State(state), ReminderCount: count}}, nil
}

func (BasicProcessFlow) ApproveProcess(ctx dex.Context, approved bool) (*dex.RPCResult[Snapshot], error) {
// dex:action action-label:"Approve"
// dex:when attribute-key:process-state operator:in values:["waiting_for_approval","reminder_emitted"]
func (BasicProcessFlow) ApproveProcess(ctx dex.Context, _ dex.None) (*dex.RPCResult[dex.None], error) {
state, err := ProcessState.Get(ctx)
if err != nil {
return nil, err
}
if !approved || (state != StateWaitingForApproval && state != StateReminderEmitted) {
if state != string(StateWaitingForApproval) && state != string(StateReminderEmitted) {
return nil, fmt.Errorf("%s", duplicateApprovalDetail)
}
title, err := ProcessTitle.Get(ctx)
if err != nil {
return nil, err
}
count, err := ProcessReminderCount.Get(ctx)
if err != nil {
return nil, err
}
if err := ProcessState.Set(ctx, StateApproved); err != nil {
if err := ProcessState.Set(ctx, string(StateApproved)); err != nil {
return nil, err
}
if err := ProcessApprovals.Publish(ctx, true); err != nil {
return nil, err
}
return &dex.RPCResult[Snapshot]{Output: Snapshot{Title: title, State: StateApproved, ReminderCount: count}}, nil
return &dex.RPCResult[dex.None]{}, nil
}

var BasicProcess = BasicProcessFlow{}
Expand All @@ -242,5 +291,7 @@ var _ dex.Step[Input] = WaitForApproval{}
var _ dex.Step[Input] = EmitReminder{}
var _ dex.Step[Input] = ExecuteApprovedAutomation{}
var _ dex.Step[Input] = CompleteProcess{}
var _ dex.RPC[dex.None, map[string]any] = BasicProcess.GetDexSummary
var _ dex.RPC[dex.None, map[string]any] = BasicProcess.GetDexDisplay
var _ dex.RPC[dex.None, Snapshot] = BasicProcess.DescribeProcess
var _ dex.RPC[bool, Snapshot] = BasicProcess.ApproveProcess
var _ dex.RPC[dex.None, dex.None] = BasicProcess.ApproveProcess
3 changes: 3 additions & 0 deletions internal/process/flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ func TestBasicProcessDefinitionRegistersEveryDurablePrimitive(t *testing.T) {
if got := len(BasicProcess.GetPersistenceSchema().Channels); got != 1 {
t.Fatalf("channel count = %d, want 1", got)
}
if got := len(BasicProcess.GetRPCs()); got != 4 {
t.Fatalf("RPC count = %d, want 4", got)
}
}
Loading
Loading