Skip to content

✨ feat(workflow): reuse accepted goals as versioned, schedulable workflows (#594)#636

Merged
vaayne merged 16 commits into
mainfrom
feat/workflow-entity
Jul 4, 2026
Merged

✨ feat(workflow): reuse accepted goals as versioned, schedulable workflows (#594)#636
vaayne merged 16 commits into
mainfrom
feat/workflow-entity

Conversation

@vaayne

@vaayne vaayne commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

What

Implements workflow-as-entity (#594): an accepted composite goal can be saved as a reusable, versioned workflow and re-run — manually or on a schedule — as a fresh goal tree with the same frozen plan.

  • Schema: agent_workflow (immutable version rows: name, version, inputs, payload_format='frozen/v0', FrozenPlan payload, fully_frozen), agent_workflow_run (instantiation ledger, UNIQUE(workflow_id, idempotency_key)), workflow_id/workflow_version attribution columns on agent_goal, sched_job.dispatch_kind (chat|workflow), sched_job_run.root_goal_id.
  • internal/workflow: recursive FrozenPlan snapshot/validation, text-level inputs ({{inputs.name}} substitution over a closed allowlist: title / intent / judgment prompt / rubric — never deterministic commands; unresolved placeholder = hard error), SaveGoalAsWorkflow, and claim→materialize→done Instantiate with full crash-resume.
  • HTTP API + CLI: workflow CRUD/run endpoints (OpenAPI-first, new workflows credential scope), stella workflow save|list|show|run.
  • Scheduler bridge: stella scheduler add --workflow <id> --cron ...; the scheduler owns time, the workflow owns structure. Kind-aware job validation, fully_frozen gate (partially frozen requires explicit allow_replan), and a status-aware overlap policy.
  • Surface: system skill + system prompt updates, how-to docs (EN+ZH), web UI — workflows list, a save-as-workflow dialog on accepted goals (with an inputs editor), a read-only workflow detail page (inputs, frozen-plan tree, run history, run-with-inputs and delete actions), lineage badge on instantiated goals ("from workflow X · run #N"), and a workflow-filtered run-history view.

Why

Users ask "keep this goal and run it every morning." That decomposes into definition / trigger / instance — none of which existed: goals were one-shot trees, and re-running meant re-planning from scratch with planner drift. This PR adds the missing entity (definition), reuses the scheduler as the trigger, and makes every run a fresh, attributable goal tree (instance). Done goals are never reopened.

How

Key invariants and mechanics:

  • Determinism: a saved workflow snapshots the accepted decomposition per node. fully_frozen (every composite has a saved sub-plan) is required for scheduled runs by default, so the same structure replays without planner drift. A nil sub-plan composite stays draft with planned_at IS NULL so the planner picks it up at runtime.
  • Idempotency: Instantiate claims a run row via INSERT ON CONFLICT (+ xmax = 0 to detect the claim), sets the root via CAS (WHERE root_goal_id IS NULL); a concurrent loser deletes its own draft orphan and continues the walk on the winner's root. The layer walk is idempotent (planned_at fence + deterministic child IDs), and resume re-substitutes from the inputs stored on the run row.
  • Overlap policy (self-healing): previous run failed/skipped → run fresh; stalled mid-claim/materialize → resume with the old idempotency key (never a second tree); done with a still-active root → skip and record why; otherwise fresh with idempotency_key = sched_job_run.id.
  • Decoupling: the scheduler talks to a small WorkflowRunner interface; the adapter is wired in the composition root, so internal/scheduler does not import internal/workflow.

Three bugs were caught during adversarial review + live e2e and fixed in-branch: a double-root race in Instantiate, a permanent-skip deadlock in the scheduler after a mid-claim crash, and the run root not inheriting the workflow's agent / not substituting inputs into the root intent.

Verified: mise run format && mise run build && mise run test green; live e2e (CLI save/run, idempotent replay, scope enforcement 403, scheduler validation) plus browser verification of the list page, lineage badge, and filtered run history.

Refs

vaayne added 5 commits July 3, 2026 20:28
…ribution, dispatch_kind (#594)

Phase 1 of workflow-as-entity: the definition entity (agent_workflow,
immutable version rows, owner XOR check, UNIQUE NULLS NOT DISTINCT on
owner/name/version), the instantiation ledger (agent_workflow_run with
(workflow_id, idempotency_key) uniqueness and claimed/materializing/
done/failed/skipped states), run attribution columns on agent_goal,
sched_job.dispatch_kind, and sched_job_run.root_goal_id, plus sqlc
queries (ClaimWorkflowRun uses xmax=0 to distinguish fresh claims in
one round trip).
…resume instantiation (#594)

Phase 2 of workflow-as-entity: FrozenPlan DTO (strict decode, per-layer
ValidateDecomposition reuse, FullyFrozen, content hash), input signature
with allowlisted {{inputs.*}} substitution (title/intent/judgment
prompt/rubric only — deterministic command excluded; unresolved or
unknown placeholders are hard errors), SaveGoalAsWorkflow (accepted
composite → recursive structural snapshot), and Instantiate with the
claim-resume protocol: ClaimWorkflowRun dedupes by idempotency key,
SetWorkflowRunRoot is a CAS (root_goal_id IS NULL) so a lost race
deletes the loser's draft root and continues the idempotent walk
against the winner's tree; nil-plan composites stay draft+unplanned
for the dispatcher's planner path. Goal-side surface is two narrow
hooks (MaterializeFrozenLayer, ActivateFrozenComposite) plus workflow
attribution on CreateInput.
Phase 3 of workflow-as-entity: OpenAPI-first endpoints (save-as-workflow,
list/get/delete workflows, list runs, instantiate with idempotency_key —
replays return 200, ErrRunAlreadyFailed and delete-with-runs map to 409),
handlers wired like goals (scoped boundary, cursor pagination, UTC
timestamps), a workflows credential scope, and the stella workflow
save/list/show/run CLI.
…-healing overlap policy (#594)

Phase 4 of workflow-as-entity: scheduled jobs learn a second dispatch
kind. Workflow jobs carry {workflow_id, inputs} in payload, reject a
chat message, and enforce the fully_frozen gate at creation
(allow_replan opts a partially frozen workflow in). Dispatch resolves
the sched_job_run id from a typed context, uses it as the instantiation
idempotency key, and records root_goal_id back onto the run row. The
cross-run overlap policy is status-aware and self-healing: a previous
run that is done with a non-terminal tree skips this fire; a stalled
claimed/materializing run is resumed via its original idempotency key
instead of minting a second tree; failed/skipped runs never block the
schedule. The scheduler stays decoupled from internal/workflow behind a
WorkflowRunner interface wired in the stellad composition root.
Workflow deletion now also refuses while enabled workflow jobs
reference the id. CLI: stella scheduler add --workflow/--input/
--allow-replan.
…ge, run history (#594)

Also fixes two instantiation bugs found in browser e2e:
- run root now inherits the workflow's own agent instead of the caller's
  scope (PAT callers have no agent scope → FK violation)
- run root intent now goes through input substitution like child nodes
  ({{inputs.*}} placeholders were landing raw on the root goal)
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

📊 Coverage Report

Total coverage: 49.8% (generated files excluded)

Lowest-covered entries (first 200)
github.com/CherryHQ/stella/api/client/helpers.go:103:        0.0%
github.com/CherryHQ/stella/api/client/helpers.go:111:        0.0%
github.com/CherryHQ/stella/api/client/helpers.go:17:         0.0%
github.com/CherryHQ/stella/api/client/helpers.go:27:         0.0%
github.com/CherryHQ/stella/api/client/helpers.go:35:         0.0%
github.com/CherryHQ/stella/api/client/helpers.go:58:         0.0%
github.com/CherryHQ/stella/api/client/helpers.go:70:         0.0%
github.com/CherryHQ/stella/api/client/helpers.go:89:         0.0%
github.com/CherryHQ/stella/cmd/stella/commands.go:9:         0.0%
github.com/CherryHQ/stella/cmd/stella/db.go:14:              0.0%
github.com/CherryHQ/stella/cmd/stella/db.go:24:              0.0%
github.com/CherryHQ/stella/cmd/stella/email.go:103:          0.0%
github.com/CherryHQ/stella/cmd/stella/email.go:815:          0.0%
github.com/CherryHQ/stella/cmd/stella/email.go:828:          0.0%
github.com/CherryHQ/stella/cmd/stella/email.go:82:           0.0%
github.com/CherryHQ/stella/cmd/stella/email.go:836:          0.0%
github.com/CherryHQ/stella/cmd/stella/email.go:844:          0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:109:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:155:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:194:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:232:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:23:            0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:268:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:302:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:342:           0.0%
github.com/CherryHQ/stella/cmd/stella/goal.go:50:            0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:130:    0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:142:    0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:149:    0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:156:    0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:17:     0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:57:     0.0%
github.com/CherryHQ/stella/cmd/stella/goal_health.go:82:     0.0%
github.com/CherryHQ/stella/cmd/stella/main.go:11:            0.0%
github.com/CherryHQ/stella/cmd/stella/mcp.go:127:            0.0%
github.com/CherryHQ/stella/cmd/stella/mcp.go:14:             0.0%
github.com/CherryHQ/stella/cmd/stella/mcp.go:33:             0.0%
github.com/CherryHQ/stella/cmd/stella/mcp.go:40:             0.0%
github.com/CherryHQ/stella/cmd/stella/mcp.go:75:             0.0%
github.com/CherryHQ/stella/cmd/stella/mise.go:13:            0.0%
github.com/CherryHQ/stella/cmd/stella/mise.go:29:            0.0%
github.com/CherryHQ/stella/cmd/stella/oauth.go:102:          0.0%
github.com/CherryHQ/stella/cmd/stella/oauth.go:139:          0.0%
github.com/CherryHQ/stella/cmd/stella/oauth.go:14:           0.0%
github.com/CherryHQ/stella/cmd/stella/oauth.go:31:           0.0%
github.com/CherryHQ/stella/cmd/stella/oauth.go:68:           0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:103:   0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:14:    0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:163:   0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:191:   0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:217:   0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:30:    0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:44:    0.0%
github.com/CherryHQ/stella/cmd/stella/oauth_client.go:78:    0.0%
github.com/CherryHQ/stella/cmd/stella/recally_articles.go:403: 0.0%
github.com/CherryHQ/stella/cmd/stella/scheduler.go:146:      0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:146:          0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:15:           0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:172:          0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:183:          0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:33:           0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:64:           0.0%
github.com/CherryHQ/stella/cmd/stella/token.go:89:           0.0%
github.com/CherryHQ/stella/cmd/stella/version.go:11:         0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:118:       0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:15:        0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:179:       0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:232:       0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:256:       0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:34:        0.0%
github.com/CherryHQ/stella/cmd/stella/workflow.go:80:        0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:333:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:349:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:370:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:408:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:416:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:436:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:448:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:506:      0.0%
github.com/CherryHQ/stella/cmd/stellad/commands.go:92:       0.0%
github.com/CherryHQ/stella/cmd/stellad/debug_dump.go:30:     0.0%
github.com/CherryHQ/stella/cmd/stellad/debug_dump.go:35:     0.0%
github.com/CherryHQ/stella/cmd/stellad/debug_dump.go:58:     0.0%
github.com/CherryHQ/stella/cmd/stellad/debug_dump_signal_unix.go:12: 0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:136:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:375:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:386:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:390:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:397:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:405:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:412:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:423:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:431:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:452:       0.0%
github.com/CherryHQ/stella/cmd/stellad/gateway.go:65:        0.0%
github.com/CherryHQ/stella/cmd/stellad/main.go:11:           0.0%
github.com/CherryHQ/stella/cmd/stellad/models.go:12:         0.0%
github.com/CherryHQ/stella/cmd/stellad/models.go:37:         0.0%
github.com/CherryHQ/stella/cmd/stellad/models.go:41:         0.0%
github.com/CherryHQ/stella/cmd/stellad/models.go:50:         0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:107: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:120: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:141: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:164: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:18: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:190: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:195: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:203: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:225: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:26: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:81: 0.0%
github.com/CherryHQ/stella/cmd/stellad/plugin_services.go:94: 0.0%
github.com/CherryHQ/stella/cmd/stellad/postgres.go:118:      0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:101: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:115: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:119: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:123: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:127: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:131: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:142: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:149: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:153: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:161: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:185: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:198: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:215: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:236: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:245: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:272: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:292: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:374: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:393: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:404: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:414: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:422: 0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:56:  0.0%
github.com/CherryHQ/stella/cmd/stellad/service_linux.go:60:  0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_embedding.go:19: 0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_embedding.go:31: 0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_memory.go:20:   0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_memory.go:70:   0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_memory.go:86:   0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_plugins.go:129: 0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_plugins.go:30:  0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_plugins.go:69:  0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_plugins.go:93:  0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_pool.go:23:     0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_pool.go:31:     0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_pool.go:39:     0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_pool.go:84:     0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_reflect.go:27:  0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_reflect.go:45:  0.0%
github.com/CherryHQ/stella/cmd/stellad/setup_skills.go:26:   0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:181:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:221:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:233:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:238:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:338:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:575:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:598:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:608:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:615:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:692:       0.0%
github.com/CherryHQ/stella/cmd/stellad/version.go:768:       0.0%
github.com/CherryHQ/stella/internal/agent/agentctx/context.go:12: 0.0%
github.com/CherryHQ/stella/internal/agent/agentctx/context.go:20: 0.0%
github.com/CherryHQ/stella/internal/agent/agentctx/context.go:29: 0.0%
github.com/CherryHQ/stella/internal/agent/agentctx/context.go:37: 0.0%
github.com/CherryHQ/stella/internal/agent/agentctx/context.go:46: 0.0%
github.com/CherryHQ/stella/internal/agent/agentctx/context.go:66: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:102: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:106: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:110: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:114: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:149: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:163: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:172: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:194: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:211: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:228: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:238: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:273: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:294: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:329: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:344: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:372: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:393: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:419: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:426: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:436: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:44: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:459: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:483: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:48: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:502: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:523: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:52: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:549: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:561: 0.0%
github.com/CherryHQ/stella/internal/agent/pool_manager.go:56: 0.0%

vaayne added 2 commits July 3, 2026 23:29
… web UI (#594)

Save as workflow from an accepted composite goal's page (name slug
prefill, optional inputs editor), plus a read-only workflow detail
page: metadata, inputs table, frozen-plan tree, run history, and
run-with-inputs / delete actions.

The run dialog holds one idempotency key per open so a retry after a
lost response resumes the same run instead of minting a second tree.
Workflow routes move to a directory (goals-style siblings) — the flat
workflows.$workflowId file nested under the list route, which has no
Outlet, so the detail page never rendered.
…#594)

Address adversarial review findings on the workflow-as-entity PR:

- Derive the run root goal id deterministically from the run id
  (sha256-based) and create it via idempotent insert, closing the
  crash window between root creation and run binding that could mint
  a second root. Remove the CAS-loser draft-root delete: with
  deterministic ids the loser holds the same row as the winner.
- Exclude workflow roots (workflow_id IS NOT NULL) from the
  autonomous decomposition dispatcher; they are materialized only by
  workflow replay.
- Instantiate consults the existing run by idempotency key before
  resolving caller inputs, and returns a created flag; the handler
  drops its racy pre-check and uses the flag for 201/200.
- POST /api/goals/{id}/save-as-workflow now requires workflows:write
  instead of goals:write.
- Scheduler workflow-job validation and not-found errors map to
  400/404 instead of 500.
- Retry version allocation on unique violation (3 attempts) instead
  of failing on concurrent saves.
- Validate frozen-plan depth against the stored convergence policy,
  not the package default.
- Web: keyed remount for the workflow detail route; run numbering
  uses the new WorkflowRunList.total instead of page length; goal
  lineage badge falls back to name-only when the run is outside the
  fetched page; run-dialog idempotency key survives dialog reopen and
  regenerates on input change or success.
- Docs/skill: overlap wording now states failed instantiation does
  not block the next tick and stalled instantiation resumes.
@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial review round (3 parallel reviews, cross-verified)

All findings fixed in 812859b. Highlights:

Blockers

  1. Crash window minted orphan rootsInstantiate created the run root before binding it to the run row; a crash in between left an unbound draft root that the decomposition dispatcher would pick up and plan with live LLM calls (zombie tree). Fixed by deriving the root goal id deterministically from the run id + idempotent insert (ON CONFLICT (id) DO UPDATE), and excluding workflow_id IS NOT NULL roots from ListDecomposableComposites. The old CAS-loser draft-root delete was removed — with deterministic ids the "loser" holds the same row as the winner, so deleting it would have destroyed the winner's tree.
  2. Scope gapPOST /api/goals/{id}/save-as-workflow enforced goals:write; it creates a workflow, so it now requires workflows:write (path-classification special case + test).

Should-fixes

  • Instantiate now consults the existing run by idempotency key before resolving caller inputs (done-run replay with missing/changed inputs returns the existing run instead of erroring), and returns a created flag; the handler's racy 201/200 pre-check is gone.
  • Scheduler workflow-job validation/not-found → 400/404 instead of 500.
  • Version allocation retries on unique violation instead of failing concurrent saves.
  • Frozen-plan depth validated against the stored convergence policy, not the package default.
  • Web: keyed remount for the detail route; run numbering uses new WorkflowRunList.total (page length lied beyond page 1); lineage badge shows name-only when the run is outside the fetched page; run-dialog idempotency key survives reopen (retry-safe) and regenerates on input change/success.
  • Docs/skill overlap wording: failed instantiation doesn't block the next tick; stalled instantiation resumes.

New tests: crash-resume binds the pre-created root, root-race convergence, dispatcher exclusion, done-replay idempotency, save-as-workflow scope. Gates: vp check --fix && vp build, mise run format && build && test — all green.

…idate inputs at save (#594)

Holistic self-review findings:

- Nested frozen composites were dispatcher bait: between walk transactions
  (or across a crash-resume gap) a composite child carrying a frozen
  sub-plan sat draft/unplanned without workflow_id, so scanAndDecompose
  would claim it and LLM-replan a node the workflow promised to replay
  deterministically. MaterializeFrozenLayer now takes a FrozenStamp and
  marks those children with the workflow identity in the same tx that
  creates them; ListDecomposableComposites already filters stamped goals.
  Children without a frozen sub-plan stay unstamped and planner-eligible
  (partial freeze).
- SubmitDecomposition re-reads the composite under the row lock and fails
  closed (ErrInvalidTransition) when planned_at is already set, so a late
  decomposition submit can never overwrite an installed frozen plan and
  create a second content-keyed children set.
- Input specs are validated at save time: names must match [A-Za-z0-9_-]+
  and be unique, and every {{inputs.name}} referenced by the frozen plan
  or root intent must be declared. Previously a bad name saved fine and
  failed on every instantiation.
- Caller-fixable input errors (bad spec, unknown/missing input, unresolved
  placeholder) now wrap ErrInvalidWorkflowInput and map to 400; save on a
  non-accepted goal maps to 409 instead of 500; version conflict maps to
  409. UpdateSchedulerJob maps workflow validation/not-found errors to
  400/404 (UpdateUserJob revalidates dispatch on every update, including
  enable toggles).
- Web: the lineage badge stays a run-root affordance now that nested
  frozen children also carry workflow_id.
@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Holistic self-review round → fixed in e44838a

A full multi-lens pass over the PR found one design gap the previous review rounds missed, plus two smaller issues:

F1 — nested frozen composites could be hijacked by the decomposition dispatcher. The walk materializes layer by layer in separate transactions. After a parent layer committed, a composite child carrying a frozen sub-plan sat draft / planned_at NULL / workflow_id NULL — exactly what ListDecomposableComposites scans for (2s tick). In the happy path the window is milliseconds; after a crash mid-walk it spans the whole gap until resume, making an LLM replan of a frozen node near-certain. Depending on who won, the frozen sub-plan was silently skipped and grandplans grafted onto LLM children by position, or SubmitDecomposition (which had no planned_at fence and re-read nothing under its lock) overwrote the installed plan and created a second content-keyed children set.
Fix: MaterializeFrozenLayer now stamps frozen-sub-plan children with the workflow identity in the same tx that creates them (FrozenStamp + StampGoalWorkflow), so the existing workflow_id IS NULL dispatcher filter covers them atomically; nil-plan children stay unstamped and planner-eligible (partial freeze preserved). Defense in depth: SubmitDecomposition re-reads under the row lock and fails closed on planned_at already set.

F2 — input specs were unvalidated at save. A spec name outside [A-Za-z0-9_-]+ (unreferencable by the placeholder regex) saved fine and failed on every instantiation. Save now validates name format + uniqueness, and checks every {{inputs.name}} referenced by the frozen plan or root intent against the declared specs — typos fail at save, not at run N.

F3 — error taxonomy. Caller-fixable input errors wrap ErrInvalidWorkflowInput → 400 (previously 500); save on a non-accepted goal → 409; version conflict → 409; UpdateSchedulerJob now maps workflow validation/not-found → 400/404 (it revalidates dispatch on every update, including enable toggles — only the Create handler had the mapping).

New tests: mid-walk dispatcher-exclusion (stamped frozen child excluded, dynamic sibling still eligible), late-decomposition-submit fails closed without clobbering frozen children, stamp assertions on the instantiate path, save-time spec/placeholder validation. Gates: vp check --fix && vp build, mise run format && build && test — all green.

@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Live-fire scheduler e2e — PASSED

Ran the never-exercised path end-to-end on the dev stack (real binary, real PostgreSQL, real River tick):

  1. Save over HTTP: POST /goals/{id}/save-as-workflow on a seeded accepted tree — bad input name → 400, undeclared placeholder → 400 (new save-time validation live), valid save → fully-frozen v1 workflow.
  2. Real tick fire: one-shot (at: now+25s) job with dispatch_kind=workflow fired on schedule. Workflow run went claimed→done; root goal id is the deterministic sha256 derivation; {{inputs.topic}} substituted into titles/intent; hard edge gated live (write stayed pending while collect ran); run root stamped with workflow identity, leaf children unstamped.
  3. Overlap matrix live: run-now while the previous run's root was active → job run recorded skipped: previous workflow run still active, no second tree (also confirms overlap is per-workflow, not per-job — the skip fired from a different job than the original run). After cancelling the root → next fire produced a fresh run with sched_job_run.root_goal_id attribution and a new deterministic root.
  4. Error mappings live: scheduler create with unknown workflow → 404, message on workflow job → 400; delete workflow with runs/enabled job → 409.

One finding (pre-existing interaction, not a regression): one-shot at jobs self-delete after firing (removeOneTimeJob), and sched_job_run.job_id is ON DELETE CASCADE — so the run record, including the freshly written root_goal_id attribution, is wiped moments after the fire. agent_workflow_run survives as the durable ledger (its idempotency key still encodes the deleted run id), but scheduler-side attribution for one-shot workflow jobs is lost the instant they succeed. Worth a follow-up decision: exempt one-shot jobs with runs from auto-delete, or drop the cascade in favor of SET NULL + tombstone.

All seeded/instantiated rows cleaned up after the test (trees cancelled then deleted, workflow + job + forged session removed).

Goals and workflows read as two disconnected systems: clicking a run
threw the user into the Goals tab with no way back, the runs table
reported instantiation status ("Done") while the goal tree was
cancelled, and scheduled runs flooded the Goals overview.

- Runs table: derived status from the root goal (ListWorkflowRuns joins
  agent_goal; WorkflowRun gains root_lifecycle/block_reason/done_reason),
  run # links, inputs summary, in-page pagination instead of the
  goals/all?workflow_id jump
- Goal page: run roots get a back link and lineage badge pointing to the
  workflow detail page instead of the goals list
- Goals overview: terminal workflow run roots collapse to one row per
  workflow with a run-count badge, linking to the workflow
- Schedule button on the workflow detail page creates a workflow
  scheduler job (name, cron/every/at picker, inputs, allow-replan for
  partially frozen workflows)
- Dialog fix: Form in Run/Save-as-workflow dialogs now uses
  display:contents so the footer stays inside the popup card; input
  defaults render an em dash instead of "No data"; frozen-plan edges
  render as readable sentences with node titles

Refs #594
@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

UI de-fragmentation round (a4a150e)

User-reported problem: goals and workflows read as two disconnected systems — workflow runs lived over in the Goals area, and the two surfaces disagreed about what a run's status was.

Findings from a full UI walkthrough (all verified live against seeded data):

  1. Clicking a run from the workflow detail page dropped the user into the Goals tab with a "← Goals" breadcrumb and no path back to the workflow.
  2. The runs table showed agent_workflow_run.status ("Done" = tree materialized) while the goal tree itself was Cancelled — the two surfaces contradicted each other.
  3. "View all runs" jumped to goals/all?workflow_id=…, which rendered three identical rows with no run numbers or inputs.
  4. Scheduled runs flooded the Goals overview "Recently completed" list.
  5. The Run and Save-as-workflow dialogs rendered their footer outside the popup card (missing display:contents on the Form wrapper — measured live: footer at y 467–527 vs popup bottom 468).

Fixes:

  • ListWorkflowRuns now LEFT JOINs the root goal; WorkflowRun gains nullable root_lifecycle / root_block_reason / root_done_reason. The runs table derives its status pill from the goal tree (same displayStatus mapping as the Goals UI); instantiation states only show as "Starting" / "Failed to start" before a root exists.
  • Runs table: run # links, inputs summary column, in-page "Show more" pagination. The external jump is gone.
  • Run-root goal pages: back link and lineage badge now point at the workflow detail page.
  • Goals overview: terminal workflow run roots collapse to one row per workflow with an "N runs" badge, linking to the workflow.
  • New Schedule button on the workflow detail page creates a workflow scheduler job (schedule picker + inputs + allow-replan opt-in for partially frozen workflows). Verified end-to-end: created job carried cron + inputs, then deleted.
  • Dialog footer fix (display:contents), em-dash placeholders, and frozen-plan edges rendered as readable sentences using node titles ("Write the digest runs after Collect items…" instead of "write ← collect · hard · block").

Gates: format, build, tests, vp check all green. Screenshots verified in dev for: detail page, run dialog, schedule dialog, run-root goal page backlink, overview collapse.

vaayne added 2 commits July 4, 2026 10:43
…ial tasks scope

DefaultSandboxScopes never got workflows:* when the workflows resource
landed, so every in-sandbox call to /api/workflows and
/api/goals/{id}/save-as-workflow was denied by MatchScope — the
"save this goal as a workflow" flow the skill teaches could not work
from inside a sandbox.

While in the catalog, drop the "tasks" resource: no /api/tasks route
exists, so the scope was grantable but useless. Validation only runs at
PAT-creation / OAuth-authorization time, so stored tokens carrying
tasks:* keep working (the scope simply matches nothing); an OAuth
client registered with tasks:* would fail new authorizations, but no
such client can exist meaningfully since the scope never had routes.
One noun per concept everywhere users read: Goal/目标 for tracked work,
Workflow/工作流 for a frozen reusable definition, Run/运行 for one
execution, Schedule/定时任务 for the time trigger. "Task" survives only
as internal wire values (session kind, stella task CLI alias) and the
docs slug.

- i18n: delete 64 orphaned keys (dead automations.* surface, unused
  scheduler task toasts, hub task-mixing copy); reword live keys
  (hub.kindSchedule, hub.deleteConfirm, subtasks -> child goals); drop
  the "Automations" kicker over the Goals and Workflows pages; the
  session inspector sr-only title now says Workspace, matching its
  toggle.
- API spec: Goal description no longer calls a child "a former task";
  the session kind enum documents "task" as the goal-worker wire value.
- Inbox: failed scheduler runs deep-link to the schedule detail page
  instead of the dead /agents/{id}/tasks path.
- CLI: root help says "manage goals, schedules".
- Docs: core-concepts merges the orphaned Task definition into Goal;
  scheduling docs point at the Goals tab (the tab's actual name);
  EN/ZH index and ZH titles updated.
- Skill/system prompt: "Tasks tab" -> "Goals tab";
  references/tasks.md -> references/goals.md.
@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Concept-unification round (62dde14, 6fd9f64), following the naming audit across UI / API / CLI / docs / skill:

External vocabulary is now one noun per concept: Goal (tracked work), Workflow (frozen reusable definition), Run (one execution), Schedule (time trigger). "Task" survives only as internal wire values (session kind, stella task CLI alias) and the docs slug.

  • Real bug found by the audit: DefaultSandboxScopes never got workflows:*, so in-sandbox stella workflow … and POST /goals/{id}/save-as-workflow were 403 — the exact flow the skill teaches. Fixed in 62dde14, which also retires the vestigial tasks scope (no /api/tasks route ever existed behind it).
  • 64 orphaned i18n keys deleted (dead automations.* surface, unused scheduler task toasts); "Automations" kicker removed from Goals/Workflows pages; inbox failed-run items now deep-link to the schedule detail page instead of the dead /agents/{id}/tasks path; docs/skill/system prompt all say "Goals tab" (the tab's actual name); core-concepts no longer defines an orphaned "Task" concept.

vaayne added 2 commits July 4, 2026 11:08
…al model

V's call: Goal and Workflow as sibling top-level tabs is one concept too
many. The merge is navigational, not ontological — a workflow is still a
frozen definition and a goal still an execution, but workflow surfaces
are now facets of the single Goals tab:

- The Workflows facet tab is gone; the Goals tab stays active on
  /workflows/* routes, and the old list URL redirects to the overview.
- The Goals overview gains a "Repeatable (workflows)" section (name,
  version, run count, partly-frozen badge) that renders nothing until
  the first workflow exists — a user who never saved one never meets
  the concept.
- The workflow detail page is unchanged and reachable from the section,
  run lineage badges, and schedule rows; deleting a workflow now lands
  on the goals overview instead of the removed list page.
The nouns were cleanly separated but the choice was untaught: for a
repeat request the agent had three roads (scheduler chat job, new goal,
workflow run) and no rule. Wrong picks don't error — they replan drift
or mint duplicate goals. Rule now in SKILL.md and the system prompt:
same plan with only text inputs changing -> workflow schedule; re-think
each time -> chat job; "run it again" -> workflow run, never a
duplicate goal.
@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Navigational merge (1d44e41, 0eb9b1c): Goal and Workflow as sibling top-level tabs was one concept too many (V). The merge is navigational, not ontological — the Workflows facet tab is gone; the Goals overview gains a "Repeatable (workflows)" section that renders nothing until the first workflow exists, so a user who never saved one never meets the concept. The workflow detail page is unchanged (reached via the section, run lineage badges, and schedule rows); the old list URL redirects to the goals overview. Verified live: tab bar, section rendering, detail page with Goals tab kept active.

Also taught the agent the goal-vs-workflow decision rule in SKILL.md + system prompt (same plan with only inputs changing → workflow schedule; re-think each time → chat job; "run it again" → workflow run, never a duplicate goal) — the nouns were separated but the choice was untaught, and wrong picks don't error, they drift.

Deleting a one-time job after it fires cascaded away its sched_job_run
rows (ON DELETE CASCADE), wiping the freshly written run record — and
with it the root_goal_id attribution for one-shot workflow jobs — and
breaking "run now" on the fired job.

Retire the job by marking it disabled instead: run history, workflow
run attribution, and run-now survive. A disabled past-timestamp job can
never re-fire — startup only arms enabled jobs, the River worker guards
on Enabled at fire time, and re-enabling is rejected while the
timestamp is in the past.
@vaayne

vaayne commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

One-shot CASCADE attribution fix (1caab87): fired one-time (at) jobs are now disabled instead of deleted. Deleting them cascaded away their sched_job_run rows (ON DELETE CASCADE), wiping the run record — including root_goal_id attribution for one-shot workflow jobs — moments after it was written, and breaking run-now on the fired job.

Why disable is safe against re-arming: startup only schedules Enabled jobs (and skips past-timestamp one-shots regardless), the River worker guards on Enabled at fire time, and UpdateUserJob rejects re-enabling while the timestamp is in the past. Run-now works from the in-memory job map and doesn't check Enabled, so it keeps working on retired jobs.

No schema change needed — the CASCADE FK now only fires on explicit user deletion, where dropping run history with the job is the intended semantics (agent_workflow_run remains the durable ledger). Docs updated (scheduling.md en/zh); TestOneTimeJobFiresAndRetires asserts the job survives disabled, persisted, with no live River registration.

vaayne added 2 commits July 4, 2026 11:27
Retired one-time jobs sit in the list as disabled rows with a past
timestamp, so toggling one back on is now a routine user action; it hit
UpdateUserJob's reschedule path and surfaced as a 500. Export
ErrOneTimeJobPast and map it to a 400 telling the user to set a new
time.
TestExecutorWaitsForOneShotSessionClose raced callbackCalled against
returned in one select after releasing the close: both channels can be
ready by the time the test goroutine is scheduled, and a random pick of
returned misreported correct ordering as "Execute returned before
sandbox callback" (flaked on CI under -race). The callback runs
synchronously on the chat goroutine before the event channel closes, so
it happens-before Execute returns — wait for the return, then assert
the callback fired with a non-blocking check.
@vaayne vaayne merged commit 49d5490 into main Jul 4, 2026
5 checks passed
@vaayne vaayne deleted the feat/workflow-entity branch July 4, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(workflow): workflow-as-entity — freeze, parameterize, and schedule goals as reusable workflows

1 participant