Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d6dabca
feat: establish provider-neutral workflow capabilities
luck0r Aug 28, 2026
cf1129c
chore: regenerate provider workflow API contracts
luck0r Aug 28, 2026
de891f7
feat: normalize manual workflow definitions
luck0r Aug 28, 2026
939d656
fix: reject ambiguous workflow definitions
luck0r Aug 28, 2026
8026d35
feat: implement GitHub workflow actions provider
luck0r Aug 28, 2026
2a8a076
test: cover routed GitHub workflow clients
luck0r Aug 28, 2026
17ac6a2
test: harden GitHub workflow provider coverage
luck0r Aug 28, 2026
41b84b5
feat: expose provider workflow action routes
luck0r Aug 28, 2026
e9b05ae
feat: make workflow actions an opt-in UI mode
luck0r Aug 28, 2026
03e2844
test: include actions in settings fixture
luck0r Aug 28, 2026
78385e8
fix: restore workflow capability fixture typing
luck0r Aug 28, 2026
6760c2e
feat: own workflow actions in the app runtime
luck0r Aug 28, 2026
ecb057d
fix: close workflow actions ownership races
luck0r Aug 28, 2026
0598151
feat: add typed workflow dispatch controls
luck0r Aug 28, 2026
fcdff6b
fix: harden workflow dispatch admission
luck0r Aug 28, 2026
24788d5
fix: reset workflow admission cycles
luck0r Aug 28, 2026
8f6c508
fix: retain workflow reload conflict latch
luck0r Aug 28, 2026
e3e8d93
feat: add the opt-in workflow Actions workspace
luck0r Aug 28, 2026
3af6429
fix: surface degraded Actions reads
luck0r Aug 28, 2026
eb4ba92
feat: run provider workflows from pull requests
luck0r Aug 28, 2026
43bfc12
feat: deliver manual workflow actions end to end
luck0r Aug 28, 2026
48f11c4
fix: pace workflow catalog retries
luck0r Aug 28, 2026
29acb54
fix: complete workflow Actions recovery contracts
luck0r Aug 28, 2026
ee5c91e
fix: satisfy workflow Actions frontend checks
luck0r Aug 28, 2026
a3f548b
fix: preserve workflow run ambiguity fence
luck0r Aug 28, 2026
28e2242
fix: use shared workflow input dropdowns
luck0r Aug 28, 2026
1a055ad
fix: label workflow input dropdowns
luck0r Aug 28, 2026
e470e32
test: drive workflow input dropdowns
luck0r Aug 28, 2026
f491c84
test: use local assertions in workflow Actions coverage
Aug 28, 2026
69eb4cb
perf: avoid duplicate workflow definition reads
luck0r Aug 28, 2026
8d2b205
fix: skip unavailable workflow environments
luck0r Aug 28, 2026
631ebc8
test: use scoped workflow assertions
luck0r Aug 28, 2026
1ee4b9a
fix: close workflow Actions final review gaps
luck0r Aug 28, 2026
bd2eab4
fix: retain workflow reload recovery state
luck0r Aug 28, 2026
fefc58b
fix: fence workflow catalog refresh races
luck0r Aug 28, 2026
22dda19
chore: normalize workflow Actions Go formatting
luck0r Aug 29, 2026
76c162c
fix: close final workflow Actions findings
luck0r Aug 29, 2026
9f4df8e
fix: separate workflow dispatch from pull request actions
luck0r Sep 1, 2026
c45c5b0
merge: integrate current main into workflow actions
luck0r Sep 1, 2026
3d51eb1
feat: add workflow dispatch and run inspection
mariusvniekerk Sep 3, 2026
6176671
Merge remote-tracking branch 'origin/main' into kenn-forge/issue-1003…
mariusvniekerk Sep 3, 2026
9c21349
fix: compile server tests against the shared JSON request helper
mariusvniekerk Sep 3, 2026
1fa5282
refactor: parse workflow files with actionlint instead of a custom wa…
mariusvniekerk Sep 3, 2026
d0ae4b0
refactor: move workflow dispatch follow-through to the server
mariusvniekerk Sep 4, 2026
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
395 changes: 393 additions & 2 deletions cmd/e2e-server/main.go

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions cmd/e2e-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"github.com/stretchr/testify/require"
"go.kenn.io/forge/internal/config"
"go.kenn.io/forge/internal/db"
ghclient "go.kenn.io/forge/internal/github"
"go.kenn.io/forge/internal/platform"
"go.kenn.io/forge/internal/procutil"
"go.kenn.io/forge/internal/testutil"
"go.kenn.io/forge/internal/testutil/testsignal"
Expand Down Expand Up @@ -129,6 +131,138 @@ func (tracker *testTmuxTracker) stop(key string, tmuxCommand []string) error {
tracker.mu.Unlock()
return nil
}
func TestE2EWorkflowClientExercisesProviderWorkflowContract(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
base, ok := testutil.NewFixtureClient().(*testutil.FixtureClient)
require.True(ok)
fixture := newE2EWorkflowClient(base)
registry, err := ghclient.NewProviderRegistry(map[string]ghclient.Client{
"github.com": fixture,
})
require.NoError(err)
capabilities, err := registry.Capabilities(platform.KindGitHub, "github.com")
require.NoError(err)
assert.True(capabilities.ReadLabels)
assert.True(capabilities.LabelMutation)

ref := platform.RepoRef{
Platform: platform.KindGitHub,
Host: "github.com",
Owner: "acme",
Name: "widgets",
RepoPath: "acme/widgets",
}
catalogReader, err := registry.WorkflowCatalogReader(platform.KindGitHub, "github.com")
require.NoError(err)
workflows, err := catalogReader.ListManualWorkflows(t.Context(), ref)
require.NoError(err)
require.Len(workflows, 2)
assert.Equal([]string{"Release", "Maintenance"}, []string{
workflows[0].Name,
workflows[1].Name,
})
assert.Equal([]platform.WorkflowInput{
{
Name: "version",
Description: "Version to publish",
Required: true,
Type: platform.WorkflowInputString,
},
{
Name: "dry_run",
Type: platform.WorkflowInputBoolean,
Default: false,
HasDefault: true,
},
{
Name: "channel",
Required: true,
Type: platform.WorkflowInputChoice,
Default: "stable",
HasDefault: true,
Options: []string{"stable", "beta"},
},
{
Name: "target",
Required: true,
Type: platform.WorkflowInputEnvironment,
},
}, workflows[0].Inputs)
assert.Empty(workflows[1].Inputs)
environments, err := catalogReader.ListWorkflowEnvironments(t.Context(), ref)
require.NoError(err)
assert.Equal([]platform.WorkflowEnvironment{
{Name: "staging"},
{Name: "production"},
}, environments)

runReader, err := registry.WorkflowRunReader(platform.KindGitHub, "github.com")
require.NoError(err)
page, err := runReader.ListWorkflowRuns(t.Context(), ref, platform.WorkflowRunQuery{
WorkflowID: e2eReleaseWorkflowID,
Event: "workflow_dispatch",
})
require.NoError(err)
require.Len(page.Items, 2)
assert.Equal([]string{"success", "failure"}, []string{
page.Items[0].Conclusion,
page.Items[1].Conclusion,
})

dispatcher, err := registry.WorkflowDispatcher(platform.KindGitHub, "github.com")
require.NoError(err)
submittedInputs := map[string]any{
"version": "v2.4.0",
"dry_run": true,
"channel": "beta",
"target": "production",
}
result, err := dispatcher.DispatchWorkflow(t.Context(), ref, platform.WorkflowDispatchRequest{
WorkflowID: e2eReleaseWorkflowID,
Ref: "feature/caching",
Inputs: submittedInputs,
})
require.NoError(err)
require.NotNil(result.Run)
assert.Equal(e2eDispatchedRunID, result.Run.ID)
submittedInputs["version"] = "mutated-after-dispatch"
assert.Equal([]e2eWorkflowDispatch{{
Owner: "acme",
Repository: "widgets",
WorkflowID: 8101,
Ref: "feature/caching",
Inputs: map[string]any{
"version": "v2.4.0",
"dry_run": true,
"channel": "beta",
"target": "production",
},
}}, fixture.dispatchedRequests())

page, err = runReader.ListWorkflowRuns(t.Context(), ref, platform.WorkflowRunQuery{
WorkflowID: e2eReleaseWorkflowID,
Event: "workflow_dispatch",
Branch: "feature/caching",
})
require.NoError(err)
require.Len(page.Items, 1)
assert.Equal(e2eDispatchedRunID, page.Items[0].ID)
assert.Equal("in_progress", page.Items[0].Status)
assert.Equal("fixture-viewer", page.Items[0].Actor)
assert.Equal("e2e-dispatched-head-sha", page.Items[0].HeadSHA)

jobs, err := runReader.ListWorkflowRunJobs(t.Context(), ref, e2eDispatchedRunID)
require.NoError(err)
require.Len(jobs, 1)
assert.Equal("publish-release", jobs[0].Name)
require.Len(jobs[0].Steps, 2)
assert.Equal([]string{"Prepare", "Publish"}, []string{
jobs[0].Steps[0].Name,
jobs[0].Steps[1].Name,
})
assert.Equal("in_progress", jobs[0].Steps[1].Status)
}

