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
3 changes: 3 additions & 0 deletions .github/workflows/coverage-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@ jobs:
with:
coverage-threshold: 75
fail-coverage: always
ignore-pattern: |
/docs/
/cmd/
2 changes: 2 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
- name: Run tests with coverage
run: |
go test ./... -covermode=count -coverprofile=coverage.out
grep -v -e '/docs/' -e '/cmd/' coverage.out > coverage.tmp || true
mv coverage.tmp coverage.out
go tool cover -func=coverage.out -o=coverage.out
- name: Go Coverage Badge
uses: tj-actions/coverage-badge-go@v3
Expand Down
23 changes: 12 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,19 @@ Strong success criteria let you loop independently. Weak criteria ("make it work

**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

## 5. Notes
## 5. Tooling (mandatory)

**These are important notes and must be followed.**
### Skills
- Before any response or action, invoke Skill tool twice with exact names `caveman`, then `using-superpowers`. Never Read skill files.

Always use these skills:
### jcodemunch (code search first)
- Session start: call `jcodemunch_guide` once, then `resolve_repo` with the absolute folder path to confirm the index is present (`list_repos` as fallback).
- This repo index: `local/simpwf-3c83eba5`, source root `/home/didasy/project/simpwf`.
- Discovery order: `search_symbols` → `get_context_bundle` (or `get_symbol_source`) → `search_text` (literals/comments; `is_regex=true` for regex) → `find_references`/`find_importers` (usages) → `get_ranked_context` (task context within token budget) → `get_file_tree`/`get_repo_outline` (structure).
- Never use Grep/Glob for code discovery in an indexed repo. Use Read only on the exact file about to edit (Edit requires a prior Read in the same conversation).
- Re-index: after editing source files, call `index_folder` with the absolute folder path. Never call `index_repo` with a local path (GitHub URLs only). Skip re-indexing for read-only work when the index is present.
- Deferred schemas: if a jcodemunch tool call fails, load it first via ToolSearch `select:<name>`.

- caveman
- superpowers

Before doing any work.

Always update jcodemunch index before doing any changes and after any changes to source code files.

Use github mcp when accessing github and jcodemunch mcp to find things in the source codes.
### GitHub
- Use `default.github___*` tools for all GitHub reads/writes. Never the `gh` CLI, curl, WebSearch, or FetchUrl for GitHub.

16 changes: 14 additions & 2 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ info:
Durable PostgreSQL-backed workflow engine. Definitions are immutable;
new versions reference the previous version. Instances run asynchronously
and expose status, context, input, and node-level debugging APIs.
Lean-context mode stores per-node context as replayable set/unset diffs
with periodic anchors. Fleet default: SIMPWF_ENGINE_LEAN_CONTEXT_DEFAULT
(also SIMPWF_ENGINE_LEAN_ANCHOR_EVERY, SIMPWF_ENGINE_LEAN_REPLAY_MAX).
servers:
- url: /
tags:
Expand Down Expand Up @@ -645,7 +648,12 @@ components:
properties:
name: { type: string }
previous_version_id: { type: string, format: uuid, nullable: true }
content: { type: object }
content:
type: object
description: |
Workflow content. Optional top-level `context_mode: full|lean`
overrides the SIMPWF_ENGINE_LEAN_CONTEXT_DEFAULT fleet default
and is snapshotted onto each created instance.
WorkflowDefinition:
type: object
required: [id, name, version, lineage_id, content, created_by, updated_by, created_at, updated_at]
Expand Down Expand Up @@ -709,10 +717,14 @@ components:
total_pages: { type: integer }
InstanceStatus:
type: object
required: [id, workflow_definition_id, status, waiting_reason, pause_requested, termination_pending, attempt, counters, created_by, updated_by, created_at, updated_at]
required: [id, workflow_definition_id, context_mode, status, waiting_reason, pause_requested, termination_pending, attempt, counters, created_by, updated_by, created_at, updated_at]
properties:
id: { type: string, format: uuid }
workflow_definition_id: { type: string, format: uuid }
context_mode:
type: string
enum: [full, lean]
description: Snapshotted at instance create from the definition context_mode or SIMPWF_ENGINE_LEAN_CONTEXT_DEFAULT
status: { type: string, enum: [waiting, running, paused, finished, failed, stopped] }
waiting_reason: { type: string, nullable: true }
pause_requested: { type: boolean }
Expand Down
21 changes: 18 additions & 3 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ func run(ctx context.Context, cfg *configuration.Config, logger *logrus.Logger)

nodeDefs := repository.NewNodeDefinitionRepository(db)
wfDefs := repository.NewWorkflowDefinitionRepository(db)
instances := repository.NewInstanceRepository(db)
leanOpts := model.LeanOptions{
LeanContextDefault: cfg.Engine.LeanContextDefault,
AnchorEvery: cfg.Engine.LeanAnchorEvery,
ReplayMax: cfg.Engine.LeanReplayMax,
}
instances := repository.NewInstanceRepositoryWithOptions(db, leanOpts)

nodeLimits := model.NodeLimits{
DefaultTimeout: cfg.Engine.DefaultNodeTimeout,
Expand Down Expand Up @@ -193,8 +198,18 @@ func run(ctx context.Context, cfg *configuration.Config, logger *logrus.Logger)
}
return wfSvc.Materialize(ctx, wc)
}
eng := engine.NewEngine(instances, executors, hookRunner, limits, loader, actor)
instSvc := service.NewInstanceService(instances, wfDefs, wfSvc, &executor.InputExecutor{}, hookRunner, actor, nodeLimits, eng)
eng := engine.NewEngine(instances, executors, hookRunner, limits, loader, actor, leanOpts)
instSvc := service.NewInstanceService(
instances,
wfDefs,
wfSvc,
&executor.InputExecutor{},
hookRunner,
actor,
nodeLimits,
eng,
leanOpts,
)
hostname, _ := os.Hostname()

