Skip to content
26 changes: 24 additions & 2 deletions .github/workflows/execd-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
paths:
- 'components/execd/**'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -29,10 +33,28 @@ jobs:
#
make multi-build

- name: Run tests
- name: Run tests with coverage
run: |
cd components/execd
make test
go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/...

- name: Calculate coverage and generate summary
id: coverage
run: |
cd components/execd
# Extract total coverage percentage
TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "total_coverage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT

# Generate GitHub Actions job summary
echo "## 📊 execd Test Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total Line Coverage:** $TOTAL_COVERAGE" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Coverage report generated for commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*Coverage targets: Core packages >80%, API layer >70%*" >> $GITHUB_STEP_SUMMARY

smoke:
strategy:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/real-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
push:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
python-e2e:
name: Python E2E (docker bridge)
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/server-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
paths:
- 'server/**'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/verify-license.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
pull_request:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
verify-license:
runs-on: ubuntu-latest
Expand Down
112 changes: 102 additions & 10 deletions components/execd/pkg/runtime/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) {
kernel := &jupyterKernel{
kernelID: session.Kernel.ID,
client: client,
language: req.Language,
}
c.storeJupyterKernel(session.ID, kernel)

Expand All @@ -63,6 +64,45 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) {
return session.ID, nil
}

func (c *Controller) DeleteContext(session string) error {
kernel := c.getJupyterKernel(session)
if kernel == nil {
return ErrContextNotFound
}

c.mu.Lock()
defer c.mu.Unlock()

return c.jupyterClient().DeleteSession(session)
}

func (c *Controller) ListContext(language string) ([]CodeContext, error) {
switch language {
case Command.String(), BackgroundCommand.String(), SQL.String():
return nil, fmt.Errorf("unsupported language context operation: %s", language)
case "":
return c.listAllContexts()
default:
return c.listLanguageContexts(Language(language))
}
}

func (c *Controller) DeleteLanguageContext(language Language) error {
contexts, err := c.listLanguageContexts(language)
if err != nil {
return err
}

client := c.jupyterClient()
for _, context := range contexts {
err := client.DeleteSession(context.ID)
if err != nil {
return fmt.Errorf("error deleting context %s: %w", context.ID, err)
}
}
return nil
}

func (c *Controller) newContextID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")
}
Expand Down Expand Up @@ -112,16 +152,7 @@ func (c *Controller) createDefaultLanguageContext(language Language) error {

// createContext performs the actual context creation workflow.
func (c *Controller) createContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) {
httpClient := &http.Client{
Transport: &jupyter.AuthTransport{
Token: c.token,
Base: http.DefaultTransport,
},
}

client := jupyter.NewClient(c.baseURL,
jupyter.WithToken(c.token),
jupyter.WithHTTPClient(httpClient))
client := c.jupyterClient()

kernel, err := c.searchKernel(client, request.Language)
if err != nil {
Expand Down Expand Up @@ -165,3 +196,64 @@ func (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel)

c.jupyterClientMap[sessionID] = kernel
}

func (c *Controller) jupyterClient() *jupyter.Client {
httpClient := &http.Client{
Transport: &jupyter.AuthTransport{
Token: c.token,
Base: http.DefaultTransport,
},
}

return jupyter.NewClient(c.baseURL,
jupyter.WithToken(c.token),
jupyter.WithHTTPClient(httpClient))
}

func (c *Controller) listAllContexts() ([]CodeContext, error) {
c.mu.RLock()
defer c.mu.RUnlock()

var contexts []CodeContext
for session, kernel := range c.jupyterClientMap {
if kernel != nil {
contexts = append(contexts, CodeContext{
ID: session,
Language: kernel.language,
})
}
}

for language, defaultContext := range c.defaultLanguageJupyterSessions {
contexts = append(contexts, CodeContext{
ID: defaultContext,
Language: language,
})
}

return contexts, nil
}

func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) {
c.mu.RLock()
defer c.mu.RUnlock()

var contexts []CodeContext
for session, kernel := range c.jupyterClientMap {
if kernel != nil && kernel.language == language {
contexts = append(contexts, CodeContext{
ID: session,
Language: language,
})
}
}

if defaultContext := c.defaultLanguageJupyterSessions[language]; defaultContext != "" {
contexts = append(contexts, CodeContext{
ID: defaultContext,
Language: language,
})
}

return contexts, nil
}
110 changes: 110 additions & 0 deletions components/execd/pkg/runtime/context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2025 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package runtime

