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
@@ -0,0 +1,527 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
)
// RemoveSkill deletes a skill from the image. It is not a DB-only operation:
// the files really do leave the image, via a new snapshot taken after the
// directory is gone. Removal therefore shares the install's per-config lock —
// both grow a new snapshot from the current one, and interleaving them would
// discard whichever wrote the pointer first.
func (s *TenantSkillService) RemoveSkill(
ctx context.Context, tenantID uint64, configID, skillID string,
) error {
skill, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
return err
}
if skill == nil {
return apperrors.NewNotFoundError("skill not found")
}
now := s.now()
skill.Status = types.SkillStatusRemoving
skill.Error = ""
skill.InstallingSince = &now
if err := s.skills.UpdateSkill(ctx, skill); err != nil {
return err
}
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 5, Stage: "accepted", Status: types.SkillStatusRemoving,
})
// Like the install, the removal outlives the HTTP request and must not
// inherit its cancellation. It is not durable across a restart either;
// StopSkill rewrites the row, and the stuck-run reaper is the backup.
go func() {
bgCtx := context.WithoutCancel(ctx)
if err := s.withSkillRunLock(bgCtx, tenantID, configID, skillID, func(lockCtx context.Context) error {
return s.runRemove(lockCtx, tenantID, configID, skillID)
}); err != nil {
logger.Errorf(bgCtx, "[skill] remove %s failed: %v", skillID, err)
}
}()
return nil
}
func (s *TenantSkillService) runRemove(
ctx context.Context, tenantID uint64, configID, skillID string,
) (err error) {
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
ctx = types.WithSandboxTenantID(ctx, tenantID)
// An empty id never names a row; refusing here keeps the later GetSkill
// from being the only thing standing between us and a no-op that looks
// like success.
if strings.TrimSpace(skillID) == "" {
return fmt.Errorf("skill id is required")
}
// RemoveSkill validates the skill outside this lock, so two submissions
// queue two runs. The second has nothing to remove, and doing it anyway
// would boot a sandbox, wipe a directory that is not there and spend a
// snapshot plus a generation bump to produce an identical image.
existing, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
return fmt.Errorf("load skill %s: %w", skillID, err)
}
if existing == nil {
return nil
}
// RemoveSkill queues this run before the per-config lock. A newer upload
// of the same name already flipped the row back to installing; deleting
// it (or wiping its directory) would discard that install.
if existing.Status != types.SkillStatusRemoving {
return nil
}
// The image directory is the skill name, and SkillDirFor is what refuses a
// name that would escape the skills root. Resolved before any sandbox work
// so a bad name costs nothing; the guard at the rm itself is what keeps
// that an invariant rather than a convention.
skillDir, err := sandbox.SkillDirFor(existing.Name)
if err != nil {
return err
}
// Cleanup runs on a context that cannot be cancelled by whatever it is
// compensating for: withConfigLock cancels ctx the moment lock renewal
// fails, and both the sandbox destroy (a provider call) and the terminal
// row write (a DB call) still have to happen then. Only cancellation is
// detached here — each consumer calls cleanupContext so its budget starts
// when its work does, because a removal can run for minutes.
cleanupBase := context.WithoutCancel(ctx)
handle := s.lookupSkillRun(tenantID, configID, skillID)
// imageChanged marks the point of no return. Past it the skill is gone
// from the image every new session boots, so restoring the row to ready
// would advertise files that no longer exist.
imageChanged := false
defer func() {
if err == nil {
return
}
if imageChanged {
logger.Errorf(cleanupBase,
"[skill] %s is gone from the image but its bookkeeping is incomplete: %v",
skillID, err)
return
}
if !s.skillRunStillBound(tenantID, configID, skillID, handle) {
return
}
// A half-removed skill is worse than a kept one: the image still has
// it, so put the row back the way the operator can retry from.
restoreCtx, cancelRestore := s.cleanupContext(cleanupBase)
defer cancelRestore()
s.restoreSkillAfterFailedRemoval(restoreCtx, tenantID, configID, skillID, err)
}()
cfgEntity, err := s.configs.GetByID(ctx, tenantID, configID)
if err != nil {
return fmt.Errorf("load sandbox config %s: %w", configID, err)
}
if cfgEntity == nil {
return fmt.Errorf("sandbox config %s not found", configID)
}
// A skill that never made it into any image needs no sandbox at all, and
// no live sandbox can be out of date over a skill it never carried.
if currentSnapshotID(cfgEntity) == "" {
return s.finishRemoval(cleanupBase, tenantID, configID, skillID, false)
}
// Everything below either creates provider resources or moves the image
// pointer, and the fallback moves it without taking a snapshot first. A
// cancelled context means the lock's renewal failed and another run may
// already be building on this config, so stop rather than race it.
if err := ctx.Err(); err != nil {
return fmt.Errorf("remove lock lost before the image changes: %w", err)
}
remaining, err := s.countRemainingSkills(ctx, tenantID, configID, skillID)
if err != nil {
return err
}
if remaining == 0 {
// Nothing would be left to carry, and the base template carries no
// skills by construction, so pointing back at it removes the files as
// surely as a wipe would. Checked before the sandbox is started: the
// snapshot that work produced would be discarded unread.
if err := s.clearImagePointer(
ctx, tenantID, configID, currentGeneration(cfgEntity)+1,
); err != nil {
return err
}
imageChanged = true
// No new ledger row exists to keep active: nothing points at a
// snapshot any more.
s.markPreviousSnapshotsSuperseded(ctx, tenantID, configID, "")
return s.finishRemoval(cleanupBase, tenantID, configID, skillID, true)
}
// Remaining skills still live in the current snapshot, so it must be an
// image the live credentials can resolve. A last-skill clear above does
// not boot that snapshot — it only drops the pointer — and must stay
// possible after a credential rotation so the operator can fall back to
// the base template.
if err := ensureUsableImage(cfgEntity); err != nil {
return err
}
builtFingerprint := skillOwnerFingerprint(cfgEntity.Config)
// ResolveEffectiveConfig has already turned the current snapshot into the
// template, so this sandbox boots from the image the skill is in.
sess, mgr, err := s.startMaintenanceSession(ctx, tenantID, configID, "remove")
if err != nil {
return err
}
// Only the sandbox is released; the session and its messages stay, the
// same way an install keeps its transcript.
defer func() {
releaseCtx, cancelRelease := s.cleanupContext(cleanupBase)
defer cancelRelease()
s.releaseSandbox(releaseCtx, mgr, sess.ID)
}()
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{Percent: 30, Stage: "sandbox_ready"})
if err := s.removeSkillDirectory(ctx, mgr, sess.ID, skillDir); err != nil {
return err
}
if err := s.removeManifestEntry(ctx, mgr, sess.ID, skillID); err != nil {
return err
}
// Same reason as the install: the per-session workspace and the package
// caches must not reach the snapshot.
if err := s.cleanImageScratch(ctx, mgr, sess.ID); err != nil {
return err
}
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{Percent: 60, Stage: "removed"})
// Checked again: everything above is confined to a sandbox we are about
// to destroy, and the wipe itself takes minutes, so the lock may have been
// lost since the check before the fallback branch.
if err := ctx.Err(); err != nil {
return fmt.Errorf("remove lock lost before snapshot: %w", err)
}
owned, err := s.removeStillOwnsTheRow(ctx, tenantID, configID, skillID)
if err != nil {
return err
}
if !owned {
return nil
}
ledger, err := s.skills.ListSnapshotsByConfig(ctx, tenantID, configID)
if err != nil {
return fmt.Errorf("list snapshots of config %s: %w", configID, err)
}
generation := nextSnapshotGeneration(currentGeneration(cfgEntity), ledger)
// The ledger row is written before the snapshot: a snapshot with no ledger
// entry is a provider resource nobody knows exists.
removeRowID := uuid.NewString()
snapshotName := skillSnapshotBuildName(tenantID, configID, generation, removeRowID)
if err := s.skills.CreateSnapshotRow(ctx, &types.TenantSkillSnapshotEntity{
ID: removeRowID, TenantID: tenantID, SandboxConfigID: configID, SkillID: skillID,
ParentSnapshotID: currentSnapshotID(cfgEntity), Generation: generation,
Trigger: types.SkillSnapshotTriggerRemove, State: types.SkillSnapshotStateBuilding,
PlannedName: snapshotName,
}); err != nil {
return err
}
ref, err := s.createSnapshot(ctx, mgr, sess.ID, snapshotName)
if err != nil {
return err
}
if err := s.skills.MarkSnapshotState(
ctx, tenantID, removeRowID, types.SkillSnapshotStateActive, ref.ID,
); err != nil {
// The snapshot's ID exists nowhere but this function's locals now, so
// it is as unreachable as one nothing points at.
s.abandonSnapshot(cleanupBase, tenantID, mgr, removeRowID, ref.ID)
return err
}
if err := s.switchImagePointer(ctx, tenantID, configID, ref.ID, generation, builtFingerprint); err != nil {
s.abandonSnapshot(cleanupBase, tenantID, mgr, removeRowID, ref.ID)
return err
}
imageChanged = true
s.markPreviousSnapshotsSuperseded(ctx, tenantID, configID, removeRowID)
return s.finishRemoval(cleanupBase, tenantID, configID, skillID, true)
}
// removeSkillDirectory wipes one skill's tree from the image. The per-skill
// environment discipline is what makes a single rm enough: the venv,
// node_modules and any local bin live under that tree, so they go with it.
//
// It reclaims only what lives under that tree. Anything the installer agent
// put elsewhere - an apt package, a globally linked npm binary - stays behind,
// because there is no record of what a given install changed outside its own
// directory and guessing would mean uninstalling a system library another
// skill now depends on. The cost is bounded image growth, which the rebuild
// flow can reclaim from the base template; the alternative risks breaking
// skills that still work.
func (s *TenantSkillService) removeSkillDirectory(
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string,
) error {
if err := guardSkillDir(skillDir); err != nil {
return err
}
if _, err := s.execInstall(
ctx, mgr, sessionID, "rm -rf "+sandbox.ShellQuote(skillDir),
); err != nil {
return fmt.Errorf("remove skill directory %s: %w", skillDir, err)
}
return nil
}
// removeManifestEntry drops the skill from the image's manifest.
//
// A manifest that cannot be read or parsed is not a reason to fail the
// removal: it is a troubleshooting aid, never the source of truth for
// execution, and rewriting it from an empty value would erase the entries of
// every other skill in the image.
func (s *TenantSkillService) removeManifestEntry(
ctx context.Context, mgr sandbox.Manager, sessionID, skillID string,
) error {
reader, ok := mgr.(sandbox.SessionFileReader)
if !ok {
return nil
}
raw, err := reader.ReadSessionFile(ctx, sessionID, sandbox.SkillsManifestPath)
if err != nil {
logger.Warnf(ctx, "[skill] read image manifest failed: %v", err)
return nil
}
var manifest skillImageManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
logger.Warnf(ctx, "[skill] image manifest is unreadable: %v", err)
return nil
}
kept := make([]skillImageManifestEntry, 0, len(manifest.Skills))
for _, entry := range manifest.Skills {
if entry.ID != skillID {
kept = append(kept, entry)
}
}
if len(kept) == len(manifest.Skills) {
return nil
}
manifest.Skills = kept
store, err := installFileStore(mgr)
if err != nil {
return err
}
payload, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
payload = append(payload, '\n')
return store.WriteSessionFile(ctx, sessionID, sandbox.SkillsManifestPath, payload)
}
// countRemainingSkills counts what the image would still have to carry.
//
// Every row counts, not just the ready ones: a row left failed by a re-install
// still has the previously installed version's files in the image, and
// dropping back to the base template would silently take those away too.
func (s *TenantSkillService) countRemainingSkills(
ctx context.Context, tenantID uint64, configID, removedSkillID string,
) (int, error) {
skills, err := s.skills.ListSkillsByConfig(ctx, tenantID, configID)
if err != nil {
return 0, fmt.Errorf("list skills of config %s: %w", configID, err)
}
remaining := 0
for _, skill := range skills {
if skill != nil && skill.ID != removedSkillID {
remaining++
}
}
return remaining, nil
}
// clearImagePointer points the config back at its base template. It re-reads
// the config for the same reason switchImagePointer does: the entity read at
// the top of the run is minutes old, and writing it back would revert whatever
// the config service persisted in the meantime.
func (s *TenantSkillService) clearImagePointer(
ctx context.Context, tenantID uint64, configID string, generation int,
) error {
cfgEntity, err := s.configs.GetByID(ctx, tenantID, configID)
if err != nil {
return fmt.Errorf("re-read sandbox config %s: %w", configID, err)
}
if cfgEntity == nil || cfgEntity.Config == nil {
return fmt.Errorf("sandbox config %s disappeared during the removal", configID)
}
// The metadata is kept and the generation still advances: an empty
// SnapshotID already means "boot the base template", and the rest is what
// tells an operator which generation emptied the image.
cfgEntity.Config.SkillImage = &types.SkillImageConfig{
Generation: generation,
BuiltAt: s.now(),
BaseTemplateID: effectiveBaseTemplate(cfgEntity),
OwnerFingerprint: skillOwnerFingerprint(cfgEntity.Config),
}
cfgEntity.TenantID = tenantID
return s.configs.Update(ctx, cfgEntity)
}
// finishRemoval drops the DB row, then invalidates bound sandboxes. The
// catalog archive is left alone: uninstalling from a sandbox must not
// destroy the definition. It runs only once the image no longer contains
// the skill, which is also why the row delete is retried rather than
// reported as a failed removal.
func (s *TenantSkillService) finishRemoval(
cleanupBase context.Context, tenantID uint64, configID, skillID string, imageChanged bool,
) error {
ctx, cancel := s.cleanupContext(cleanupBase)
defer cancel()
owned, err := s.removeStillOwnsTheRow(ctx, tenantID, configID, skillID)
if err != nil {
return err
}
if !owned {
return nil
}
// Read before the delete: past it the only record of what this row named is
// gone, and a pinned archive would be left behind with nothing to reclaim it.
pinned := ""
if row, err := s.skills.GetSkill(ctx, tenantID, configID, skillID); err == nil && row != nil {
pinned = strings.TrimSpace(row.BundleRef)
}
if err := s.retrySkillBookkeeping(ctx, func() error {
return s.skills.DeleteSkill(ctx, tenantID, configID, skillID)
}); err != nil {
return fmt.Errorf("skill %s no longer exists in the image but its row remains: %w",
skillID, err)
}
// The definition's archive survives this; only an object no catalog and no
// other install names is reclaimed.
s.releaseInstallBundle(ctx, tenantID, pinned)
// Only an image that actually changed can leave a bound sandbox out of
// date. Marking after a removal that moved no pointer would destroy and
// rebuild every live sandbox of the config - throwing away each session's
// /workspace scratch - for a change no sandbox can observe.
if imageChanged {
s.markConfigSandboxesStale(ctx, tenantID, configID)
}
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 100, Stage: "done", Status: "removed",
})
return nil
}
// restoreSkillAfterFailedRemoval puts back the row a failed removal borrowed.
func (s *TenantSkillService) restoreSkillAfterFailedRemoval(
ctx context.Context, tenantID uint64, configID, skillID string, cause error,
) {
owned, err := s.removeStillOwnsTheRow(ctx, tenantID, configID, skillID)
if err != nil || !owned {
return
}
status := types.SkillStatusFailed
_ = s.updateSkillFields(ctx, tenantID, configID, skillID,
func(e *types.TenantSkillEntity) {
// A skill that reached an image is still in it, so it goes back to
// ready and the operator can retry. One that never did has nothing
// to be ready for, and calling it ready would point the agent at
// files no image carries.
if e.InstalledSnapshotID != "" {
status = types.SkillStatusReady
}
e.Status = status
e.Error = cause.Error()
e.InstallingSince = nil
})
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 100, Stage: "failed", Status: status, Log: cause.Error(),
})
}
// removeStillOwnsTheRow is the lock-side counterpart of RemoveSkill's
// optimistic status flip. A newer upload of the same name already set the
// row back to installing; deleting it (or restoring it to ready/failed)
// would discard that install.
func (s *TenantSkillService) removeStillOwnsTheRow(
ctx context.Context, tenantID uint64, configID, skillID string,
) (bool, error) {
current, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
return false, fmt.Errorf("load skill %s: %w", skillID, err)
}
if current == nil {
return false, nil
}
return current.Status == types.SkillStatusRemoving, nil
}
// releaseInstallBundle drops an archive an install row has stopped naming,
// unless something else still names it.
//
// The reference is not owned by one row. A definition's own object is shared by
// every install of it, and one replaced archive is pinned by each install that
// was built from it, so "this row let go" is not the same question as "nobody
// needs these bytes". Both tables are checked before the object is deleted, and
// an unreadable table means it is kept: a leaked archive costs storage, a
// deleted one costs an install its files.
func (s *TenantSkillService) releaseInstallBundle(
ctx context.Context, tenantID uint64, bundleRef string,
) {
ref := strings.TrimSpace(bundleRef)
if ref == "" {
return
}
catalogs, err := s.skills.ListCatalogsByTenant(ctx, tenantID)
if err != nil {
logger.Warnf(ctx, "[skill] list catalogs before releasing bundle %s failed: %v", ref, err)
return
}
for _, cat := range catalogs {
if cat != nil && strings.TrimSpace(cat.BundleRef) == ref {
return
}
}
installs, err := s.skills.ListSkillsByTenant(ctx, tenantID)
if err != nil {
logger.Warnf(ctx, "[skill] list installs before releasing bundle %s failed: %v", ref, err)
return
}
for _, row := range installs {
if row != nil && strings.TrimSpace(row.BundleRef) == ref {
return
}
}
s.deleteBundleBestEffort(ctx, tenantID, ref)
}
func (s *TenantSkillService) deleteBundleBestEffort(
ctx context.Context, tenantID uint64, bundleRef string,
) {
fs, err := s.fileServiceForTenant(ctx, tenantID)
if err != nil || fs == nil {
logger.Warnf(ctx, "[skill] resolve file service to delete bundle %s failed: %v",
bundleRef, err)
return
}
if err := fs.DeleteFile(ctx, bundleRef); err != nil {
logger.Warnf(ctx, "[skill] delete bundle %s failed: %v", bundleRef, err)
}
}