func TestTestTmuxTrackerRetainsFailedCleanupForRetry(t *testing.T) {
if runtime.GOOS == "windows" {
Expand Down
4 changes: 4 additions & 0 deletions context/config-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,9 @@ back to TOML.
- When zero is meaningful, represent the saved value as optional so TOML `omitempty` cannot turn explicit zero into an unset default; the round-trip test must cover zero (`internal/config/config.go::Terminal`).
- Whole-file settings mutations must hold `configReloadMu` before `cfgMu` while applying and saving changes, or the watcher can restore a stale snapshot between writes (`internal/server/settings_handlers.go::updateSettings`).
- Partial settings request objects must use pointer fields and merge only fields that were present; reusing persisted value structs collapses omission into zero values (`internal/server/settings_handlers.go::mcpSettingsUpdate`).
- `modes.actions` is false by default and controls presentation only; provider
workflow HTTP access remains available (`internal/config/config.go::ModeVisibility`).
- Disabling Actions releases demand and removes both its top-level mode and PR
workflow menu (`frontend/src/App.svelte::syncWorkflowActionsAvailability`).
- `roborev.init_managed_clones` is a hot-reloaded, false-by-default setup policy. It persists through the partial `roborev` settings object and the committed workspace API snapshot; only the effective Roborev endpoint remains in the startup-bound restart snapshot (`internal/config/config.go::Roborev`, `internal/server/config_reload.go::startupConfigSnapshot`, `internal/server/workspaceapi/config.go::ConfigSnapshot`).
- Repository preset config stores only named custom definitions; `Global` is a derived UI preset and must never be serialized to TOML. Each member persists provider, provider host, provider-verified repository ID, and a last-known display route; preset create/update/delete use dedicated atomic settings endpoints instead of replacing the collection through generic settings (`internal/config/config.go::RepoPreset`, `internal/server/settings_handlers.go::mutateRepoPresets`).
3 changes: 3 additions & 0 deletions context/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ branch. Keep the OpenAPI enum stable and regenerate API artifacts with
unverified provider failures, unreadable or oversized hub responses, and local persistence
failures after provider success return `mutationOutcomeUnknown`
(`internal/server/httpapi/problems.go::ProviderMutationProblem`, `internal/server/provider_proxy.go::providerProxy.ServeHTTP`).
- A stale manual-workflow definition is `conflict` with
`details.reason = "workflow_definition_changed"` and expected/live SHAs, so
clients can require a catalog reload (`internal/server/workflowapi/routes.go::Handler.dispatch`).

## Server Construction

Expand Down
20 changes: 20 additions & 0 deletions context/frontend-effect.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ service is the supported tool here.
- Use scoped acquisition and finalizers for listeners, streams, readers,
abort controllers, timers, presenters, and workflow owners. Teardown must be
explicit at the same lifetime boundary that acquired the resource.
- Publish an owner registry entry and install its finalizer in one
uninterruptible acquisition handoff. After an ordered queue admits a
non-idempotent command, pending-state publication and executor release are
likewise one uninterruptible handoff.
- Workflow Actions is a plain app-scoped store that reads on demand and applies
server events; it owns no polling loops, queues, or reconciliation. Dispatch
follow-through (locating the run, watching it finish) lives on the server and
arrives as `workflow_dispatch_progress` events keyed by `dispatch_id`
(`frontend/src/lib/stores/workflow-actions.svelte.ts::applyDispatchProgress`,
`internal/server/workflowapi/dispatch_follow.go::Handler.followDispatch`).
- Reads are latest-wins per repository through generation counters; a stale catalog
or run response never replaces newer data
(`frontend/src/lib/stores/workflow-actions.svelte.ts::selectWorkflow`).
- One dispatch cycle exists per workflow. Success, rejection, uncertainty, and
definition conflict leave presentation only through the explicit new-cycle
command; retry returns to fresh confirmation and never replays the POST
(`frontend/src/lib/stores/workflow-actions.svelte.ts::newDispatchCycle`).
- Definition-reload failures are cycle state separate from general read errors;
a successful reload clears that workflow's cycle
(`frontend/src/lib/stores/workflow-actions.svelte.ts::refreshCatalog`).
- Use Effect concurrency, queues, fibers, schedules, and interruption instead
of bespoke Promise generations, overlapping timers, or boolean race guards.
Preserve latest-wins, single-flight, ordered, or lossless semantics explicitly;
Expand Down
3 changes: 3 additions & 0 deletions context/platform-sync-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ combining repository-owned history
exact ownership generation before snapshot, notification, or cache commits;
same-identity freshness observations do not advance that generation
(`internal/db/repository_catalog.go::RepositoryRouteFence`).
- Workflow reads and dispatch fence route ownership after live provider work;
dispatch also re-reads definition state before its single write, so stale UI
authority cannot cross revisions (`internal/server/workflowapi/routes.go::Handler.dispatch`).
- Hub descriptors are provider observations and use the same
reconciliation path as sync, enrollment, and project discovery. Spokes must
preserve stable provider identity and A-to-B-to-A route generations; they may
Expand Down
16 changes: 16 additions & 0 deletions context/provider-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ Rules:
`internal/gitclone/clone.go::Manager.RequireCredentialRoute`).
- Handlers must check capabilities before performing mutations. A missing
capability is a feature-level failure, not a whole-provider failure.
- Keep workflow approval and workflow dispatch separate: approval advances an
existing run, while dispatch starts a new one; neither contract may assume
GitHub Actions semantics (`internal/platform/client.go::WorkflowDispatcher`).
- Workflow contracts remain optional and provider-neutral. GitHub is the sole
current implementation; other providers must not advertise capabilities
before implementing the interfaces (`internal/github/sync.go::gitHubClientProvider.Capabilities`).
- Treat workflow dispatch as live-state mutation: re-read definitions/environments,
validate SHA and typed inputs, then gate one provider call under a stable route fence;
never retry uncertain writes or persist definitions (`internal/server/workflowapi/routes.go::Handler.dispatch`).
- Workflow catalog partial availability is definition-specific: missing or undecodable files
become unavailable rows, while cancellation, auth, server, transport, and rate failures abort
the catalog (`internal/github/sync.go::workflowDefinitionReadMustAbort`).
- actionlint owns the GitHub workflow file grammar; Forge only projects its dispatch inputs
and types defaults. Do not hand-parse workflow YAML. The module is pinned to the fork
behind rhysd/actionlint#730 until a release compiles against yaml/v4 rc.6
(`internal/platform/github/workflow_definition.go::ParseManualWorkflow`, `go.mod`).
- Provider-backed comment deletes remove synchronized local rows only after upstream
synchronization observes provider absence. DELETE itself changes no SQLite comment
state; the UI hides a confirmed deletion while ordinary sync converges. Authoritative
Expand Down
6 changes: 6 additions & 0 deletions context/retries-and-backoffs.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ The archive worker uses the backoff schedule type only as an idle delay calculat
not as a retry wrapper: idle passes double up to a five-minute cap and any wake or
worked pass resets it (`internal/github/sync.go::runArchiveLoop`).

- Manual workflow dispatch is non-idempotent and never retried by either side.
The server locates the created run by reading runs for about a minute, then
watches that run at a fixed interval until it completes or thirty minutes pass;
the browser never polls workflow state
(`internal/server/workflowapi/dispatch_follow.go::Handler.followDispatch`).

## Long-lived stream recovery

Hub event-stream recovery is connection lifecycle policy, not a retry
Expand Down
5 changes: 5 additions & 0 deletions context/server-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ and the root event stream.

## Event Replay

- Workflow dispatch follow-through is server-owned background work: after a
provider accepts a dispatch, a tracked goroutine locates the run and watches it
to completion, publishing `workflow_dispatch_progress` events keyed by the
response's `dispatch_id`. Clients never poll workflow runs
(`internal/server/workflowapi/dispatch_follow.go::Handler.followDispatch`).
- SSE event IDs are process-scoped replay cursors, not durable sequence
numbers. Reconnects may replay only IDs retained by the current process's
ring (`internal/server/event_hub.go::EventHub.ReplaySnapshotSince`).
Expand Down
3 changes: 3 additions & 0 deletions context/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ cleanup targets the server instead of a `go run` wrapper. An explicit binary rem
externally owned
and must not be rebuilt or removed (`frontend/tests/e2e-full/support/e2eServer.ts::ensureE2EServerBinary`).

- E2E provider decorators must embed concrete fixtures rather than base interfaces;
narrowing erases optional capability interfaces (`cmd/e2e-server/main_test.go::TestE2EWorkflowClientExercisesProviderWorkflowContract`).

- Full-stack Playwright workers must publish child ownership in the shared tmux root;
the root removes it only after every published child exits (`frontend/tests/e2e-full/support/e2eServer.ts::waitForSharedServerOwners`).
- Give independent full-stack boundary scenarios separate Playwright tests; a loop shares
Expand Down
3 changes: 3 additions & 0 deletions context/ui-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ otherwise fails only in the Vitest/Playwright transform tier, not in
wrapped instead. Every stage must expose the same accessible names
(`frontend/src/lib/components/roborev/ReviewDrawer.svelte::.footer-actions-fit`,
`frontend/src/lib/components/detail/PullDetail.svelte::measuredPrimaryActions`).
- Pull-request lifecycle decisions stay in the primary action row; workspace
creation and workflow dispatch form a utility row, joining the measured
`Actions` overflow only under pressure (`frontend/src/lib/components/detail/PullDetail.svelte::workflowActionsMenu`).
- Flash: one shared store (`frontend/src/lib/stores/flash.svelte.ts`); kit `FlashBanner`
mounts once per shell in a page-level fixed layer below measured shell chrome
and above modal backdrops, never inside feature containers; headerless shells
Expand Down
21 changes: 21 additions & 0 deletions context/ui-interaction-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,24 @@ Persisted controls must state their scope clearly.
- A foreground Activity load replaces a same-scope author read owned by supersedable
reconciliation; joining that read can let its interruption strand stale candidates
(`frontend/src/lib/stores/activity.svelte.ts::loadActivity`).
- Actions is standalone-only and stays inert until settings hydrate; disabling it releases
workflow demand before replacing `/actions` with Activity (`frontend/src/App.svelte::syncWorkflowActionsAvailability`).
- Actions derives provider identity from globally filtered repository summaries and requires
workflow catalog, run, and dispatch capabilities; unsupported repos stay visible without reads
(`frontend/src/lib/components/actions/ActionsPage.svelte::supportsWorkflowActions`).
- Actions snapshot read failures never collapse into successful empty/current data: empty
failures replace the empty state, while retained runs/jobs remain with a visible stale-data alert
(`frontend/src/lib/components/actions/ActionsPage.svelte::workflowReadErrorMessage`).
- Run reads belong to the selected workflow: selection replaces the prior run
projection and every generated request carries that workflow ID
(`frontend/src/lib/stores/workflow-actions.svelte.ts::selectWorkflow`).
- A dispatched run appears in the list from the dispatch response or the first
`workflow_dispatch_progress` event, and later events update it in place; the
list is otherwise refreshed only by user action
(`frontend/src/lib/stores/workflow-actions.svelte.ts::applyDispatchProgress`).
- PR Actions defaults open same-repository pulls to the head branch, but forks
and non-open states to the target; workflows remain on merged pulls
(`frontend/src/lib/components/detail/PullDetail.svelte::workflowInitialRef`).
- Server-backed settings belong in the API only when the preference should
follow the user/config rather than one browser session.
- Settings controls persist on change. Do not add a Save button, a dirty draft,
Expand Down Expand Up @@ -870,6 +888,9 @@ Rows that contain buttons, links, or toggles need clear event ownership.
- Catalog reads use consumer-local owners: picker teardown or route replacement may cancel only that
consumer, never review-run state or sibling repository resolution
(`frontend/src/lib/components/roborev/RepoTreePicker.svelte::owner`).
- Workflow Actions seeds top-level refs from repository authority and reads jobs lazily once per
expanded run; collapsing a run keeps its jobs and never refetches
(`frontend/src/lib/components/actions/ActionsPage.svelte::expandRun`).
- Docs publish commands snapshot folder and message and remain application-owned after replacement;
same-folder surfaces adopt pending or unacknowledged failure state, while completed success is never
replayed into a later session (`frontend/src/lib/stores/docs-workflow.ts::DocsWorkflowService`).
Expand Down
8 changes: 6 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,18 @@ These values set the initial Activity view and local default-branch retention.
activity = true
repos = true
docs = false
actions = false
pulls = true
issues = true
reviews = true
workspaces = true
```

Set a mode to `false` to hide it. Docs starts hidden because it needs configured
local folders. Kata integration is contextual rather than a top-level mode.
Actions and Docs default to `false`; enable them after their provider workflows
or local folders are ready. Disabling Actions removes both its top-level page
and pull-request menu while leaving provider workflow access available to API
clients. Kata integration is contextual rather than a top-level mode. Set any
other mode to `false` to hide it.

## Roborev

Expand Down
11 changes: 11 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ until earlier members land.
The conversation, files, and workspace can share a pane layout. Reorder,
split, resize, hide, or maximize panes as the task changes.

## Run manual provider workflows

Actions is opt-in. Open **Settings → Visible modes**, enable **Actions**, and
save. In **Actions**, choose a repository and a manual workflow, select a Git
ref, complete its typed inputs, and run it.

Recent runs show their status, jobs, and steps. Follow the provider link on a
run to inspect its full logs. Pull requests also offer available workflows in
the **Actions** menu: open same-repository pull requests start from the head
branch, while fork and merged pull requests start from the target branch.

## Track local pull-request state

Set a workflow status from the pull-request detail and filter the list by one
Expand Down
Loading
Loading