Skip to content

Commit 3ef68ef

Browse files
authored
Merge pull request #29 from xraph/dashboard-contract-slice-a
Dashboard contract slice a
2 parents 1b85cc9 + 2b49ce7 commit 3ef68ef

257 files changed

Lines changed: 33584 additions & 15361 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 0 additions & 49 deletions
This file was deleted.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ METRICS_DEADLOCK_FIX.md
122122
!README.md
123123
**/*.md
124124
!**/README.md
125+
!extensions/dashboard/contract/*.md
126+
!extensions/dashboard/contract/shell/*.md
127+
!extensions/dashboard/contract/shell/test/
128+
!extensions/dashboard/contract/shell/test/**
125129
/**/*.disabled
126130
extensions/dashboard/forgeui
127131
*.blob
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// main.go
2+
package main
3+
4+
import (
5+
"bytes"
6+
"encoding/json"
7+
"flag"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
13+
"github.com/xraph/forge/extensions/dashboard/contract"
14+
)
15+
16+
func main() {
17+
base := flag.String("base", "http://localhost:8080", "dashboard base URL (no trailing slash)")
18+
kind := flag.String("kind", "query", "graph | query | command")
19+
contributor := flag.String("contributor", "", "contributor name")
20+
intent := flag.String("intent", "", "intent name")
21+
payload := flag.String("payload", "{}", "JSON payload")
22+
csrf := flag.String("csrf", "", "CSRF token (required for command)")
23+
idem := flag.String("idem", "", "idempotency key (required for command)")
24+
flag.Parse()
25+
26+
req := contract.Request{
27+
Envelope: "v1", Kind: contract.Kind(*kind),
28+
Contributor: *contributor, Intent: *intent,
29+
Payload: json.RawMessage(*payload),
30+
CSRF: *csrf, IdempotencyKey: *idem,
31+
}
32+
body, _ := json.Marshal(req)
33+
resp, err := http.Post(*base+"/api/dashboard/v1", "application/json", bytes.NewReader(body))
34+
if err != nil {
35+
fmt.Fprintln(os.Stderr, "request:", err)
36+
os.Exit(1)
37+
}
38+
defer resp.Body.Close()
39+
out, _ := io.ReadAll(resp.Body)
40+
fmt.Printf("HTTP %d\n%s\n", resp.StatusCode, out)
41+
}