// Broker input consumers deliver payloads to waiting input nodes whose
Expand Down
3 changes: 3 additions & 0 deletions cmd/atlas-loader/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func main() {
&repository.WorkflowDefinitionNodeRefModel{},
&repository.WorkflowRequestModel{},
&repository.WorkflowInstanceModel{},
&repository.NodeContextHistoryModel{},
&repository.NodeInstanceModel{},
&repository.WorkflowInstanceEventModel{},
&repository.InputDeliveryModel{},
Expand Down Expand Up @@ -75,6 +76,8 @@ func foreignKeyDDL() string {
// node_instances
add("node_instances", "fk_node_instances_workflow_instance", "\"workflow_instance_id\"", "workflow_instances", "\"id\"")
add("node_instances", "fk_node_instances_node_definition", "\"node_definition_id\"", "node_definitions", "\"id\"")
// node_context_history
add("node_context_history", "fk_node_context_history_workflow_instance", "\"workflow_instance_id\"", "workflow_instances", "\"id\"")
// workflow_instance_events
add("workflow_instance_events", "fk_wf_instance_events_workflow_instance", "\"workflow_instance_id\"", "workflow_instances", "\"id\"")
add("workflow_instance_events", "fk_wf_instance_events_created_by", "\"created_by\"", "users", "\"id\"")
Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ engine:
claim_batch_size: 10
max_output_bytes: 1048576
max_redirects: 5
# Operator overrides: SIMPWF_ENGINE_LEAN_CONTEXT_DEFAULT,
# SIMPWF_ENGINE_LEAN_ANCHOR_EVERY, SIMPWF_ENGINE_LEAN_REPLAY_MAX.
lean_context_default: false
lean_anchor_every: 20
lean_replay_max: 500
http_allowlist:
- "127.0.0.1:8080"
- "api.example.com"
Expand Down
3 changes: 3 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,9 @@ const docTemplate = `{
"attempt": {
"type": "integer"
},
"context_mode": {
"type": "string"
},
"counters": {
"type": "array",
"items": {
Expand Down
125 changes: 125 additions & 0 deletions docs/integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# FE integration guide

FE entry point for SimpWF REST API. Three files:

- `README.md` (this file): base URL, auth, conventions, errors, pagination, happy-path flow.
- `endpoints.md`: every endpoint, what it requires, what it returns, status codes.
- `fields.md`: every enum, every limit, per-node-type content reference.

Source of truth is code (`internal/workflow/handler/`, `internal/workflow/model/`, `internal/workflow/service/`).
`api/openapi.yaml` is stale in one place (`NodeDefinitionRequest.type` omits `output` and `poller`, both accepted by
code). Where this guide and `openapi.yaml` disagree, this guide follows code.

## Base URL and auth

- API base path: `/`. All v1 routes live under `/v1`. Health probes under `/health`.
- Auth: header `X-Api-Token: <token>`, required on all `/v1/*` routes only when server runs with `auth.enabled=true`.
Header name is case-insensitive (standard HTTP semantics); comparison is constant-time, so send the token exactly.
- Missing/invalid token → `401` with `application/problem+json` body.
- Health (`GET /health/live`, `GET /health/ready`) never requires auth.
- Interactive explorer: `GET /swagger/*any` (e.g. `/swagger/index.html`, `/swagger/doc.json`, Swagger 2.0) when server
runs with swagger enabled (disabled → `404`).

## Conventions

- Request and response bodies are JSON. Success content type is `application/json`.
- All ids (definition, instance, occurrence, lineage, user) are canonical lowercase UUIDv7 strings, e.g.
`11111111-1111-7111-8111-111111111101`. Uppercase or non-canonical forms are rejected wherever an id is validated.
- Timestamps are RFC 3339 date-time strings (`created_at`, `updated_at`, `started_at`, `finished_at`, `stopped_at`).
- Nullable fields render as JSON `null` when empty (`waiting_reason`, `error`, `started_at`, `finished_at`,
`previous_version_id`, `occurrence_id`, `attempt`, snapshots, `input`/`output`, `duration_ms`). `waiting_reason: null`
means runnable.
- `PUT /v1/workflow/instance/{id}/context` is full replacement, not merge. Keys absent from the body are dropped.
- `PUT /v1/workflow/instance/{id}/input` accepts any valid JSON body (object, array, scalar). Every other JSON-object
body in the API must be an object; arrays, strings, numbers, and bare `null` are rejected there.

## Errors

Error content type is `application/problem+json`:

```json
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "name, type, and content are required",
"instance": "/v1/node/definition"
}
```

Status mapping:

| Status | Meaning here |
| ------ | ------------------------------------------------------------------------------------------------------------------ |
| 200 | OK (includes pause-immediate, resume, stop, rollback, node debug) |
| 201 | Definition created |
| 202 | Accepted: instance created, input accepted, pause deferred (node still running) |
| 204 | Deleted, empty body |
| 400 | Malformed JSON, malformed query param, invalid path uuid on definition routes (`GET`/`DELETE .../definition/{id}`) |
| 401 | Missing/invalid `X-Api-Token` (auth enabled) |
| 404 | Unknown id (definition, instance, occurrence, attempt beyond latest) |
| 409 | State conflict: delete while referenced, control on terminal instance, context/input/rollback guard failed |
| 422 | Semantic validation failure (bad enum, bad content, bad payload, missing required field) |
| 500 | Server error (includes malformed instance path ids the database rejects instead of returning zero rows) |
| 503 | `GET /health/ready` only: database unreachable |

Rule of thumb for FE: `400` = fix the request shape, `422` = fix the values, `409` = refresh state first (instance moved
on), `404` on a just-created id = treat as gone and refresh the list.

## Pagination and list filters

Every `GET` list returns the same envelope:

```json
{
"items": [],
"page": 1,
"per_page": 50,
"total": 0,
"total_pages": 0
}
```

- `page`: integer `>= 1`, default `1`.
- `per_page`: integer `1`–`200`, default `50`.
- `order`: allowlisted field with optional single leading `-` for descending (e.g. `order=-created_at`). Default
`-created_at`. `--created_at` rejected. Allowlist differs per endpoint, see `endpoints.md`.
- `id`: repeatable uuid filter (`?id=A&id=B`), max 100 values. Every value must be a valid uuid.
- `name`: exact-match string filter (definitions only), case-sensitive.
- `lineage_id`: single uuid. `version`: integer `>= 1`. `latest_only`: boolean (`true`/`false`; `strconv.ParseBool`
forms accepted).
- `total_pages` is `ceil(total / per_page)`.

## Happy-path flow

1. Create node definitions (`POST /v1/node/definition`) for reusable steps, or skip them and author nodes inline in the
workflow content.
2. Create a workflow definition (`POST /v1/workflow/definition`) with `start_node_id`, `nodes`, optional `keys`,
optional `context_mode`, optional `status_update`. Full sample: `workflow.yaml` at repo root.
3. Start an instance (`POST /v1/workflow/instance`) with `workflow_definition_id` and optional `context` object.
4. Poll `GET .../status` until `status` is `paused` (input wait), `finished`, `failed`, or `stopped`. `nodes` map shows
per-graph-node progress.
5. If `waiting_reason == "input"`, find the parked input node (status entry with occurrence in `waiting`), then
`PUT .../input` with the payload and a fresh `Idempotency-Key`.
6. Paused instance with wrong data: `PUT .../context` (full replacement), then `POST .../resume`.
7. Failed/paused instance that must redo work: pick `occurrence_id` from the `nodes` map or node debug API,
`POST .../rollback`, then `POST .../resume`.
8. Inspect any step: `GET .../status/node/{node_id}` (`context_before`, `context_after`, `input`, `output`, `error`,
attempts).

## Instance lifecycle (for button state)

```
waiting --claim--> running --checkpoint--> waiting
waiting --pause--> paused --resume--> waiting
running --pause--> running (pause_requested=true, 202) --settles--> paused
waiting|running|paused --stop--> stopped (terminal)
running --finish--> finished (terminal)
running --fail--> failed (terminal)
failed --rollback--> paused (only exception to terminal immutability)
```

Enable controls by status: pause on `waiting`/`running`; resume on `paused`; stop on `waiting`/`running`/`paused`;
context replace and rollback on `paused` only (rollback also on `failed`); input delivery only when `waiting` with
`waiting_reason == "input"`. `finished`/`failed`/`stopped` accept no controls except rollback on `failed`.
`termination_pending == true` blocks rollback.
Loading
Loading