diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e741e7b..16b3147 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ @@ -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 diff --git a/.superverse/template.json b/.superverse/template.json index a2a0658..1717e58 100644 --- a/.superverse/template.json +++ b/.superverse/template.json @@ -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", @@ -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", diff --git a/AGENTS.md b/AGENTS.md index 1d3b144..26fef5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/DEX_WEB_V2_BASELINE b/DEX_WEB_V2_BASELINE new file mode 100644 index 0000000..3e0c4dd --- /dev/null +++ b/DEX_WEB_V2_BASELINE @@ -0,0 +1 @@ +78c810baf027bcc02dd556b6046f4bd8baafc402 diff --git a/Makefile b/Makefile index 6e2ef2a..6d5d750 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 8750d8c..fa80190 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 diff --git a/internal/process/flow.go b/internal/process/flow.go index f0a5770..7bf0d68 100644 --- a/internal/process/flow.go +++ b/internal/process/flow.go @@ -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] } @@ -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] } @@ -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)), @@ -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 @@ -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] } @@ -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] } @@ -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{ @@ -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}, @@ -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{} @@ -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 diff --git a/internal/process/flow_test.go b/internal/process/flow_test.go index 46bdce0..cee2c58 100644 --- a/internal/process/flow_test.go +++ b/internal/process/flow_test.go @@ -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) + } } diff --git a/internal/process/integration_test.go b/internal/process/integration_test.go index 63af163..ad75fba 100644 --- a/internal/process/integration_test.go +++ b/internal/process/integration_test.go @@ -42,7 +42,7 @@ func TestReminderWorkerRestartApprovalAndTerminalRejection(t *testing.T) { t.Log("reminder Timer skipped") waitFor(t, ctx, func(attemptCtx context.Context) bool { current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) - return getErr == nil && current.ReminderCount == 1 && current.State == process.StateWaitingForApproval + return getErr == nil && current.ReminderCount == 1 && current.State == process.StateReminderEmitted }) t.Log("reminder observed") @@ -54,7 +54,7 @@ func TestReminderWorkerRestartApprovalAndTerminalRejection(t *testing.T) { t.Log("replacement Worker started") waitFor(t, ctx, func(attemptCtx context.Context) bool { current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) - return getErr == nil && current.State == process.StateWaitingForApproval + return getErr == nil && current.State == process.StateReminderEmitted }) approved, err := runtime.Processes.Approve(ctx, view.FlowID) if err != nil { diff --git a/internal/process/service.go b/internal/process/service.go index d8a0579..ddbb9a9 100644 --- a/internal/process/service.go +++ b/internal/process/service.go @@ -73,10 +73,10 @@ func (service *Service) Get(ctx context.Context, flowID string) (View, error) { } func (service *Service) Approve(ctx context.Context, flowID string) (View, error) { - var snapshot Snapshot - err := service.client.InvokeRPC(ctx, flowID, service.flow.ApproveProcess, true, &snapshot) + var output dex.None + err := service.client.InvokeRPC(ctx, flowID, service.flow.ApproveProcess, nil, &output) if err == nil { - return snapshotView(flowID, snapshot), nil + return service.Get(ctx, flowID) } var inactive *dex.FlowNotActiveError if errors.As(err, &inactive) { diff --git a/internal/templatecontract/contract_test.go b/internal/templatecontract/contract_test.go index f5644a4..eeb17c4 100644 --- a/internal/templatecontract/contract_test.go +++ b/internal/templatecontract/contract_test.go @@ -12,6 +12,7 @@ import ( type manifest struct { SchemaVersion int `json:"schemaVersion"` BuildProfile string `json:"buildProfile"` + TemplateVersion string `json:"templateVersion"` MinimumSandboxRuntimeContractRevision int `json:"minimumSandboxRuntimeContractRevision"` OpenAPISpec string `json:"openapiSpec"` AgentInstructions string `json:"agentInstructions"` @@ -29,7 +30,7 @@ func TestTemplateContract(t *testing.T) { if err := json.Unmarshal(manifestBytes, &contract); err != nil { t.Fatalf("decode manifest: %v", err) } - if contract.SchemaVersion != 1 || contract.BuildProfile != "go-react-v1" || contract.MinimumSandboxRuntimeContractRevision != 2 { + if contract.SchemaVersion != 1 || contract.BuildProfile != "go-react-v1" || contract.TemplateVersion != "1.1.0" || contract.MinimumSandboxRuntimeContractRevision != 2 { t.Fatalf("unexpected template identity: %+v", contract) } for _, path := range []string{contract.OpenAPISpec, contract.AgentInstructions, contract.DexSkill} { diff --git a/scripts/check-fdg-v2.sh b/scripts/check-fdg-v2.sh new file mode 100755 index 0000000..f559b31 --- /dev/null +++ b/scripts/check-fdg-v2.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +dexcli_binary="${DEXCLI:-dexcli}" +output_base="$(mktemp "${TMPDIR:-/tmp}/basic-process-fdg-v2.XXXXXX")" +output_json="${output_base}.json" +cleanup() { + rm -f -- "${output_base}" "${output_json}" +} +trap cleanup EXIT + +"${dexcli_binary}" visualize internal/process/flow.go \ + --schema-version 2.0 \ + --json \ + --out "${output_base}" + +python3 - "${output_json}" <<'PY' +import json +import sys + +path = sys.argv[1] +document = json.load(open(path)) +if document.get("valid") is not True: + diagnostics = json.dumps(document.get("diagnostics", []), indent=2) + raise SystemExit(f"FDG 2.0 graph is invalid:\n{diagnostics}") +if document.get("diagnostics"): + raise SystemExit(f"FDG 2.0 graph has diagnostics: {document['diagnostics']}") +print("validated FDG 2.0 graph") +PY diff --git a/web/src/App.tsx b/web/src/App.tsx index ca0a164..e2fa2b8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,6 +21,8 @@ const stateRank: Record = { completed: 5, }; +const approvableStates = new Set(['waiting_for_approval', 'reminder_emitted']); + export function App() { const [title, setTitle] = useState('Review the launch checklist'); const [flow, setFlow] = useState(); @@ -92,7 +94,7 @@ export function App() {

Reminders emitted: {flow.reminderCount}

- {flow.state !== 'completed' && } + {approvableStates.has(flow.state) && }
{flow.result &&

{flow.result}

}