import (
"errors"
"os"
"path/filepath"
"testing"
)

func TestListContextsAndNewIpynbPath(t *testing.T) {
c := NewController("http://example", "token")
c.jupyterClientMap["session-python"] = &jupyterKernel{language: Python}
c.defaultLanguageJupyterSessions[Go] = "session-go-default"

pyContexts, err := c.listLanguageContexts(Python)
if err != nil {
t.Fatalf("listLanguageContexts returned error: %v", err)
}
if len(pyContexts) != 1 || pyContexts[0].ID != "session-python" || pyContexts[0].Language != Python {
t.Fatalf("unexpected python contexts: %#v", pyContexts)
}

allContexts, err := c.listAllContexts()
if err != nil {
t.Fatalf("listAllContexts returned error: %v", err)
}
if len(allContexts) != 2 {
t.Fatalf("expected two contexts, got %d", len(allContexts))
}

tmpDir := filepath.Join(t.TempDir(), "nested")
path, err := c.newIpynbPath("abc123", tmpDir)
if err != nil {
t.Fatalf("newIpynbPath error: %v", err)
}
if _, statErr := os.Stat(tmpDir); statErr != nil {
t.Fatalf("expected directory to be created: %v", statErr)
}
expected := filepath.Join(tmpDir, "abc123.ipynb")
if path != expected {
t.Fatalf("unexpected ipynb path: got %s want %s", path, expected)
}
}

func TestNewContextID_UniqueAndLength(t *testing.T) {
c := NewController("", "")
id1 := c.newContextID()
id2 := c.newContextID()

if id1 == "" || id2 == "" {
t.Fatalf("expected non-empty ids")
}
if id1 == id2 {
t.Fatalf("expected unique ids, got identical: %s", id1)
}
if len(id1) != 32 || len(id2) != 32 {
t.Fatalf("expected 32-char ids, got %d and %d", len(id1), len(id2))
}
}

func TestNewIpynbPath_ErrorWhenCwdIsFile(t *testing.T) {
c := NewController("", "")
tmpFile := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(tmpFile, []byte("x"), 0o644); err != nil {
t.Fatalf("prepare file: %v", err)
}

if _, err := c.newIpynbPath("abc", tmpFile); err == nil {
t.Fatalf("expected error when cwd is a file")
}
}

func TestListContextUnsupportedLanguage(t *testing.T) {
c := NewController("", "")
_, err := c.ListContext(Command.String())
if err == nil {
t.Fatalf("expected error for command language")
}
if _, err := c.ListContext(BackgroundCommand.String()); err == nil {
t.Fatalf("expected error for background-command language")
}
if _, err := c.ListContext(SQL.String()); err == nil {
t.Fatalf("expected error for sql language")
}
}

func TestDeleteContext_NotFound(t *testing.T) {
c := NewController("", "")
err := c.DeleteContext("missing")
if err == nil {
t.Fatalf("expected ErrContextNotFound")
}
if !errors.Is(err, ErrContextNotFound) {
t.Fatalf("unexpected error: %v", err)
}
}
1 change: 1 addition & 0 deletions components/execd/pkg/runtime/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type jupyterKernel struct {
mu sync.Mutex
kernelID string
client *jupyter.Client
language Language
}

type commandKernel struct {
Expand Down
19 changes: 19 additions & 0 deletions components/execd/pkg/runtime/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2025 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package runtime

import "errors"

var ErrContextNotFound = errors.New("context not found")
Loading
Loading