WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+498
View File
@@ -0,0 +1,498 @@
package workbench
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func workbenchContext(tenant uint64, user string, role types.TenantRole) context.Context {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, tenant)
ctx = context.WithValue(ctx, types.UserIDContextKey, user)
return context.WithValue(ctx, types.TenantRoleContextKey, role)
}
func sqliteWorkbench(t *testing.T) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "workbench.db")+"?_busy_timeout=5000&_journal_mode=WAL"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = db.AutoMigrate(&Resource{}, &Job{}, &Revision{}, &Event{}, &Consumption{}, &Balance{}, &BalanceHold{}, &Transaction{}, &Usage{}, &UsageQuota{}, &OperationLog{}, &PendingMemory{}); err != nil {
t.Fatal(err)
}
return NewService(db)
}
func seedResource(t *testing.T, s *Service, tenant uint64, owner, kind string) *Resource {
t.Helper()
r := &Resource{ID: uuid.NewString(), TenantID: tenant, OwnerID: owner, Kind: kind, Name: "original", Status: "draft", Version: 1, Data: JSON{}}
if err := s.db.Create(r).Error; err != nil {
t.Fatal(err)
}
return r
}
func TestSaveRequiresCurrentLeaseAndScope(t *testing.T) {
for _, state := range []string{"valid", "expired", "token", "status", "resource", "owner", "tenant"} {
t.Run(state, func(t *testing.T) {
s := sqliteWorkbench(t)
ctx := workbenchContext(1, "alice", types.TenantRoleContributor)
r := seedResource(t, s, 1, "alice", "training-maps")
until := time.Now().Add(time.Minute)
j := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Status: "running", LeaseToken: "lease", LeaseUntil: &until}
switch state {
case "expired":
until = time.Now().Add(-time.Second)
case "token":
j.LeaseToken = "other"
case "status":
j.Status = "failed"
case "resource":
j.ResourceID = uuid.NewString()
case "owner":
j.OwnerID = "bob"
case "tenant":
j.TenantID = 2
}
if err := s.db.Create(&j).Error; err != nil {
t.Fatal(err)
}
ctx = context.WithValue(ctx, leaseContextKey{}, jobLease{j.ID, "lease"})
r.Name = "generated"
err := s.save(ctx, r, 1)
if state == "valid" {
if err != nil {
t.Fatal(err)
}
} else if !errors.Is(err, ErrConflict) {
t.Fatalf("invalid lease saved: %v", err)
}
var stored Resource
if err := s.db.First(&stored, "id = ?", r.ID).Error; err != nil {
t.Fatal(err)
}
var revisions int64
s.db.Model(&Revision{}).Count(&revisions)
if state == "valid" && (stored.Version != 2 || revisions != 2) {
t.Fatal("valid result must retain the original and create the next revision")
}
if state != "valid" && (stored.Version != 1 || stored.Name != "original" || revisions != 0) {
t.Fatal("failed lease changed persisted state")
}
})
}
}
func TestManualSaveRejectsActiveJobsAndUnscopedRows(t *testing.T) {
s := sqliteWorkbench(t)
s.agents = generationAgentStub{}
ctx := workbenchContext(1, "alice", types.TenantRoleContributor)
r := seedResource(t, s, 1, "alice", "training-maps")
r.Data = asJSON(MapData{Topic: "topic", AgentID: "author", DaysCount: 1})
if err := s.db.Save(r).Error; err != nil {
t.Fatal(err)
}
if _, err := s.submit(ctx, r.Kind, r.ID, "generate", ActionRequest{Version: 1}); err != nil {
t.Fatal(err)
}
r.Name = "changed"
if err := s.save(ctx, r, 1); !errors.Is(err, ErrConflict) {
t.Fatalf("queued job must block edit: %v", err)
}
for _, c := range []context.Context{workbenchContext(1, "bob", types.TenantRoleContributor), workbenchContext(2, "alice", types.TenantRoleAdmin)} {
if err := s.save(c, r, 1); err == nil {
t.Fatal("cross-owner or cross-tenant write succeeded")
}
if _, err := s.submit(c, r.Kind, r.ID, "generate", ActionRequest{Version: 1}); err == nil {
t.Fatal("cross-owner or cross-tenant submit succeeded")
}
}
}
func TestEmptyClaimDoesNotLogRecordNotFound(t *testing.T) {
s := sqliteWorkbench(t)
var output bytes.Buffer
s.db = s.db.Session(&gorm.Session{Logger: logger.New(log.New(&output, "", 0), logger.Config{LogLevel: logger.Warn})})
j, err := s.claim(context.Background())
if err != nil || j != nil || strings.Contains(output.String(), "record not found") {
t.Fatalf("empty queue must be quiet: job=%v err=%v log=%s", j, err, output.String())
}
}
func TestClaimRequiresAffectedRow(t *testing.T) {
s := sqliteWorkbench(t)
j := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: uuid.NewString(), Kind: "training-maps", Status: "queued"}
if err := s.db.Create(&j).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Exec("CREATE TRIGGER refuse_claim BEFORE UPDATE ON workbench_jobs BEGIN SELECT RAISE(IGNORE); END;").Error; err != nil {
t.Fatal(err)
}
claimed, err := s.claim(context.Background())
if claimed != nil || !errors.Is(err, ErrConflict) {
t.Fatalf("unclaimed job must not run: job=%v err=%v", claimed, err)
}
}
func TestExpiredLeaseCannotPublishProgress(t *testing.T) {
s := sqliteWorkbench(t)
until := time.Now().Add(-time.Second)
j := Job{ID: uuid.NewString(), Status: "running", LeaseToken: "lease", LeaseUntil: &until}
if err := s.db.Create(&j).Error; err != nil {
t.Fatal(err)
}
ctx := context.WithValue(context.Background(), leaseContextKey{}, jobLease{j.ID, j.LeaseToken})
if err := s.progress(ctx, j.ID, 90, "done"); !errors.Is(err, ErrConflict) {
t.Fatalf("expired progress update allowed: %v", err)
}
}
func TestWorkerRejectsSuspendedMembership(t *testing.T) {
s := sqliteWorkbench(t)
if err := s.db.AutoMigrate(&types.TenantMember{}); err != nil {
t.Fatal(err)
}
member := types.TenantMember{TenantID: 1, UserID: "alice", Role: types.TenantRoleAdmin, Status: types.TenantMemberStatusSuspended}
if err := s.db.Create(&member).Error; err != nil {
t.Fatal(err)
}
r := seedResource(t, s, 1, "alice", "training-maps")
until := time.Now().Add(time.Minute)
j := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Status: "running", LeaseToken: "valid-lease", LeaseUntil: &until, Request: asJSON(ActionRequest{Version: 1})}
if err := s.db.Create(&j).Error; err != nil {
t.Fatal(err)
}
s.run(context.Background(), &j)
var stored Job
if err := s.db.First(&stored, "id = ?", j.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Status != "failed" || stored.Error != ErrForbidden.Error() {
t.Fatalf("suspended user executed job: %+v", stored)
}
}
func TestResourceJobListIsScopedAndDoesNotExposeLease(t *testing.T) {
s := sqliteWorkbench(t)
r := seedResource(t, s, 1, "alice", "training-maps")
jobs := []Job{
{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Status: "running", LeaseToken: "private-token", Request: asJSON(ActionRequest{Advice: "private-advice"})},
{ID: uuid.NewString(), TenantID: 1, OwnerID: "bob", ResourceID: r.ID, Kind: r.Kind, Status: "queued"},
{ID: uuid.NewString(), TenantID: 2, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Status: "queued"},
}
if err := s.db.Create(&jobs).Error; err != nil {
t.Fatal(err)
}
for _, role := range []types.TenantRole{types.TenantRoleContributor, types.TenantRoleAdmin} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "kind", Value: r.Kind}, {Key: "id", Value: r.ID}}
c.Request = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(workbenchContext(1, "alice", role))
NewHandler(s).Jobs(c)
var result struct {
Total int64 `json:"total"`
Data []Job `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
want := int64(1)
if role == types.TenantRoleAdmin {
want = 2
}
if w.Code != 200 || result.Total != want || int64(len(result.Data)) != want || strings.Contains(w.Body.String(), "private-token") || strings.Contains(w.Body.String(), "private-advice") {
t.Fatalf("job list leaked scope or internal fields: %d %s", w.Code, w.Body.String())
}
}
}
func deleteWorkbenchResource(s *Service, ctx context.Context, r *Resource, detach bool) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "kind", Value: r.Kind}, {Key: "id", Value: r.ID}}
c.Request = httptest.NewRequest(http.MethodDelete, "/?detach="+map[bool]string{true: "true", false: "false"}[detach], nil).WithContext(ctx)
NewHandler(s).Delete(c)
return w
}
func TestDeleteBlocksQueuedJobAndDetachesWithRevision(t *testing.T) {
s := sqliteWorkbench(t)
ctx := workbenchContext(1, "alice", types.TenantRoleContributor)
r := seedResource(t, s, 1, "alice", "training-maps")
r.Status = "confirmed"
r.Data = asJSON(MapData{Days: []Day{{Day: 1, Title: "day"}}})
if err := s.db.Save(r).Error; err != nil {
t.Fatal(err)
}
flow := seedResource(t, s, 1, "alice", "flows")
flow.Data = asJSON(FlowData{Nodes: []FlowNode{{ID: "n", AgentID: "author"}}})
if err := s.db.Save(flow).Error; err != nil {
t.Fatal(err)
}
s.agents = generationAgentStub{}
task := seedResource(t, s, 1, "alice", "content-tasks")
task.Data = asJSON(TaskData{MapID: r.ID, FlowID: flow.ID, Day: 1, AgentID: "author", Review: "old review", Composed: "old draft"})
if err := s.db.Model(task).Update("data", task.Data).Error; err != nil {
t.Fatal(err)
}
if _, err := s.submit(ctx, task.Kind, task.ID, "decompose", ActionRequest{Version: 1}); err != nil {
t.Fatal(err)
}
if w := deleteWorkbenchResource(s, ctx, r, true); w.Code != 409 {
t.Fatalf("active dependent must block delete: %d %s", w.Code, w.Body.String())
}
if err := s.db.Model(&Job{}).Where("resource_id = ?", task.ID).Update("status", "failed").Error; err != nil {
t.Fatal(err)
}
if w := deleteWorkbenchResource(s, ctx, r, false); w.Code != 409 {
t.Fatal("delete requires explicit detach")
}
if w := deleteWorkbenchResource(s, ctx, r, true); w.Code != 200 {
t.Fatalf("detach delete: %d %s", w.Code, w.Body.String())
}
stored, err := s.get(ctx, task.Kind, task.ID)
if err != nil {
t.Fatal(err)
}
d, _ := decode[TaskData](stored.Data)
var revisions int64
s.db.Model(&Revision{}).Where("resource_id = ?", task.ID).Count(&revisions)
if stored.Version != 2 || stored.Status != "needs_configuration" || d.MapID != "" || d.Review != "old review" || d.Composed != "old draft" || revisions != 1 {
t.Fatal("detachment must preserve historical output and record a revision")
}
}
func TestManualUpdateCannotInjectStateAndReassignmentInvalidatesFollowing(t *testing.T) {
original := TaskData{Segments: []Segment{{ID: "s1", AgentID: "a", Status: "accepted", Content: "first"}, {ID: "s2", AgentID: "a", Status: "accepted", Content: "second"}, {ID: "s3", AgentID: "a", Status: "generated", Content: "third"}}, Review: "review", Composed: "final"}
r := &Resource{Kind: "content-tasks", Data: asJSON(original), Status: "completed"}
injected, _ := decode[TaskData](asJSON(original))
injected.Segments[1].Content = "injected"
if err := applyManualUpdate(r, resourceRequest{Name: "task", Data: asJSON(injected)}); !errors.Is(err, ErrInvalid) {
t.Fatalf("injected content accepted: %v", err)
}
changed, _ := decode[TaskData](asJSON(original))
changed.Segments[1].AgentID = "b"
if err := applyManualUpdate(r, resourceRequest{Name: "task", Data: asJSON(changed)}); err != nil {
t.Fatal(err)
}
actual, _ := decode[TaskData](r.Data)
if actual.Segments[0].Content != "first" || actual.Segments[0].Status != "accepted" || actual.Segments[1].AgentID != "b" || actual.Segments[1].Content != "second" || actual.Segments[1].Status != "needs_review" || actual.Segments[2].Status != "needs_review" || actual.Review != "" || actual.Composed != "" {
t.Fatalf("reassignment must invalidate its dependent output: %+v", actual)
}
md := MapData{Topic: "before", DaysCount: 1, Days: []Day{{Day: 1, Confirmed: true}}}
r = &Resource{Kind: "training-maps", Data: asJSON(md), Status: "confirmed"}
md.Days[0].Title = "injected"
if err := applyManualUpdate(r, resourceRequest{Data: asJSON(md)}); !errors.Is(err, ErrInvalid) {
t.Fatal("direct map content mutation accepted")
}
md, _ = decode[MapData](r.Data)
md.Topic = "after"
if err := applyManualUpdate(r, resourceRequest{Name: "map", Data: asJSON(md)}); err != nil {
t.Fatal(err)
}
md, _ = decode[MapData](r.Data)
if len(md.Days) != 1 || md.Days[0].Confirmed || r.Status != "needs_review" {
t.Fatal("configuration change must retain output and require its review")
}
}
// Uses only an isolated schema. Set WORKBENCH_TEST_POSTGRES_DSN to opt in.
func TestPostgresResourceRaces(t *testing.T) {
dsn := os.Getenv("WORKBENCH_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("WORKBENCH_TEST_POSTGRES_DSN is not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
schema := "wb_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
defer db.Exec("DROP SCHEMA " + schema + " CASCADE")
// Every pooled connection uses the isolated schema, including parallel workers.
testDSN := dsn + " search_path=" + schema
if strings.Contains(dsn, "://") {
separator := "?"
if strings.Contains(dsn, "?") {
separator = "&"
}
testDSN = dsn + separator + "search_path=" + schema
}
testDB, err := gorm.Open(postgres.Open(testDSN), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
testSQL, _ := testDB.DB()
defer testSQL.Close()
if err = testDB.AutoMigrate(&Resource{}, &Job{}, &Revision{}, &Event{}, &Consumption{}); err != nil {
t.Fatal(err)
}
s := NewService(testDB)
s.agents = generationAgentStub{}
ctx := workbenchContext(1, "alice", types.TenantRoleContributor)
seedGeneratableMap := func() *Resource {
r := seedResource(t, s, 1, "alice", "training-maps")
r.Data = asJSON(MapData{Topic: "topic", AgentID: "author", DaysCount: 1})
if err := s.db.Save(r).Error; err != nil {
t.Fatal(err)
}
return r
}
t.Run("lease expires while waiting for lock", func(t *testing.T) {
r := seedResource(t, s, 1, "alice", "training-maps")
until := time.Now().Add(150 * time.Millisecond)
job := Job{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", ResourceID: r.ID, Kind: r.Kind, Status: "running", LeaseToken: "locked-lease", LeaseUntil: &until}
if err := s.db.Create(&job).Error; err != nil {
t.Fatal(err)
}
locked := s.db.Begin()
if locked.Error != nil {
t.Fatal(locked.Error)
}
defer locked.Rollback()
if err := locked.Exec("SELECT id FROM workbench_jobs WHERE id = ? FOR UPDATE", job.ID).Error; err != nil {
t.Fatal(err)
}
leaseCtx := context.WithValue(ctx, leaseContextKey{}, jobLease{job.ID, job.LeaseToken})
finished := make(chan error, 1)
go func() { candidate := *r; candidate.Name = "too late"; finished <- s.save(leaseCtx, &candidate, 1) }()
time.Sleep(200 * time.Millisecond)
if err := locked.Commit().Error; err != nil {
t.Fatal(err)
}
if err := <-finished; !errors.Is(err, ErrConflict) {
t.Fatalf("lease expired under lock was allowed to save: %v", err)
}
})
for i := 0; i < 12; i++ {
r := seedGeneratableMap()
start := make(chan struct{})
var wg sync.WaitGroup
var editErr, submitErr error
wg.Add(2)
go func() {
defer wg.Done()
<-start
candidate := *r
candidate.Name = "edited"
editErr = s.save(ctx, &candidate, 1)
}()
go func() {
defer wg.Done()
<-start
_, submitErr = s.submit(ctx, r.Kind, r.ID, "generate", ActionRequest{Version: 1})
}()
close(start)
wg.Wait()
if (editErr == nil) == (submitErr == nil) {
t.Fatalf("exactly one concurrent write must succeed: edit=%v submit=%v", editErr, submitErr)
}
}
t.Run("binding versus parent delete", func(t *testing.T) {
for i := 0; i < 8; i++ {
m := seedResource(t, s, 1, "alice", "training-maps")
if err := s.db.Model(m).Update("status", "confirmed").Error; err != nil {
t.Fatal(err)
}
flow := seedResource(t, s, 1, "alice", "flows")
task := &Resource{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", Kind: "content-tasks", Name: "bound task", Version: 1, Data: asJSON(TaskData{MapID: m.ID, FlowID: flow.ID})}
start := make(chan struct{})
var wg sync.WaitGroup
var createErr error
var deleted *httptest.ResponseRecorder
wg.Add(2)
go func() { defer wg.Done(); <-start; createErr = s.create(ctx, task) }()
go func() { defer wg.Done(); <-start; deleted = deleteWorkbenchResource(s, ctx, m, true) }()
close(start)
wg.Wait()
if deleted.Code != 200 {
t.Fatalf("parent deletion failed: %d %s", deleted.Code, deleted.Body.String())
}
if createErr == nil {
stored, err := s.get(ctx, task.Kind, task.ID)
if err != nil {
t.Fatal(err)
}
data, _ := decode[TaskData](stored.Data)
if data.MapID != "" {
t.Fatal("concurrent create left dangling reference")
}
}
}
})
for i := 0; i < 12; i++ {
r := seedGeneratableMap()
start := make(chan struct{})
var wg sync.WaitGroup
var deleted *httptest.ResponseRecorder
var submitErr error
wg.Add(2)
go func() { defer wg.Done(); <-start; deleted = deleteWorkbenchResource(s, ctx, r, true) }()
go func() {
defer wg.Done()
<-start
_, submitErr = s.submit(ctx, r.Kind, r.ID, "generate", ActionRequest{Version: 1})
}()
close(start)
wg.Wait()
if (deleted.Code == 200) == (submitErr == nil) {
t.Fatalf("delete and submit must not both succeed: delete=%d submit=%v", deleted.Code, submitErr)
}
}
r := seedGeneratableMap()
if _, err = s.submit(ctx, r.Kind, r.ID, "generate", ActionRequest{Version: 1}); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
seen := map[string]bool{}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
job, err := s.claim(ctx)
if err != nil {
t.Errorf("claim: %v", err)
return
}
if job != nil {
mu.Lock()
if seen[job.ID] {
t.Error("same job claimed twice")
}
seen[job.ID] = true
mu.Unlock()
}
}()
}
wg.Wait()
}