cmd/forge/plugins/dev.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os/exec"
1010
"os/signal"
1111
"path/filepath"
12+
"runtime"
1213
"strconv"
1314
"strings"
1415
"sync"
@@ -641,6 +642,35 @@ func newAppWatcher(cfg *config.ForgeConfig, app *AppInfo) (*appWatcher, error) {
641642
// have been extracted to dev_shared.go as package-level functions shared with
642643
// dockerAppWatcher.
643644

645+
// buildApp compiles the user's main package into a stable per-app path inside
646+
// the project's .forge/dev directory. We rebuild in place each time so the
647+
// path stays predictable (helpful for debuggers and codesigning) and old
648+
// artifacts don't accumulate in the OS temp dir.
649+
func (aw *appWatcher) buildApp() (string, error) {
650+
buildDir := filepath.Join(aw.config.RootDir, ".forge", "dev")
651+
if err := os.MkdirAll(buildDir, 0o755); err != nil {
652+
return "", fmt.Errorf("create build dir: %w", err)
653+
}
654+
655+
binName := aw.app.Name
656+
if binName == "" {
657+
binName = "app"
658+
}
659+
if runtime.GOOS == "windows" {
660+
binName += ".exe"
661+
}
662+
binPath := filepath.Join(buildDir, binName)
663+
664+
cmd := exec.Command("go", "build", "-tags", "forge_debug", "-o", binPath, aw.mainFile)
665+
cmd.Dir = aw.config.RootDir
666+
cmd.Stdout = os.Stdout
667+
cmd.Stderr = os.Stderr
668+
if err := cmd.Run(); err != nil {
669+
return "", fmt.Errorf("go build: %w", err)
670+
}
671+
return binPath, nil
672+
}
673+
644674
// Start starts the application process.
645675
func (aw *appWatcher) Start(ctx cli.CommandContext) error {
646676
aw.mu.Lock()
@@ -671,9 +701,19 @@ func (aw *appWatcher) Start(ctx cli.CommandContext) error {
671701

672702
ctx.Success(fmt.Sprintf("Starting %s...", aw.app.Name))
673703

674-
// Create new command — compile with forge_debug tag so the in-process
675-
// debug server is included; disabled automatically in release builds.
676-
cmd := exec.Command("go", "run", "-tags", "forge_debug", aw.mainFile)
704+
// Build the binary first, then exec it directly. We deliberately avoid
705+
// `go run` here: it spawns the compiled binary as a grandchild, and on
706+
// abrupt shutdown (kill -9 of forge dev, parent shell exit) those
707+
// grandchildren get reparented to PID 1 and survive with their dispatch
708+
// loops still hammering the database. Building once and exec'ing the
709+
// binary directly means there's exactly one process to track and
710+
// killProcessGroup reliably reaches it.
711+
binPath, err := aw.buildApp()
712+
if err != nil {
713+
return fmt.Errorf("build failed: %w", err)
714+
}
715+
716+
cmd := exec.Command(binPath)
677717
cmd.Dir = aw.config.RootDir
678718
cmd.Stdout = os.Stdout
679719
cmd.Stderr = os.Stderr
@@ -1000,11 +1040,15 @@ func (p *DevPlugin) startContributorDevServers(ctx cli.CommandContext) []*contri
10001040
devCmd = adapter.DefaultDevCmd()
10011041
}
10021042

1003-
// Start the dev server
1043+
// Start the dev server. Put the shell in its own process group so
1044+
// killProcessGroup can take down the whole tree (npm/node/vite/...)
1045+
// instead of just the sh wrapper, which would otherwise leave the
1046+
// real dev server orphaned on rebuild.
10041047
cmd := exec.Command("sh", "-c", devCmd)
10051048
cmd.Dir = uiDir
10061049
cmd.Stdout = os.Stdout
10071050
cmd.Stderr = os.Stderr
1051+
setupProcessGroup(cmd)
10081052

10091053
if err := cmd.Start(); err != nil {
10101054
ctx.Warning(fmt.Sprintf("Failed to start contributor dev server %s: %v", cfg.Name, err))
@@ -1024,9 +1068,13 @@ func (p *DevPlugin) startContributorDevServers(ctx cli.CommandContext) []*contri
10241068
// stopContributorDevServers stops all running contributor dev server processes.
10251069
func stopContributorDevServers(processes []*contributorDevProcess) {
10261070
for _, proc := range processes {
1027-
if proc.Cmd != nil && proc.Cmd.Process != nil {
1028-
proc.Cmd.Process.Kill()
1029-
proc.Cmd.Process.Wait()
1071+
if proc.Cmd == nil || proc.Cmd.Process == nil {
1072+
continue
10301073
}
1074+
// killProcessGroup tears down the whole sh→npm→node tree. A bare
1075+
// Process.Kill would only stop the sh wrapper, leaving npm/node
1076+
// running and holding the dev port.
1077+
killProcessGroup(proc.Cmd)
1078+
_, _ = proc.Cmd.Process.Wait()
10311079
}
10321080
}

examples/webtransport/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ require (
7272
github.com/xraph/forgeui v1.4.1 // indirect
7373
github.com/xraph/go-utils v1.1.1 // indirect
7474
github.com/xraph/vessel v1.0.2 // indirect
75+
go.opentelemetry.io/otel v1.40.0 // indirect
76+
go.opentelemetry.io/otel/trace v1.40.0 // indirect
7577
go.uber.org/multierr v1.11.0 // indirect
7678
go.uber.org/zap v1.27.1 // indirect
7779
go.yaml.in/yaml/v2 v2.4.3 // indirect

examples/webtransport/go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9
5757
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
5858
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
5959
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
60+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
61+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
6062
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
6163
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
6264
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
@@ -282,6 +284,16 @@ github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g=
282284
github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4=
283285
github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo=
284286
github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs=
287+
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
288+
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
289+
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
290+
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
291+
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
292+
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
293+
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
294+
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
295+
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
296+
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
285297
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
286298
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
287299
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=

extensions/dashboard/auth/context.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package dashauth
22

3-
import "context"
3+
import (
4+
"context"
5+
"net/http"
6+
)
47

58
type contextKey string
69

@@ -47,3 +50,37 @@ func HasTenantInContext(ctx context.Context) bool {
4750

4851
return t.HasTenant()
4952
}
53+
54+
const (
55+
respWriterContextKey contextKey = "forge:dashboard:resp"
56+
requestContextKey contextKey = "forge:dashboard:req"
57+
)
58+
59+
// WithHTTP stashes the live ResponseWriter and Request on ctx so contract
60+
// command handlers that legitimately need to touch HTTP — e.g. the auth
61+
// extension's auth.login handler that issues a Set-Cookie — can reach them.
62+
// The contract transport calls this before dispatching commands.
63+
//
64+
// Most contract handlers are pure data and should NOT pull these out;
65+
// reaching for the response writer is an escape hatch for the small set of
66+
// concerns where the cookie/header IS the contract (auth, downloads).
67+
func WithHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context {
68+
ctx = context.WithValue(ctx, respWriterContextKey, w)
69+
ctx = context.WithValue(ctx, requestContextKey, r)
70+
return ctx
71+
}
72+
73+
// ResponseWriterFromContext returns the ResponseWriter previously stashed by
74+
// WithHTTP, or nil when called outside an HTTP-served dispatch (background
75+
// jobs, tests).
76+
func ResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
77+
w, _ := ctx.Value(respWriterContextKey).(http.ResponseWriter)
78+
return w
79+
}
80+
81+
// RequestFromContext returns the Request previously stashed by WithHTTP, or
82+
// nil outside an HTTP-served dispatch.
83+
func RequestFromContext(ctx context.Context) *http.Request {
84+
r, _ := ctx.Value(requestContextKey).(*http.Request)
85+
return r
86+
}

extensions/dashboard/aware.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package dashboard
22

33
import (
4+
"github.com/xraph/forge/extensions/dashboard/contract"
5+
"github.com/xraph/forge/extensions/dashboard/contract/dispatcher"
46
"github.com/xraph/forge/extensions/dashboard/contributor"
57
"github.com/xraph/forge/extensions/dashboard/ui/shell"
68
"github.com/xraph/forgeui/bridge"
@@ -52,6 +54,43 @@ type BridgeAware interface {
5254
// ext.SetAuthChecker(myAuthChecker)
5355
// ext.EnableAuth()
5456
// }
57+
//
58+
// Slice (l) note — wiring an auth extension into the contract React shell:
59+
//
60+
// The dashboard shell ships a built-in LoginScreen that submits a contract
61+
// command (default `auth.login`) and reloads the principal on success. Auth
62+
// extensions can plug in by implementing both DashboardAuthAware *and*
63+
// ContractContributorAware:
64+
//
65+
// - DashboardAuthAware.RegisterDashboardAuth wires the AuthChecker so
66+
// /api/dashboard/v1/principal returns the current user. The shell's
67+
// AuthGate listens for the 401 envelope (auth required) vs the 200
68+
// `{authenticated:false}` envelope (auth disabled) and renders the
69+
// LoginScreen only in the former case.
70+
// - ContractContributorAware.RegisterContractContributor registers the
71+
// `auth.login` command intent (and optionally `auth.logout`) on the
72+
// dispatcher. The built-in LoginScreen issues the command; an
73+
// extension that wants a richer flow can also publish a `/login`
74+
// graph route in its manifest, and the shell's AuthGate will render
75+
// that page instead of the built-in form.
76+
//
77+
// Example combined integration sketch:
78+
//
79+
// func (a *AuthsomeExtension) RegisterDashboardAuth(ext *dashboard.Extension) {
80+
// ext.SetAuthChecker(a.checker)
81+
// ext.EnableAuth()
82+
// }
83+
// func (a *AuthsomeExtension) RegisterContractContributor(
84+
// disp *dispatcher.Dispatcher,
85+
// reg contract.Registry,
86+
// wreg contract.WardenRegistry,
87+
// ) error {
88+
// return authsomecontract.Register(disp, reg, wreg, authsomecontract.Deps{
89+
// Sessions: a.sessions,
90+
// // Registers `auth.login` (command) and optionally a `/login`
91+
// // graph route that overrides the built-in LoginScreen.
92+
// })
93+
// }
5594
type DashboardAuthAware interface {
5695
RegisterDashboardAuth(ext *Extension)
5796
}
@@ -72,3 +111,35 @@ type DashboardAuthAware interface {
72111
type DashboardFooterContributor interface {
73112
DashboardUserDropdownActions(basePath string) []shell.UserDropdownAction
74113
}
114+
115+
// ContractContributorAware is an optional interface that Forge extensions can
116+
// implement to register a contract-based dashboard contributor (the slice (f)+
117+
// shape — declarative YAML manifest + typed dispatcher handlers). The
118+
// dashboard auto-discovers extensions implementing this interface during
119+
// Start() and calls RegisterContractContributor with the dashboard's contract
120+
// registry, warden registry, and dispatcher.
121+
//
122+
// Coexists with DashboardAware: an extension can implement both to register
123+
// its legacy templ-based contributor AND its contract contributor during the
124+
// migration window. New extensions should prefer this interface; legacy ones
125+
// migrate over time per the per-slice (f, g, h) plan.
126+
//
127+
// Example implementation:
128+
//
129+
// func (e *StreamingExtension) RegisterContractContributor(
130+
// disp *dispatcher.Dispatcher,
131+
// reg contract.Registry,
132+
// wreg contract.WardenRegistry,
133+
// ) error {
134+
// return streamingcontract.Register(disp, reg, wreg, streamingcontract.Deps{
135+
// Manager: func() streaming.Manager { return e.manager },
136+
// Config: func() streaming.Config { return e.config },
137+
// })
138+
// }
139+
type ContractContributorAware interface {
140+
RegisterContractContributor(
141+
disp *dispatcher.Dispatcher,
142+
reg contract.Registry,
143+
wreg contract.WardenRegistry,
144+
) error
145+
}

0 commit comments

Comments
 (0)