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: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ verification*/
vendor/
cover*.out
/*.png
opencode-src/
opencode-src/
.playwright-mcp/
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ In terms of layout, UI, style, when something doesn't fit a container, use ellip

CRITICAL: use playwright screenshots (and the skill to operate playwright) to verify the application is working correctly.

CRITICAL: every Playwright artifact save (`playwright_browser_take_screenshot`, `playwright_browser_snapshot`, console logs, network logs, or similar) must use a repo-relative filename under the current retrospective session's `.sgai/retrospectives/<session-id>/screenshots/` directory resolved from `.sgai/PROJECT_MANAGEMENT.md` frontmatter.

CRITICAL: bare Playwright filenames like `foo.png`, `foo.md`, or `foo.txt` are forbidden because they save into the repository root and pollute the workspace.

For React/TypeScript code in cmd/sgai/webapp/, use bun for building, testing, and running scripts. Build command: `bun run build`. Dev server: `bun run dev.ts`. Tests: `bun test`.

React components must use shadcn/ui components where possible. Do not create custom implementations when a shadcn component exists. Reference: https://ui.shadcn.com/docs
Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
build: webapp-build lint
go build -o ./bin/sgai ./cmd/sgai

webapp-build:
cd cmd/sgai/webapp && bun install && bun run build.ts
webapp-doctor:
npx -y react-doctor@latest cmd/sgai/webapp --no-lint --verbose --fail-on warning

webapp-test:
webapp-build:
cd cmd/sgai/webapp && bun install && bun run build.ts

webapp-test: webapp-doctor
cd cmd/sgai/webapp && bun install && bun test

test: webapp-test webapp-build
Expand Down
27 changes: 0 additions & 27 deletions browser-verification-baseline.md

This file was deleted.

54 changes: 27 additions & 27 deletions cmd/sgai/delete_detach_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/require"
)

func TestHandleAPIStateIncludesRepositoryActionPolicy(t *testing.T) {
func TestHandleAPIWorkspaceListIncludesRepositoryActionPolicy(t *testing.T) {
server, _ := setupTestServer(t)
baseDir := t.TempDir()

Expand All @@ -31,10 +31,10 @@ func TestHandleAPIStateIncludesRepositoryActionPolicy(t *testing.T) {
rootDir, forkDir := setupNamedAttachedJJRootAndFork(t, server, "root-ws", "fork-ws")
server.invalidateWorkspaceScanCache()

response := serveHTTP(server, http.MethodGet, "/api/v1/state", "")
response := serveHTTP(server, http.MethodGet, "/api/v1/workspaces", "")
require.Equal(t, http.StatusOK, response.Code)

workspaces := decodeWorkspaceStateByName(t, response.Body.Bytes())
workspaces := decodeWorkspaceListByName(t, response.Body.Bytes())

standalone := workspaces[filepath.Base(standaloneDir)]
assert.True(t, standalone.IsExternal)
Expand Down Expand Up @@ -79,18 +79,18 @@ func TestHandleAPIStateIncludesRepositoryActionPolicy(t *testing.T) {
})
}

func TestHandleAPIStateIncludesRepositoryActionPresentationMetadata(t *testing.T) {
func TestHandleAPIWorkspaceListIncludesRepositoryActionPresentationMetadata(t *testing.T) {
server, _ := setupTestServer(t)
baseDir := t.TempDir()

standaloneDir := filepath.Join(baseDir, "standalone-ws")
attachWorkspaceFixture(t, server, standaloneDir, workspaceStandalone)
_, forkDir := setupNamedAttachedJJRootAndFork(t, server, "root-ws", "fork-ws")

response := serveHTTP(server, http.MethodGet, "/api/v1/state", "")
response := serveHTTP(server, http.MethodGet, "/api/v1/workspaces", "")
require.Equal(t, http.StatusOK, response.Code)

workspaces := decodeWorkspaceStateByName(t, response.Body.Bytes())
workspaces := decodeWorkspaceListByName(t, response.Body.Bytes())

standalonePresentation := workspaces[filepath.Base(standaloneDir)].RepositoryAction.Presentation
assert.Equal(t, "Detach", standalonePresentation.DetailTriggerLabel)
Expand Down Expand Up @@ -130,7 +130,7 @@ func TestHandleAPIStateIncludesRepositoryActionPresentationMetadata(t *testing.T
assert.Equal(t, "destructive", forkDeleteOperation.Tone)
}

func TestHandleAPIStateClassifiesZeroChildRootFromJJMetadata(t *testing.T) {
func TestHandleAPIWorkspaceListClassifiesZeroChildRootFromJJMetadata(t *testing.T) {
server, _ := setupTestServer(t)
rootDir, forkDir := setupAttachedJJRootAndFork(t, server)
runJJForTest(t, rootDir, "workspace", "forget", filepath.Base(forkDir))
Expand All @@ -141,10 +141,10 @@ func TestHandleAPIStateClassifiesZeroChildRootFromJJMetadata(t *testing.T) {
freshServer.externalDirs[resolveSymlinks(rootDir)] = true
freshServer.mu.Unlock()

response := serveHTTP(freshServer, http.MethodGet, "/api/v1/state", "")
response := serveHTTP(freshServer, http.MethodGet, "/api/v1/workspaces", "")
require.Equal(t, http.StatusOK, response.Code)

workspaces := decodeWorkspaceStateByName(t, response.Body.Bytes())
workspaces := decodeWorkspaceListByName(t, response.Body.Bytes())
root := workspaces[filepath.Base(rootDir)]
assert.False(t, root.IsRoot)
assert.Empty(t, root.Forks)
Expand All @@ -158,7 +158,7 @@ func TestHandleAPIStateClassifiesZeroChildRootFromJJMetadata(t *testing.T) {
})
}

func TestHandleAPIStateHidesRootActionWhenWorkspaceTopologyUnavailable(t *testing.T) {
func TestHandleAPIWorkspaceListHidesRootActionWhenWorkspaceTopologyUnavailable(t *testing.T) {
server, _ := setupTestServer(t)
rootDir, forkDir := setupAttachedJJRootAndFork(t, server)
server.mu.Lock()
Expand All @@ -167,10 +167,10 @@ func TestHandleAPIStateHidesRootActionWhenWorkspaceTopologyUnavailable(t *testin
t.Setenv("PATH", t.TempDir())
server.invalidateWorkspaceScanCache()

response := serveHTTP(server, http.MethodGet, "/api/v1/state", "")
response := serveHTTP(server, http.MethodGet, "/api/v1/workspaces", "")
require.Equal(t, http.StatusOK, response.Code)

workspaces := decodeWorkspaceStateByName(t, response.Body.Bytes())
workspaces := decodeWorkspaceListByName(t, response.Body.Bytes())
root := workspaces[filepath.Base(rootDir)]
assert.True(t, root.IsRoot)
assertRepositoryAction(t, &root.RepositoryAction, &repositoryActionExpectation{
Expand Down Expand Up @@ -368,9 +368,9 @@ func TestHandleAPIDeleteForkKeepsFactoryStateHealthy(t *testing.T) {
assert.False(t, stillAttached)
assert.False(t, stillPinned)

stateResponse := serveHTTP(server, http.MethodGet, "/api/v1/state", "")
stateResponse := serveHTTP(server, http.MethodGet, "/api/v1/workspaces", "")
require.Equal(t, http.StatusOK, stateResponse.Code)
workspaces := decodeWorkspaceStateByName(t, stateResponse.Body.Bytes())
workspaces := decodeWorkspaceListByName(t, stateResponse.Body.Bytes())
root := workspaces[filepath.Base(rootDir)]
require.NotNil(t, root)
_, forkPresent := workspaces[filepath.Base(forkDir)]
Expand Down Expand Up @@ -449,9 +449,9 @@ func TestHandleAPIDeleteWorkspaceForkDetachOperationKeepsFiles(t *testing.T) {
assert.False(t, stillAttached)
assert.False(t, stillPinned)

stateResponse := serveHTTP(server, http.MethodGet, "/api/v1/state", "")
stateResponse := serveHTTP(server, http.MethodGet, "/api/v1/workspaces", "")
require.Equal(t, http.StatusOK, stateResponse.Code)
workspaces := decodeWorkspaceStateByName(t, stateResponse.Body.Bytes())
workspaces := decodeWorkspaceListByName(t, stateResponse.Body.Bytes())
require.NotNil(t, workspaces[filepath.Base(rootDir)])
_, forkPresent := workspaces[filepath.Base(forkDir)]
assert.False(t, forkPresent)
Expand Down Expand Up @@ -491,7 +491,7 @@ func TestHandleAPIDeleteWorkspaceRollsBackWhenPinnedPersistenceFails(t *testing.
assert.True(t, pathListContains(readJSONPathList(t, filepath.Join(server.externalConfigDir, "external.json")), workspaceDir))
}

func TestBuildFullFactoryStatePrunesMissingAttachedWorkspace(t *testing.T) {
func TestBuildWorkspaceListResponsePrunesMissingAttachedWorkspace(t *testing.T) {
server, _ := setupTestServer(t)
server.externalConfigDir = t.TempDir()
server.pinnedConfigDir = t.TempDir()
Expand All @@ -508,7 +508,7 @@ func TestBuildFullFactoryStatePrunesMissingAttachedWorkspace(t *testing.T) {
require.NoError(t, server.savePinnedProjects())
server.invalidateWorkspaceScanCache()

state := server.buildFullFactoryState()
state := server.buildWorkspaceListResponse()
require.Len(t, state.Workspaces, 1)
assert.Equal(t, filepath.Base(validDir), state.Workspaces[0].Name)

Expand All @@ -522,7 +522,7 @@ func TestBuildFullFactoryStatePrunesMissingAttachedWorkspace(t *testing.T) {
assert.NotContains(t, readJSONPathList(t, filepath.Join(server.pinnedConfigDir, "pinned.json")), missingCanonical)
}

func TestBuildFullFactoryStateDoesNotEmitPhantomRootAfterPruningMissingAttachedRoot(t *testing.T) {
func TestBuildWorkspaceListResponseDoesNotEmitPhantomRootAfterPruningMissingAttachedRoot(t *testing.T) {
server, _ := setupTestServer(t)
server.externalConfigDir = t.TempDir()
server.pinnedConfigDir = t.TempDir()
Expand Down Expand Up @@ -554,7 +554,7 @@ func TestBuildFullFactoryStateDoesNotEmitPhantomRootAfterPruningMissingAttachedR
require.NoError(t, server.savePinnedProjects())
server.invalidateWorkspaceScanCache()

state := server.buildFullFactoryState()
state := server.buildWorkspaceListResponse()
require.Len(t, state.Workspaces, 1)
assert.Equal(t, filepath.Base(forkDir), state.Workspaces[0].Name)
assert.Equal(t, forkCanonical, state.Workspaces[0].Dir)
Expand All @@ -581,7 +581,7 @@ func TestBuildFullFactoryStateDoesNotEmitPhantomRootAfterPruningMissingAttachedR
assert.NotContains(t, readJSONPathList(t, filepath.Join(server.pinnedConfigDir, "pinned.json")), missingRootCanonical)
}

func TestBuildFullFactoryStateKeepsUnreadableAttachedWorkspaceState(t *testing.T) {
func TestBuildWorkspaceListResponseKeepsUnreadableAttachedWorkspaceState(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping permission test as root")
}
Expand Down Expand Up @@ -614,7 +614,7 @@ func TestBuildFullFactoryStateKeepsUnreadableAttachedWorkspaceState(t *testing.T
require.Error(t, errStat)
require.False(t, os.IsNotExist(errStat))

state := server.buildFullFactoryState()
state := server.buildWorkspaceListResponse()
require.Len(t, state.Workspaces, 1)
assert.Equal(t, filepath.Base(validDir), state.Workspaces[0].Name)

Expand All @@ -628,7 +628,7 @@ func TestBuildFullFactoryStateKeepsUnreadableAttachedWorkspaceState(t *testing.T
assert.Contains(t, readJSONPathList(t, filepath.Join(server.pinnedConfigDir, "pinned.json")), lockedCanonical)
}

func TestBuildFullFactoryStateDoesNotEmitUnreadableAttachedRoot(t *testing.T) {
func TestBuildWorkspaceListResponseDoesNotEmitUnreadableAttachedRoot(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping permission test as root")
}
Expand Down Expand Up @@ -669,7 +669,7 @@ func TestBuildFullFactoryStateDoesNotEmitUnreadableAttachedRoot(t *testing.T) {
require.Error(t, errStat)
require.False(t, os.IsNotExist(errStat))

state := server.buildFullFactoryState()
state := server.buildWorkspaceListResponse()
require.Len(t, state.Workspaces, 1)
assert.Equal(t, filepath.Base(forkDir), state.Workspaces[0].Name)
assert.Equal(t, forkCanonical, state.Workspaces[0].Dir)
Expand Down Expand Up @@ -722,13 +722,13 @@ func assertRepositoryAction(t *testing.T, repositoryAction *apiRepositoryAction,
assert.ElementsMatch(t, want.allowedOperations, repositoryAction.AllowedOps)
}

func decodeWorkspaceStateByName(t *testing.T, data []byte) map[string]apiWorkspaceFullState {
func decodeWorkspaceListByName(t *testing.T, data []byte) map[string]apiWorkspaceListEntry {
t.Helper()

var response apiFactoryState
var response apiWorkspaceListResponse
require.NoError(t, json.Unmarshal(data, &response))

result := make(map[string]apiWorkspaceFullState, len(response.Workspaces))
result := make(map[string]apiWorkspaceListEntry, len(response.Workspaces))
for i := range response.Workspaces {
workspace := response.Workspaces[i]
result[workspace.Name] = workspace
Expand Down
8 changes: 8 additions & 0 deletions cmd/sgai/internal_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ func TestPrintUsageOmitsInternalMCPCommand(t *testing.T) {
assert.NotContains(t, output, "internal-mcp")
}

func TestPrintUsageIncludesRunAndSharedConfigHelp(t *testing.T) {
output := captureStdout(t, printUsage)
assert.Contains(t, output, "sgai run [--config path] [--var key=value] <action-name>")
assert.Contains(t, output, "--shared-config-dir")
assert.Contains(t, output, "sgai run --config ./verification/sgai.json --var Name=Ada Summarize")
assert.Contains(t, output, "sgai --shared-config-dir ./verification/shared-config")
}

func TestRequiresOpencode(t *testing.T) {
tests := []struct {
name string
Expand Down
7 changes: 5 additions & 2 deletions cmd/sgai/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,18 @@ Usage:
sgai run [--config path] [--var key=value] <action-name>

Options:
--listen-addr HTTP server listen address (default: 127.0.0.1:8080)
--listen-addr HTTP server listen address (default: 127.0.0.1:8080)
--shared-config-dir Directory for shared pinned/external workspace config

Examples:
sgai
Start web UI on localhost:8080
sgai run --config ./verification/sgai.json --var Name=Ada Summarize
Run a configured action from the CLI
sgai --listen-addr 0.0.0.0:8080
Start web UI accessible externally`)
Start web UI accessible externally
sgai --shared-config-dir ./verification/shared-config
Start web UI using an isolated shared config directory`)
}

// runWorkflow executes the main workflow loop for a target directory.
Expand Down
15 changes: 8 additions & 7 deletions cmd/sgai/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,14 @@ func newTestMessage() state.Message {

func newTestSession() *session {
return &session{
mu: sync.Mutex{},
cancel: nil,
running: false,
outputLog: nil,
mcpCloseOnce: sync.Once{},
mcpCloseFn: nil,
coord: nil,
mu: sync.Mutex{},
cancel: nil,
running: false,
skipExitNotification: false,
outputLog: nil,
mcpCloseOnce: sync.Once{},
mcpCloseFn: nil,
coord: nil,
}
}

Expand Down
6 changes: 2 additions & 4 deletions cmd/sgai/outputlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,15 @@ type sessionLogWriter struct {
sess *session
workspacePath string
srv *Server
workspaceName string
}

func newSessionLogWriter(sess *session, workspacePath string, srv *Server, workspaceName string) *sessionLogWriter {
func newSessionLogWriter(sess *session, workspacePath string, srv *Server) *sessionLogWriter {
return &sessionLogWriter{
mu: sync.Mutex{},
partial: nil,
sess: sess,
workspacePath: workspacePath,
srv: srv,
workspaceName: workspaceName,
}
}

Expand All @@ -225,7 +223,7 @@ func (w *sessionLogWriter) Write(data []byte) (int, error) {

func (w *sessionLogWriter) addLine(text string) {
w.sess.outputLog.add(logLine{prefix: "", text: text})
w.srv.notifyStateChange()
w.srv.notifyWorkspacePageChange(w.workspacePath)
}

func buildAgentOutputWriter(base io.Writer, extra ...io.Writer) io.Writer {
Expand Down
Loading