From 153ea224b16aef6dfa0583d59dc0f648504b5b6c Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:09:41 +0800 Subject: [PATCH 01/18] docs: design OTA updater flow --- .../specs/2026-08-06-ota-updater-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-ota-updater-design.md diff --git a/docs/superpowers/specs/2026-08-06-ota-updater-design.md b/docs/superpowers/specs/2026-08-06-ota-updater-design.md new file mode 100644 index 00000000..8f0a5a78 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-ota-updater-design.md @@ -0,0 +1,110 @@ +# OTA Updater Design + +## Goal + +Add opt-in desktop OTA updates backed by GitHub Releases. OneAgent checks once +per launch, asks before downloading, reports the download through the existing +task center, supports real cancellation, and lets the user restart when ready. + +## User Flow + +1. A release build silently checks the latest GitHub Release after the frontend + starts. Development builds do not check. +2. No update or a failed background check produces no task or notification. +3. When a newer version exists, a native Wails question dialog shows the target + version with `Update` and `Not Now` actions. +4. `Not Now` dismisses the update for the current launch only. The next launch + checks again. +5. `Update` starts a task-center entry and downloads the selected artifact. +6. The task card shows byte progress and can cancel the underlying Wails call. +7. After verification and staging, the completed task exposes `Restart & + Update`. The Wails updater swaps the executable or app bundle and relaunches + OneAgent only after that action. + +The built-in updater window is not used. In the pinned Wails beta.3, +`CheckAndInstall` opens that window but immediately calls +`DownloadAndInstall`; its buttons do not gate the download. A native question +dialog provides the requested confirmation without copying or forking Wails' +updater template. + +## Architecture + +### Backend + +- Configure `app.Updater` with the GitHub provider for + `MaimoryLab/OneAgent`, `SHA256SUMS`, the linker-injected current version, and + `updater.WindowNone`. +- Strip the leading `v` from `internal/version.Version` before passing it to + Wails. Do not configure the updater for the default `-dev` version. +- Add a small Wails service exposing `Check`, `DownloadAndInstall`, and + `Restart`. `Check` returns only the available version string; an empty string + means current or disabled. +- Translate Wails `EventDownloadProgress` payloads into the existing + `oneagent:install-output` progress event with one stable OTA target. + +Wails remains responsible for release comparison, download, checksum +verification, safe archive extraction, staging, executable/app-bundle swap, +rollback, and relaunch. + +### Frontend + +- Mount one update coordinator inside the existing task-center provider. +- Call `Check` once, then use `Dialogs.Question` only when a version is found. +- On approval, register an `update` task, attach the generated binding's + canceller, and call `DownloadAndInstall`. +- Extend task records with one optional terminal action. The OTA task uses it + for `Restart & Update`; existing tasks remain unchanged. +- Reuse the existing progress event listener, byte progress UI, cancellation + state, failure state, dismissal, and task locking. + +## Failure Handling + +- Background check failures stay silent because there is no user-started task. +- Download cancellation ends the task as cancelled and cancels the binding + context, which stops the HTTP request. +- Download, checksum, extraction, and staging failures end the task as failed. +- Restart failures keep the app running and show the failure on the task card; + the restart action remains available for retry. +- A second startup check or download is not started while the OTA task is + already active. + +## Release Workflow + +Reuse `.github/workflows/build-artifacts.yml` and trigger it for stable tags +matching `vX.Y.Z`. Keep the existing Windows/macOS and amd64/arm64 build +matrix, inject the tag through the existing linker flag, and publish these +GitHub Release assets: + +- `OneAgent-darwin-amd64.zip` +- `OneAgent-darwin-arm64.zip` +- `OneAgent-windows-amd64.zip` +- `OneAgent-windows-arm64.zip` +- `SHA256SUMS` + +Each macOS archive contains exactly one top-level `OneAgent.app`. Each Windows +archive contains exactly one top-level `oneagent-desktop.exe`. The platform and +architecture tokens intentionally match the Wails GitHub provider's default +asset matcher. The release job generates `SHA256SUMS` after collecting all four +archives and creates or updates the release for the pushed tag. + +The checksum detects corruption and is the integrity mechanism supported by +the GitHub provider. Signing-key infrastructure is out of scope; add it only if +the release source moves to a provider or manifest that carries Wails signature +metadata. + +## Verification + +- Go tests cover disabled/current/new-version checks and the update service's + delegation to Wails. +- Frontend tests cover `Update` versus `Not Now`, task creation, binding + cancellation, completion, and the restart action. +- Existing task-center tests continue to cover shared progress rendering and + task lifecycle behavior; add only the optional terminal-action cases. +- Run all Go tests, frontend tests, frontend build/typecheck, binding generation, + and available workflow/archive validation before completion. + +## Non-Goals + +- Forced updates, periodic checks, prerelease channels, persisted skip-version + preferences, a custom updater window, and release-note rendering. +- Reimplementing download, verification, extraction, swap, or rollback logic. From 9f9bd5173bc50c728bd36146792fb82a1c0a1ae3 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:49:08 +0800 Subject: [PATCH 02/18] feat: gate OTA on release versions --- internal/version/version.go | 11 +++++++++++ internal/version/version_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 internal/version/version_test.go diff --git a/internal/version/version.go b/internal/version/version.go index 788b1cc4..60d45b16 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,5 +1,16 @@ // Package version holds the version reported by release binaries. package version +import "strings" + // Version is replaced with the release version through Go linker flags. var Version = "v0.0.0-dev" + +// UpdaterVersion returns the release version accepted by the updater. +func UpdaterVersion() string { + version := strings.TrimPrefix(strings.TrimSpace(Version), "v") + if version == "" || strings.HasSuffix(version, "-dev") { + return "" + } + return version +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 00000000..fbce98d4 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,29 @@ +package version + +import "testing" + +func TestUpdaterVersion(t *testing.T) { + original := Version + defer func() { Version = original }() + + tests := []struct { + name string + version string + want string + }{ + {name: "prefixed release", version: "v1.2.3", want: "1.2.3"}, + {name: "unprefixed release", version: "1.2.3", want: "1.2.3"}, + {name: "prefixed development", version: "v0.0.0-dev", want: ""}, + {name: "unprefixed development", version: "1.2.3-dev", want: ""}, + {name: "whitespace", version: " ", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + Version = tt.version + if got := UpdaterVersion(); got != tt.want { + t.Fatalf("UpdaterVersion() = %q, want %q", got, tt.want) + } + }) + } +} From 3b6821fdfe5d5f7b44c1631edcb2cb5a3b83a2fd Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:55:02 +0800 Subject: [PATCH 03/18] feat: expose the Wails updater service --- internal/binding/services_test.go | 1 + internal/binding/update.go | 92 +++++++++++++++ internal/binding/update_test.go | 187 ++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 internal/binding/update.go create mode 100644 internal/binding/update_test.go diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 3faba00a..7dd64b0f 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -48,6 +48,7 @@ func TestServiceMethodAllowlist(t *testing.T) { {&ProfileService{}, []string{"ListProfiles", "SaveProfile"}}, {&RuntimeService{}, []string{"GetSettings", "InstallRuntime", "ListRuntimes", "SaveSettings"}}, {&DesktopAgentService{}, []string{"Configure", "GetStatus", "Install", "Open"}}, + {&UpdateService{}, []string{"Check", "DownloadAndInstall", "Restart"}}, } for _, test := range tests { typeOf := reflect.TypeOf(test.service) diff --git a/internal/binding/update.go b/internal/binding/update.go new file mode 100644 index 00000000..2f7848a5 --- /dev/null +++ b/internal/binding/update.go @@ -0,0 +1,92 @@ +package binding + +import ( + "context" + "errors" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/wailsapp/wails/v3/pkg/updater" +) + +const UpdateProgressTarget = "oneagent-update" + +type UpdateBackend interface { + Check(context.Context) (*updater.Release, error) + DownloadAndInstall(context.Context) error + Restart(context.Context) error +} + +type UpdateService struct { + backend UpdateBackend +} + +func NewUpdateService(backend UpdateBackend) *UpdateService { + return &UpdateService{backend: backend} +} + +func (s *UpdateService) Check(ctx context.Context) (string, error) { + if err := contextError(ctx); err != nil { + return "", err + } + if s == nil || s.backend == nil { + return "", nil + } + release, err := s.backend.Check(ctx) + if err != nil { + return "", updateError(err, "Unable to check for updates") + } + if release == nil { + return "", nil + } + return release.Version, nil +} + +func (s *UpdateService) DownloadAndInstall(ctx context.Context) error { + if err := contextError(ctx); err != nil { + return err + } + if s == nil || s.backend == nil { + return notReady("Update service is not configured") + } + if err := s.backend.DownloadAndInstall(ctx); err != nil { + return updateError(err, "Unable to download the OneAgent update") + } + return nil +} + +func (s *UpdateService) Restart(ctx context.Context) error { + if err := contextError(ctx); err != nil { + return err + } + if s == nil || s.backend == nil { + return notReady("Update service is not configured") + } + if err := s.backend.Restart(ctx); err != nil { + return updateError(err, "Unable to restart OneAgent for update") + } + return nil +} + +func updateError(err error, message string) error { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return oneerrors.New(oneerrors.Timeout, message+" was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return oneerrors.New(oneerrors.InternalError, message, oneerrors.WithStatus(500), oneerrors.WithRetryable(true), oneerrors.WithCause(err)) +} + +func UpdateProgressOutput(payload any) (process.Output, bool) { + var progress updater.Progress + switch value := payload.(type) { + case updater.Progress: + progress = value + case *updater.Progress: + if value == nil { + return process.Output{}, false + } + progress = *value + default: + return process.Output{}, false + } + return process.Output{Kind: "progress", Target: UpdateProgressTarget, Received: progress.Written, Total: progress.Total}, true +} diff --git a/internal/binding/update_test.go b/internal/binding/update_test.go new file mode 100644 index 00000000..5e6b7c31 --- /dev/null +++ b/internal/binding/update_test.go @@ -0,0 +1,187 @@ +package binding + +import ( + "context" + "errors" + "reflect" + "testing" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/wailsapp/wails/v3/pkg/updater" +) + +type updateBackendFake struct { + check func(context.Context) (*updater.Release, error) + downloadAndInstall func(context.Context) error + restart func(context.Context) error +} + +func (f *updateBackendFake) Check(ctx context.Context) (*updater.Release, error) { + return f.check(ctx) +} + +func (f *updateBackendFake) DownloadAndInstall(ctx context.Context) error { + return f.downloadAndInstall(ctx) +} + +func (f *updateBackendFake) Restart(ctx context.Context) error { + return f.restart(ctx) +} + +func TestUpdateServiceCheckDisabledAndCurrent(t *testing.T) { + for name, service := range map[string]*UpdateService{ + "nil service": nil, + "nil backend": NewUpdateService(nil), + "current": NewUpdateService(&updateBackendFake{check: func(context.Context) (*updater.Release, error) { + return nil, nil + }}), + } { + t.Run(name, func(t *testing.T) { + version, err := service.Check(context.Background()) + if err != nil || version != "" { + t.Fatalf("Check() = %q, %v", version, err) + } + }) + } + + for name, call := range map[string]func(*UpdateService) error{ + "download": func(service *UpdateService) error { return service.DownloadAndInstall(context.Background()) }, + "restart": func(service *UpdateService) error { return service.Restart(context.Background()) }, + } { + t.Run(name+" not configured", func(t *testing.T) { + err := call(NewUpdateService(nil)) + got := oneerrors.As(err) + if got.Message != "Update service is not configured" || got.Code != oneerrors.InternalError || got.Status != 501 { + t.Fatalf("error = %#v", got) + } + }) + } +} + +func TestUpdateServiceDelegatesWithCallerContext(t *testing.T) { + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "caller") + var calls []string + backend := &updateBackendFake{ + check: func(got context.Context) (*updater.Release, error) { + if got != ctx { + t.Fatalf("Check context = %v, want caller context", got) + } + calls = append(calls, "check") + return &updater.Release{Version: "1.2.3"}, nil + }, + downloadAndInstall: func(got context.Context) error { + if got != ctx { + t.Fatalf("DownloadAndInstall context = %v, want caller context", got) + } + calls = append(calls, "download") + return nil + }, + restart: func(got context.Context) error { + if got != ctx { + t.Fatalf("Restart context = %v, want caller context", got) + } + calls = append(calls, "restart") + return nil + }, + } + service := NewUpdateService(backend) + + version, err := service.Check(ctx) + if err != nil || version != "1.2.3" { + t.Fatalf("Check() = %q, %v", version, err) + } + if err := service.DownloadAndInstall(ctx); err != nil { + t.Fatal(err) + } + if err := service.Restart(ctx); err != nil { + t.Fatal(err) + } + if want := []string{"check", "download", "restart"}; !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %v, want %v", calls, want) + } +} + +func TestUpdateServiceRejectsCancelledContextBeforeDelegation(t *testing.T) { + calls := 0 + backend := &updateBackendFake{ + check: func(context.Context) (*updater.Release, error) { calls++; return nil, nil }, + downloadAndInstall: func(context.Context) error { calls++; return nil }, + restart: func(context.Context) error { calls++; return nil }, + } + service := NewUpdateService(backend) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, checkErr := service.Check(ctx) + errs := []error{checkErr, service.DownloadAndInstall(ctx), service.Restart(ctx)} + for _, err := range errs { + got := oneerrors.As(err) + if got.Code != oneerrors.Timeout || got.Message != "Request was cancelled" || !got.Retryable || !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %#v, cause = %v", got, errors.Unwrap(got)) + } + } + if calls != 0 { + t.Fatalf("backend calls = %d, want 0", calls) + } +} + +func TestUpdateServiceConvertsBackendFailures(t *testing.T) { + tests := []struct { + name string + message string + call func(*UpdateService) error + }{ + {"check", "Unable to check for updates", func(service *UpdateService) error { _, err := service.Check(context.Background()); return err }}, + {"download", "Unable to download the OneAgent update", func(service *UpdateService) error { return service.DownloadAndInstall(context.Background()) }}, + {"restart", "Unable to restart OneAgent for update", func(service *UpdateService) error { return service.Restart(context.Background()) }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cause := errors.New("private backend detail") + service := NewUpdateService(&updateBackendFake{ + check: func(context.Context) (*updater.Release, error) { return nil, cause }, + downloadAndInstall: func(context.Context) error { return cause }, + restart: func(context.Context) error { return cause }, + }) + err := test.call(service) + got := oneerrors.As(err) + if got.Code != oneerrors.InternalError || got.Message != test.message || got.Status != 500 || !got.Retryable || !errors.Is(err, cause) { + t.Fatalf("error = %#v, cause = %v", got, errors.Unwrap(got)) + } + + cancelled := NewUpdateService(&updateBackendFake{ + check: func(context.Context) (*updater.Release, error) { return nil, context.DeadlineExceeded }, + downloadAndInstall: func(context.Context) error { return context.DeadlineExceeded }, + restart: func(context.Context) error { return context.DeadlineExceeded }, + }) + err = test.call(cancelled) + got = oneerrors.As(err) + if got.Code != oneerrors.Timeout || got.Message != test.message+" was cancelled" || !got.Retryable || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cancellation error = %#v, cause = %v", got, errors.Unwrap(got)) + } + }) + } +} + +func TestUpdateServiceProgressOutput(t *testing.T) { + want := process.Output{Kind: "progress", Target: UpdateProgressTarget, Received: 12, Total: 0} + progress := updater.Progress{Written: 12} + for name, payload := range map[string]any{"value": progress, "pointer": &progress} { + t.Run(name, func(t *testing.T) { + got, ok := UpdateProgressOutput(payload) + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("UpdateProgressOutput() = %#v, %t", got, ok) + } + }) + } + var nilProgress *updater.Progress + for name, payload := range map[string]any{"unrelated": "progress", "nil pointer": nilProgress} { + t.Run(name, func(t *testing.T) { + if got, ok := UpdateProgressOutput(payload); ok || !reflect.DeepEqual(got, process.Output{}) { + t.Fatalf("UpdateProgressOutput() = %#v, %t", got, ok) + } + }) + } +} From a428d4ab0ad943e56f07f5586bb9b6baddcd36d4 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:02:40 +0800 Subject: [PATCH 04/18] feat: wire GitHub Releases into the desktop updater --- cmd/oneagent-desktop/main_wails.go | 34 +++++++++++++++++++ .../OneAgent/internal/binding/index.ts | 4 ++- .../internal/binding/updateservice.ts | 18 ++++++++++ go.mod | 1 + go.sum | 2 ++ 5 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go index 616d0771..b788ed28 100644 --- a/cmd/oneagent-desktop/main_wails.go +++ b/cmd/oneagent-desktop/main_wails.go @@ -12,9 +12,33 @@ import ( "github.com/MaimoryLab/OneAgent/internal/binding" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/MaimoryLab/OneAgent/internal/version" "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/updater" + "github.com/wailsapp/wails/v3/pkg/updater/providers/github" ) +func configureUpdater(appInstance *application.App) binding.UpdateBackend { + current := version.UpdaterVersion() + if current == "" { + return nil + } + provider, err := github.New(github.Config{Repository: "MaimoryLab/OneAgent", ChecksumAsset: "SHA256SUMS"}) + if err != nil { + slog.Error("OneAgent updater provider is unavailable", "error", err) + return nil + } + if err := appInstance.Updater.Init(updater.Config{ + CurrentVersion: current, + Providers: []updater.Provider{provider}, + Window: updater.WindowNone, + }); err != nil { + slog.Error("OneAgent updater is unavailable", "error", err) + return nil + } + return appInstance.Updater +} + func main() { var appInstance *application.App core := newDesktopUseCases() @@ -70,6 +94,16 @@ func main() { }, Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, }) + updateBackend := configureUpdater(appInstance) + appInstance.Event.On(updater.EventDownloadProgress, func(event *application.CustomEvent) { + if event == nil { + return + } + if output, ok := binding.UpdateProgressOutput(event.Data); ok { + appInstance.Event.Emit("oneagent:install-output", output) + } + }) + appInstance.RegisterService(application.NewServiceWithOptions(binding.NewUpdateService(updateBackend), application.ServiceOptions{MarshalError: oneerrors.Marshal})) if !application.System.IsServer() { // A floor, not a second breakpoint: the sidebar deliberately collapses to // a 72px icon rail under 900px, and the layout is verified down to 560px. diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts index de02370f..4c199d28 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -7,13 +7,15 @@ import * as ProfileService from "./profileservice.js"; import * as ProviderService from "./providerservice.js"; import * as RuntimeService from "./runtimeservice.js"; import * as StatusService from "./statusservice.js"; +import * as UpdateService from "./updateservice.js"; export { AgentService, DesktopAgentService, ProfileService, ProviderService, RuntimeService, - StatusService + StatusService, + UpdateService }; export type { diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts new file mode 100644 index 00000000..81de709d --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.ts @@ -0,0 +1,18 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +export function Check(): $CancellablePromise { + return $Call.ByID(4243899107); +} + +export function DownloadAndInstall(): $CancellablePromise { + return $Call.ByID(697947605); +} + +export function Restart(): $CancellablePromise { + return $Call.ByID(841440456); +} diff --git a/go.mod b/go.mod index 370b78bd..1a5cda97 100644 --- a/go.mod +++ b/go.mod @@ -15,5 +15,6 @@ require ( github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 03a0eeef..2a942696 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA= github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= From f39ed059b581a4dd763167e9fde1e0950baabada Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:24:28 +0800 Subject: [PATCH 05/18] feat: expose OTA calls to the frontend --- frontend/src/backend/wails.test.ts | 25 +++++++++++++++++++++++++ frontend/src/backend/wails.ts | 5 +++++ 2 files changed, 30 insertions(+) diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index e24cd621..295fbbdb 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -20,6 +20,9 @@ const bridge = vi.hoisted(() => ({ desktopConfigure: vi.fn(), profiles: vi.fn(), saveProfile: vi.fn(), + updateCheck: vi.fn(), + updateDownload: vi.fn(), + updateRestart: vi.fn(), eventsOn: vi.fn(), })); @@ -51,6 +54,11 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profiles ListProfiles: bridge.profiles, SaveProfile: bridge.saveProfile, })); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js", () => ({ + Check: bridge.updateCheck, + DownloadAndInstall: bridge.updateDownload, + Restart: bridge.updateRestart, +})); import { CancellablePromise } from "@wailsio/runtime"; import { INSTALL_OUTPUT_EVENT, normalizeWailsError, onInstallOutput, wailsApi } from "./wails"; @@ -150,6 +158,23 @@ describe("Wails backend adapter", () => { await expect(rejection).resolves.toMatchObject({ name: "CancelError" }); }); + it("forwards OTA calls and preserves download cancellation", async () => { + const oncancelled = vi.fn(); + bridge.updateCheck.mockResolvedValue("1.2.3"); + bridge.updateDownload.mockReturnValue(new CancellablePromise(() => {}, oncancelled)); + bridge.updateRestart.mockResolvedValue(undefined); + + await expect(wailsApi.checkUpdate()).resolves.toBe("1.2.3"); + const request = wailsApi.downloadUpdate(); + expect(typeof request.cancel).toBe("function"); + await request.cancel?.(); + await expect(wailsApi.restartUpdate()).resolves.toBeUndefined(); + expect(oncancelled).toHaveBeenCalledOnce(); + expect(bridge.updateCheck).toHaveBeenCalledWith(); + expect(bridge.updateDownload).toHaveBeenCalledWith(); + expect(bridge.updateRestart).toHaveBeenCalledWith(); + }); + it("subscribes to and filters installation output events", () => { const unsubscribe = vi.fn(); const listener = vi.fn(); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index dca25c5d..91d550ab 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -6,6 +6,7 @@ import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/i import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; import * as RuntimeService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.js"; import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js"; +import * as UpdateService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js"; import type { ActivateAgentResponse, DesktopAgentActionResult, @@ -33,6 +34,7 @@ import { currentLocale, translate } from "../i18n"; import { isCancellationError, OneAgentApiError } from "./errors"; export const INSTALL_OUTPUT_EVENT = "oneagent:install-output"; +export const OTA_PROGRESS_TARGET = "oneagent-update"; export function onInstallOutput(listener: (output: InstallOutput) => void): () => void { return Events.On(INSTALL_OUTPUT_EVENT, (event) => { @@ -98,6 +100,9 @@ function call(operation: () => PromiseLike): CancellableRequest { export const wailsApi = { onInstallOutput, status: (): Promise => call(() => StatusService.GetStatus()) as Promise, + checkUpdate: (): Promise => call(() => UpdateService.Check()) as Promise, + downloadUpdate: (): CancellableRequest => call(() => UpdateService.DownloadAndInstall()) as CancellableRequest, + restartUpdate: (): Promise => call(() => UpdateService.Restart()).then(() => undefined), desktopAgentStatus: (agentId: string): Promise => call(() => DesktopAgentService.GetStatus({ agent_id: agentId })) as Promise, installDesktopAgent: (agentId: string): CancellableRequest => From c895d18de786541f0e23df3e40e5d7d6e16788ac Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:29:44 +0800 Subject: [PATCH 06/18] feat: add terminal actions to task cards --- frontend/src/components/TaskCenter.test.tsx | 16 ++++++++++++- frontend/src/components/TaskCenter.tsx | 10 ++++++-- frontend/src/state/TaskCenterContext.tsx | 26 +++++++++++++++++++-- frontend/src/styles/app.css | 18 ++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/TaskCenter.test.tsx b/frontend/src/components/TaskCenter.test.tsx index c29f73dd..53fbfcb6 100644 --- a/frontend/src/components/TaskCenter.test.tsx +++ b/frontend/src/components/TaskCenter.test.tsx @@ -12,6 +12,7 @@ import { TaskCenter } from "./TaskCenter"; let emit: ((output: InstallOutput) => void) | null = null; const unsubscribe = vi.fn(); const cancelRequest = vi.fn(); +const terminalAction = vi.fn(); vi.mock("../backend/api", async () => { const errors = await import("../backend/errors"); @@ -41,13 +42,14 @@ const installTask: TaskInput = { }; function TaskHarness() { - const { startTask, finishTask, setTaskCanceller } = useTaskCenter(); + const { startTask, finishTask, setTaskCanceller, setTaskAction } = useTaskCenter(); return (
+ + {task.state !== "running" && task.action ? ( + + ) : null} {task.state === "running" ? ( + + + + ); +} +~~~ + +Add this complete helper beside the existing renderTaskCenter helper: + +~~~tsx +import type { ReactNode } from "react"; + +function renderTaskCenterWith(children: ReactNode, initialEntry = "/overview") { + return render( + + + + {children} + + } /> + + + , + ); +} +~~~ + +Add this assertion: + +~~~tsx +it("renders and invokes an optional terminal action", async () => { + const action = vi.fn(); + const user = userEvent.setup(); + renderTaskCenterWith(); + await user.click(screen.getByRole("button", { name: "启动更新" })); + await user.click(screen.getByRole("button", { name: "完成更新" })); + await user.click(screen.getByRole("button", { name: "添加操作" })); + await user.click(screen.getByRole("button", { name: "重启并更新" })); + expect(action).toHaveBeenCalledOnce(); +}); +~~~ + +- [ ] **Step 2: Run the focused test and verify red** + +Run: cd frontend && pnpm test -- src/components/TaskCenter.test.tsx + +Expected: FAIL because TaskAction, setTaskAction, and the action button do not exist. + +- [ ] **Step 3: Extend the context with the minimum terminal-action state** + +Replace the existing task type block with these complete definitions: + +~~~tsx +export interface TaskAction { + label: string; + run: () => void | PromiseLike; +} + +export interface TaskInput { + id?: string; + kind: TaskKind; + target: string; + title: string; + route: string; + progressTarget?: string; + group?: string; + action?: TaskAction; +} + +export interface TaskRecord extends TaskInput { + id: string; + progressTarget: string; + state: TaskState; + progress?: TaskProgress; + message?: string; + startedAt: number; +} +~~~ + +Add these methods to TaskCenterValue and the default context: + +~~~tsx +setTaskAction: (id: string, action?: TaskAction) => void; +setTaskMessage: (id: string, message: string) => void; +~~~ + +Implement them beside finishTask: + +~~~tsx +const setTaskAction = useCallback((id: string, action?: TaskAction) => { + updateTasks((current) => current.map((task) => ( + task.id === id || task.target === id ? { ...task, action } : task + ))); +}, [updateTasks]); + +const setTaskMessage = useCallback((id: string, message: string) => { + updateTasks((current) => current.map((task) => ( + task.id === id || task.target === id ? { ...task, message } : task + ))); +}, [updateTasks]); +~~~ + +Include both callbacks in the provider value and dependency list. The existing finishTask spread preserves action, so a success or failure transition does not lose the restart retry. + +- [ ] **Step 4: Render the action as a sibling button** + +Import RefreshCw from lucide-react. Add has-action to the card class when the task has a terminal action, then render this after the main card button and before the dismiss button: + +~~~tsx +{task.state !== "running" && task.action ? ( + +) : null} +~~~ + +This keeps the DOM valid: the action is never nested inside the existing main button. + +- [ ] **Step 5: Add compact responsive styling** + +Append these rules beside the existing task-card rules: + +~~~css +.task-card.has-action { display: grid; grid-template-columns: minmax(0, 1fr); } +.task-card-action { + min-height: 27px; + margin: 0 30px 7px 10px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + color: var(--text-primary); + background: var(--window-bg); + font: inherit; + font-size: 11px; + cursor: pointer; +} +.task-card-action:hover { background: var(--surface-pressed); } +~~~ + +- [ ] **Step 6: Run the focused tests and build** + +Run: cd frontend && pnpm test -- src/components/TaskCenter.test.tsx && pnpm run typecheck + +Expected: PASS; existing progress, cancellation, and dismissal tests stay green. + +- [ ] **Step 7: Commit the task-center extension** + +~~~bash +git add frontend/src/state/TaskCenterContext.tsx frontend/src/components/TaskCenter.tsx frontend/src/components/TaskCenter.test.tsx frontend/src/styles/app.css +git commit -m "feat: add terminal actions to task cards" +~~~ ++ + +### Task 6: Startup Coordinator and Native Confirmation + +**Files:** +- Create: frontend/src/components/AppUpdater.tsx +- Create: frontend/src/components/AppUpdater.test.tsx +- Modify: frontend/src/i18n.tsx +- Modify: frontend/src/App.tsx + +- [ ] **Step 1: Write failing coordinator tests and runtime mocks** + +Create AppUpdater.test.tsx with these mocks and a deferred promise helper: + +~~~tsx +import { StrictMode } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const bridge = vi.hoisted(() => ({ + check: vi.fn(), + download: vi.fn(), + restart: vi.fn(), + question: vi.fn(), + onInstallOutput: vi.fn(() => vi.fn()), +})); + +beforeEach(() => { + vi.clearAllMocks(); + bridge.onInstallOutput.mockReturnValue(vi.fn()); +}); + +vi.mock("@wailsio/runtime", () => ({ + Dialogs: { Question: bridge.question }, +})); +vi.mock("../backend/api", () => ({ + api: { + checkUpdate: bridge.check, + downloadUpdate: bridge.download, + restartUpdate: bridge.restart, + onInstallOutput: bridge.onInstallOutput, + }, + describeError: (error: unknown, fallback: string) => ({ + message: error instanceof Error ? error.message : fallback, + }), + isCancellationError: (error: unknown) => ( + error && typeof error === "object" && (error as { name?: unknown }).name === "CancelError" + ), + taskCanceller: (request: { cancel?: () => void }) => request.cancel ? () => request.cancel?.() : undefined, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +} +~~~ + +Add the provider wrapper used by every test: + +~~~tsx +import type { PropsWithChildren } from "react"; +import { I18nProvider } from "../i18n"; +import { TaskCenter } from "./TaskCenter"; +import { TaskCenterProvider } from "../state/TaskCenterContext"; +import { AppUpdater } from "./AppUpdater"; + +function TestProviders({ children }: PropsWithChildren) { + return ( + + + + {children} + + + ); +} +~~~ + +Render the component with TestProviders. Add cases that assert: StrictMode calls +Check once; Not now calls no download; Update creates a card and attaches/calls +cancel; a resolved request renders Restart and update; and a rejected restart +leaves that action available with the failure message. + +The approved-download assertion should look for the versioned task title +Update OneAgent 1.4.0, then click the card's Cancel task control and assert the +fake request's cancel function was called exactly once. + +Use these concrete terminal-action assertions: + +~~~tsx +it("finishes with a restart action", async () => { + bridge.check.mockResolvedValue("1.4.0"); + bridge.question.mockResolvedValue("更新"); + bridge.download.mockResolvedValue(undefined); + bridge.restart.mockResolvedValue(undefined); + const user = userEvent.setup(); + render(, { wrapper: TestProviders }); + await user.click(await screen.findByRole("button", { name: "重启并更新" })); + expect(bridge.restart).toHaveBeenCalledOnce(); +}); + +it("keeps restart available and reports a restart failure", async () => { + bridge.check.mockResolvedValue("1.4.0"); + bridge.question.mockResolvedValue("更新"); + bridge.download.mockResolvedValue(undefined); + bridge.restart.mockRejectedValue(new Error("restart failed")); + const user = userEvent.setup(); + render(, { wrapper: TestProviders }); + const action = await screen.findByRole("button", { name: "重启并更新" }); + await user.click(action); + expect(await screen.findByText("restart failed")).toBeTruthy(); + expect(screen.getByRole("button", { name: "重启并更新" })).toBeTruthy(); +}); +~~~ + +- [ ] **Step 2: Run the focused test and verify red** + +Run: cd frontend && pnpm test -- src/components/AppUpdater.test.tsx + +Expected: FAIL because AppUpdater and the new backend methods do not exist. + +- [ ] **Step 3: Add the update strings** + +Add these keys to the english dictionary in frontend/src/i18n.tsx: + +~~~ts + "OneAgent 更新": "OneAgent update", + "发现 OneAgent 新版本 {version},现在下载吗?": "OneAgent {version} is available. Download it now?", + "暂不": "Not now", + "更新 OneAgent": "Update OneAgent", + "更新 OneAgent {version}": "Update OneAgent {version}", + "更新已下载": "Update downloaded", + "重启并更新": "Restart and update", + "更新失败": "Update failed", + "无法重启并更新": "Could not restart and update", +~~~ + +- [ ] **Step 4: Implement the one-shot coordinator** + +Create frontend/src/components/AppUpdater.tsx: + +~~~tsx +import { Dialogs } from "@wailsio/runtime"; +import { useCallback, useEffect, useRef } from "react"; + +import { api, describeError, isCancellationError, taskCanceller } from "../backend/api"; +import { OTA_PROGRESS_TARGET } from "../backend/wails"; +import { useI18n } from "../i18n"; +import { taskKey, useTaskCenter } from "../state/TaskCenterContext"; + +export const OTA_TASK_ID = taskKey("update", OTA_PROGRESS_TARGET); + +export function AppUpdater() { + const { t } = useI18n(); + const { + startTask, finishTask, setTaskCanceller, setTaskAction, setTaskMessage, isTaskRunning, + } = useTaskCenter(); + + const restart = useCallback(async () => { + try { + await api.restartUpdate(); + } catch (error) { + setTaskMessage(OTA_TASK_ID, describeError(error, t("无法重启并更新")).message); + } + }, [setTaskMessage, t]); + + const download = useCallback(async (version: string) => { + if (isTaskRunning(OTA_TASK_ID) || !startTask({ + id: OTA_TASK_ID, + kind: "update", + target: OTA_PROGRESS_TARGET, + progressTarget: OTA_PROGRESS_TARGET, + title: t("更新 OneAgent {version}", { version }), + route: "/overview", + })) return; + + try { + const request = api.downloadUpdate(); + setTaskCanceller(OTA_TASK_ID, taskCanceller(request)); + await request; + finishTask(OTA_TASK_ID, { kind: "success", message: t("更新已下载") }); + setTaskAction(OTA_TASK_ID, { label: t("重启并更新"), run: restart }); + } catch (error) { + const cancelled = isCancellationError(error); + finishTask(OTA_TASK_ID, { + kind: cancelled ? "cancelled" : "failure", + message: cancelled ? t("已取消") : describeError(error, t("更新失败")).message, + }); + } + }, [finishTask, isTaskRunning, restart, setTaskAction, setTaskCanceller, startTask, t]); + + const check = useCallback(async () => { + if (isTaskRunning(OTA_TASK_ID)) return; + let version = ""; + try { + version = await api.checkUpdate(); + } catch { + return; + } + if (!version || isTaskRunning(OTA_TASK_ID)) return; + + try { + const update = t("更新"); + const choice = await Dialogs.Question({ + Title: t("OneAgent 更新"), + Message: t("发现 OneAgent 新版本 {version},现在下载吗?", { version }), + Buttons: [ + { Label: update, IsDefault: true }, + { Label: t("暂不"), IsCancel: true }, + ], + }); + if (choice === update) await download(version); + } catch { + // A background prompt failure must not interrupt startup. + } + }, [download, isTaskRunning, t]); + + const started = useRef(false); + useEffect(() => { + if (started.current) return; + started.current = true; + void check(); + }, [check]); + + return null; +} +~~~ + +The version parameter supplies visible dialog and task text; Wails retains the checked pending release, so the frontend never picks an asset or reimplements version comparison. Not now is not persisted. + +- [ ] **Step 5: Mount it inside the existing provider** + +Change the provider tree in frontend/src/App.tsx to: + +~~~tsx + + + + + + +~~~ + +Add the import from ./components/AppUpdater. + +- [ ] **Step 6: Run coordinator and frontend tests** + +Run: cd frontend && pnpm test -- src/components/AppUpdater.test.tsx src/components/TaskCenter.test.tsx && pnpm run typecheck + +Expected: PASS; ignored updates create no card, approved downloads are cancellable, successful downloads expose the restart action, and restart errors leave that action available. + +- [ ] **Step 7: Commit the frontend OTA flow** + +~~~bash +git add frontend/src/components/AppUpdater.tsx frontend/src/components/AppUpdater.test.tsx frontend/src/i18n.tsx frontend/src/App.tsx +git commit -m "feat: add consented OTA task flow" +~~~ ++ + +### Task 7: Tag Release Packaging and Checksums + +**Files:** +- Modify: .github/workflows/build-artifacts.yml +- Modify: README.md + +- [ ] **Step 1: Change the workflow trigger and release permissions** + +Replace the manual-only trigger/env block with: + +~~~yaml +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + version: + description: Release version (for example v0.3.0) + required: true + type: string + default: v0.0.0 + +permissions: + contents: read + +env: + RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} +~~~ + +The existing validation command remains the exact gate: + +~~~bash +if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version must match vX.Y.Z" >&2 + exit 1 +fi +~~~ + +- [ ] **Step 2: Keep the existing build labels and add OTA asset labels** + +The license inventory and existing artifact checks use x64 as the amd64 label, so +do not rename that matrix value. Replace the string matrix with an object that +preserves the existing label while adding the Wails-facing archive token: + +~~~yaml + arch: + - label: x64 + goarch: amd64 + ota: amd64 + - label: arm64 + goarch: arm64 + ota: arm64 + + env: + CGO_ENABLED: ${{ matrix.target.cgo }} + CGO_CFLAGS: ${{ matrix.target.platform == 'macos' && '-mmacosx-version-min=12.0' || '' }} + CGO_LDFLAGS: ${{ matrix.target.platform == 'macos' && '-mmacosx-version-min=12.0' || '' }} + GOARCH: ${{ matrix.arch.goarch }} + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.target.platform == 'macos' && '12.0' || '' }} +~~~ + +Change the build job name to use the matrix arch label. Use the label field for +the existing third-party verification and non-OTA artifact names; use the ota +field only for OTA zip names. Keep the existing linker flag exactly: + +~~~bash +version_ldflag="-X github.com/MaimoryLab/OneAgent/internal/version.Version=$RELEASE_VERSION" +~~~ + +The concrete substitutions are: + +~~~yaml + name: ${{ matrix.target.platform }} ${{ matrix.arch.label }} + --verify-platform "${{ matrix.target.platform }}-${{ matrix.arch.label }}" + name: OneAgent-${{ matrix.target.platform }}-${{ matrix.arch.label }} +~~~ + +- [ ] **Step 3: Zip exactly one top-level desktop payload per matrix entry** + +After the existing macOS bundle/license verification, add: + +~~~yaml + - name: Package macOS OTA archive + if: matrix.target.platform == 'macos' + shell: bash + run: | + (cd bin && zip -qry "OneAgent-darwin-${{ matrix.arch.ota }}.zip" OneAgent.app) + roots=$(unzip -Z1 "bin/OneAgent-darwin-${{ matrix.arch.ota }}.zip" | awk -F/ 'NF {print $1}' | sort -u) + test "$roots" = "OneAgent.app" + + - name: Package Windows OTA archive + if: matrix.target.platform == 'windows' + shell: pwsh + run: | + $archive = "bin/OneAgent-windows-${{ matrix.arch.ota }}.zip" + Compress-Archive -Path bin/oneagent-desktop.exe -DestinationPath $archive -CompressionLevel Optimal + $roots = @(tar -tf $archive | ForEach-Object { ($_ -split '/')[0] } | Sort-Object -Unique) + if ($roots.Count -ne 1 -or $roots[0] -ne 'oneagent-desktop.exe') { throw 'OTA archive has unexpected top-level entries' } + + - name: Upload OTA archive + uses: actions/upload-artifact@v7 + with: + name: ota-${{ matrix.target.platform }}-${{ matrix.arch.label }} + path: bin/OneAgent-${{ matrix.target.platform }}-${{ matrix.arch.ota }}.zip + if-no-files-found: error +~~~ + +The macOS archive is made from OneAgent.app only; the Windows archive is made +from oneagent-desktop.exe only. Compliance files remain inside the macOS app +bundle as they do in the existing package. + +- [ ] **Step 4: Add a release job that generates SHA256SUMS and creates or updates the release** + +Append this job: + +~~~yaml + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/download-artifact@v8 + with: + pattern: ota-* + path: release-assets + merge-multiple: true + + - name: Validate OTA set and create checksum manifest + shell: bash + run: | + set -euo pipefail + cd release-assets + test "$(find . -maxdepth 1 -type f -name 'OneAgent-*.zip' | wc -l)" -eq 4 + printf '%s\n' OneAgent-darwin-amd64.zip OneAgent-darwin-arm64.zip OneAgent-windows-amd64.zip OneAgent-windows-arm64.zip | while read -r name; do + test -f "$name" + done + sha256sum OneAgent-*.zip > SHA256SUMS + test "$(wc -l < SHA256SUMS)" -eq 4 + + - name: Create or update GitHub Release + shell: bash + run: | + set -euo pipefail + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "$RELEASE_VERSION" release-assets/* --clobber + else + gh release create "$RELEASE_VERSION" release-assets/* --verify-tag --title "$RELEASE_VERSION" --generate-notes + fi +~~~ + +The job has write permission only where publication occurs. SHA256SUMS uses the exact archive base names that the Wails GitHub provider requests through ChecksumAsset: SHA256SUMS. + +- [ ] **Step 5: Update the release documentation** + +Replace the manual-release paragraph in README.md with: + +~~~md +Release packages are built and published by .github/workflows/build-artifacts.yml +when a stable vX.Y.Z tag is pushed. It publishes macOS and Windows amd64/arm64 +OTA archives plus SHA256SUMS; the macOS archives contain OneAgent.app and the +Windows archives contain oneagent-desktop.exe. +~~~ + +- [ ] **Step 6: Validate workflow text and commit** + +Run: + +~~~bash +git diff --check +ruby -e 'require "yaml"; YAML.load_file(".github/workflows/build-artifacts.yml"); puts "workflow yaml parsed"' +python3 scripts/check-docs.py +~~~ + +Expected: no whitespace errors, YAML parses, and documentation checks pass. + +~~~bash +git add .github/workflows/build-artifacts.yml README.md +git commit -m "ci: publish OTA archives on version tags" +~~~ + +### Task 8: Full Verification and Handoff + +**Files:** +- No new files; inspect all changes and generated output. + +- [ ] **Step 1: Regenerate bindings from the current source** + +Run: task generate:bindings + +Expected: the pinned Wails CLI completes and produces no unexpected edits beyond the updater service bindings. + +- [ ] **Step 2: Run all Go checks** + +Run: go test ./... && go vet ./... + +Expected: PASS. If the host lacks native Wails libraries, use the repository's existing non-Wails Go suite and separately run: + +~~~bash +go test -tags wails ./cmd/oneagent-desktop ./internal/binding -run '^$' +~~~ + +- [ ] **Step 3: Run all frontend checks** + +Run: cd frontend && pnpm test && pnpm run build + +Expected: all Vitest tests pass and typecheck plus Vite production build complete. + +- [ ] **Step 4: Validate archive rules with disposable fixtures** + +Run the same root-entry checks against temporary fixtures without touching tracked files: + +~~~bash +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/OneAgent.app/Contents/MacOS" +touch "$tmp_dir/OneAgent.app/Contents/MacOS/oneagent-desktop" +(cd "$tmp_dir" && zip -qry OneAgent-darwin-amd64.zip OneAgent.app) +test "$(unzip -Z1 "$tmp_dir/OneAgent-darwin-amd64.zip" | awk -F/ 'NF {print $1}' | sort -u)" = "OneAgent.app" +printf 'binary' > "$tmp_dir/oneagent-desktop.exe" +(cd "$tmp_dir" && zip -qry OneAgent-windows-amd64.zip oneagent-desktop.exe) +test "$(unzip -Z1 "$tmp_dir/OneAgent-windows-amd64.zip" | awk -F/ 'NF {print $1}' | sort -u)" = "oneagent-desktop.exe" +echo "archive checks passed" +~~~ + +Expected: archive checks passed. + +- [ ] **Step 5: Review the diff against the specification** + +Run: + +~~~bash +git diff --stat +git diff --check +git status --short +~~~ + +Confirm every spec section has an implementation: release-only startup check, native consent, current-launch ignore, task progress/cancellation/failure, restart retry, four exact archives, checksum manifest, and tag publication. Do not add forced-update, prerelease, skip-version persistence, signing keys, or custom updater-window code; they are explicit non-goals. + +- [ ] **Step 6: Commit the verified final state** + +~~~bash +git add docs/superpowers/plans/2026-08-06-ota-updater.md internal/version internal/binding cmd/oneagent-desktop frontend .github/workflows/build-artifacts.yml README.md go.mod go.sum +git commit -m "feat: complete GitHub Releases OTA updates" +~~~ + +## Self-Review Checklist + +- Spec coverage: Tasks 1-3 cover version gating, GitHub provider configuration, checksum selection, Wails delegation, and progress translation. Tasks 4-6 cover generated bindings, native confirmation, one-shot startup, task-center cancellation/action/failure behavior. Task 7 covers tag release packaging and publication. Task 8 covers requested verification. +- Placeholder scan: every task step has a concrete snippet and command with an expected result; no deferred implementation is hidden behind a vague instruction. +- Type consistency: OTA_PROGRESS_TARGET is the shared frontend target, UpdateProgressTarget is its Go counterpart, UpdateBackend is the exact interface accepted by NewUpdateService, and the generated methods are Check, DownloadAndInstall, and Restart throughout. From bbe9ff7ebc3b31b46543db60d137012b9e4f5dfb Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:24:16 +0800 Subject: [PATCH 13/18] fix: require checksums for OTA releases --- internal/binding/update.go | 4 ++++ internal/binding/update_test.go | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/binding/update.go b/internal/binding/update.go index 2f7848a5..954d345b 100644 --- a/internal/binding/update.go +++ b/internal/binding/update.go @@ -2,6 +2,7 @@ package binding import ( "context" + "crypto/sha256" "errors" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" @@ -39,6 +40,9 @@ func (s *UpdateService) Check(ctx context.Context) (string, error) { if release == nil { return "", nil } + if release.Verification == nil || release.Verification.DigestAlgo != "sha256" || len(release.Verification.Digest) != sha256.Size { + return "", updateError(errors.New("invalid update verification"), "Unable to check for updates") + } return release.Version, nil } diff --git a/internal/binding/update_test.go b/internal/binding/update_test.go index 5e6b7c31..c7f932cb 100644 --- a/internal/binding/update_test.go +++ b/internal/binding/update_test.go @@ -2,6 +2,7 @@ package binding import ( "context" + "crypto/sha256" "errors" "reflect" "testing" @@ -69,7 +70,7 @@ func TestUpdateServiceDelegatesWithCallerContext(t *testing.T) { t.Fatalf("Check context = %v, want caller context", got) } calls = append(calls, "check") - return &updater.Release{Version: "1.2.3"}, nil + return &updater.Release{Version: "1.2.3", Verification: &updater.Verification{DigestAlgo: "sha256", Digest: make([]byte, sha256.Size)}}, nil }, downloadAndInstall: func(got context.Context) error { if got != ctx { @@ -103,6 +104,30 @@ func TestUpdateServiceDelegatesWithCallerContext(t *testing.T) { } } +func TestUpdateServiceCheckRejectsInvalidVerification(t *testing.T) { + tests := map[string]*updater.Verification{ + "missing": nil, + "wrong algorithm": {DigestAlgo: "sha512", Digest: make([]byte, sha256.Size)}, + "wrong digest size": {DigestAlgo: "sha256", Digest: make([]byte, sha256.Size-1)}, + } + for name, verification := range tests { + t.Run(name, func(t *testing.T) { + service := NewUpdateService(&updateBackendFake{check: func(context.Context) (*updater.Release, error) { + return &updater.Release{Version: "1.2.3", Verification: verification}, nil + }}) + + version, err := service.Check(context.Background()) + got := oneerrors.As(err) + if version != "" || got.Code != oneerrors.InternalError || got.Message != "Unable to check for updates" || got.Status != 500 || !got.Retryable { + t.Fatalf("Check() = %q, %#v", version, got) + } + if errors.Unwrap(err) == nil || err.Error() != "Unable to check for updates" { + t.Fatalf("public error = %q, private cause = %v", err, errors.Unwrap(err)) + } + }) + } +} + func TestUpdateServiceRejectsCancelledContextBeforeDelegation(t *testing.T) { calls := 0 backend := &updateBackendFake{ From b3d996a99fb7dc0f5008d1e00a3140648704bf9f Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:25:18 +0800 Subject: [PATCH 14/18] fix: publish existing OTA releases as stable --- .github/workflows/build-artifacts.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 0ac0d9b6..2b0c3cb0 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -235,6 +235,7 @@ jobs: test "$(wc -l < release-assets/SHA256SUMS)" -eq 4 if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + gh release edit "$RELEASE_VERSION" --draft=false --prerelease=false gh release upload "$RELEASE_VERSION" release-assets/* --clobber else gh release create "$RELEASE_VERSION" release-assets/* --verify-tag --title "$RELEASE_VERSION" --generate-notes From bf372980c886bbbfaa9a152224ada301f25ecc09 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:27:41 +0800 Subject: [PATCH 15/18] fix: serialize OTA restart attempts --- frontend/src/components/AppUpdater.test.tsx | 23 +++++++++++++++++++++ frontend/src/components/AppUpdater.tsx | 5 +++++ 2 files changed, 28 insertions(+) diff --git a/frontend/src/components/AppUpdater.test.tsx b/frontend/src/components/AppUpdater.test.tsx index a26611f4..adff76d3 100644 --- a/frontend/src/components/AppUpdater.test.tsx +++ b/frontend/src/components/AppUpdater.test.tsx @@ -186,4 +186,27 @@ describe("AppUpdater", () => { expect(await screen.findByText(/restart failed/)).toBeTruthy(); expect(screen.getByRole("button", { name: "Restart and update" })).toBeTruthy(); }); + + it("serializes restart attempts and allows retry after failure", async () => { + const user = userEvent.setup(); + let rejectRestart!: (error: unknown) => void; + const restart = new Promise((_resolve, reject) => { rejectRestart = reject; }); + mocks.checkUpdate.mockResolvedValue("v2.0.0"); + mocks.question.mockResolvedValue("Update"); + mocks.downloadUpdate.mockResolvedValue(undefined); + mocks.restartUpdate.mockReturnValueOnce(restart).mockResolvedValueOnce(undefined); + mount(); + + await waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Task center" })); + const restartButton = await screen.findByRole("button", { name: "Restart and update" }); + await user.click(restartButton); + await user.click(restartButton); + expect(mocks.restartUpdate).toHaveBeenCalledTimes(1); + + rejectRestart(new Error("restart failed")); + expect(await screen.findByText(/restart failed/)).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Restart and update" })); + expect(mocks.restartUpdate).toHaveBeenCalledTimes(2); + }); }); diff --git a/frontend/src/components/AppUpdater.tsx b/frontend/src/components/AppUpdater.tsx index 189ac416..5b272c2b 100644 --- a/frontend/src/components/AppUpdater.tsx +++ b/frontend/src/components/AppUpdater.tsx @@ -14,6 +14,7 @@ export function AppUpdater() { const latest = useRef(taskCenter); const checked = useRef(false); const active = useRef(false); + const restarting = useRef(false); latest.current = taskCenter; useEffect(() => { @@ -62,10 +63,14 @@ export function AppUpdater() { latest.current.setTaskAction(OTA_TASK_ID, { label: t("重启并更新"), run: async () => { + if (restarting.current) return; + restarting.current = true; try { await api.restartUpdate(); } catch (error) { latest.current.setTaskMessage(OTA_TASK_ID, describeError(error, t("无法重启并更新")).message); + } finally { + restarting.current = false; } }, }); From 10d8473fcb42d8cab3705a718e79cbde1d873de6 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:30:08 +0800 Subject: [PATCH 16/18] fix: publish OTA release after asset upload --- .github/workflows/build-artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 2b0c3cb0..6bbf32da 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -235,8 +235,8 @@ jobs: test "$(wc -l < release-assets/SHA256SUMS)" -eq 4 if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then - gh release edit "$RELEASE_VERSION" --draft=false --prerelease=false gh release upload "$RELEASE_VERSION" release-assets/* --clobber + gh release edit "$RELEASE_VERSION" --draft=false --prerelease=false else gh release create "$RELEASE_VERSION" release-assets/* --verify-tag --title "$RELEASE_VERSION" --generate-notes fi From 113998320ba5e548a18106e43aef1a2cb223299a Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:40:41 +0800 Subject: [PATCH 17/18] ci: skip release when ci is triggered manually --- .github/workflows/build-artifacts.yml | 43 ++------------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 6bbf32da..b177e90c 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -36,8 +36,6 @@ jobs: fi - uses: actions/checkout@v7 - with: - ref: ${{ env.RELEASE_VERSION }} - uses: pnpm/action-setup@v6 with: @@ -95,8 +93,6 @@ jobs: steps: - uses: actions/checkout@v7 - with: - ref: ${{ env.RELEASE_VERSION }} - uses: actions/setup-go@v7 with: @@ -117,14 +113,6 @@ jobs: go build -tags wails,production -trimpath -buildvcs=false -ldflags="${{ matrix.target.desktop_ldflags }} $version_ldflag" -o "bin/oneagent-desktop${{ matrix.target.suffix }}" ./cmd/oneagent-desktop go build -trimpath -buildvcs=false -ldflags="-w -s $version_ldflag" -o "bin/oneagent${{ matrix.target.suffix }}" ./cmd/oneagent - - name: Verify production Go dependency inventory - shell: bash - run: | - python3 scripts/generate_third_party_licenses.py \ - --verify-platform "${{ matrix.target.platform }}-${{ matrix.arch.label }}" \ - --verify-go-binary "bin/oneagent-desktop${{ matrix.target.suffix }}" \ - --verify-go-binary "bin/oneagent${{ matrix.target.suffix }}" - - name: Package macOS app if: matrix.target.platform == 'macos' shell: bash @@ -139,35 +127,6 @@ jobs: codesign --force --deep --sign - "bin/OneAgent.app" rm "bin/oneagent-desktop" - - name: Add license material to artifact - shell: bash - run: | - cp LICENSE NOTICE third_party/THIRD_PARTY_NOTICES.md bin/ - cp -R third_party/licenses bin/licenses - if [[ "${{ matrix.target.platform }}" == "macos" ]]; then - compliance="bin/OneAgent.app/Contents/Resources/compliance" - mkdir -p "$compliance" - cp LICENSE NOTICE third_party/THIRD_PARTY_NOTICES.md "$compliance/" - cp -R third_party/licenses "$compliance/licenses" - fi - - - name: Verify artifact license material - shell: bash - run: | - test -f bin/LICENSE - test -f bin/NOTICE - test -f bin/THIRD_PARTY_NOTICES.md - test -f bin/licenses/go/github.com_go-ole_go-ole@v1.3.0/LICENSE - test -f bin/licenses/npm/react-router@7.18.1/LICENSE.md - test -f bin/licenses/npm/scheduler@0.27.0/LICENSE - test -f bin/licenses/npm/cookie@1.0.1/LICENSE - test -f bin/licenses/npm/set-cookie-parser@2.6.0/LICENSE - if [[ "${{ matrix.target.platform }}" == "macos" ]]; then - test -f bin/OneAgent.app/Contents/Resources/compliance/LICENSE - test -f bin/OneAgent.app/Contents/Resources/compliance/NOTICE - test -f bin/OneAgent.app/Contents/Resources/compliance/THIRD_PARTY_NOTICES.md - fi - - name: Upload artifact uses: actions/upload-artifact@v7 with: @@ -204,6 +163,8 @@ jobs: release: needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + name: Publish release runs-on: ubuntu-latest permissions: contents: write From ae7f9b011252a2912e714bf8d146b2862710a037 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:46:25 +0800 Subject: [PATCH 18/18] chore: add missing license --- scripts/generate_third_party_licenses.py | 1 + third_party/THIRD_PARTY_NOTICES.md | 1 + .../go/golang.org_x_mod@v0.35.0/LICENSE | 27 +++++++++++++++++++ third_party/manifest.json | 15 +++++++++++ 4 files changed, 44 insertions(+) create mode 100644 third_party/licenses/go/golang.org_x_mod@v0.35.0/LICENSE diff --git a/scripts/generate_third_party_licenses.py b/scripts/generate_third_party_licenses.py index 5685e83e..7def4cf9 100755 --- a/scripts/generate_third_party_licenses.py +++ b/scripts/generate_third_party_licenses.py @@ -40,6 +40,7 @@ "github.com/pelletier/go-toml/v2": "MIT", "github.com/wailsapp/wails/v3": "MIT", "golang.org/x/sys": "BSD-3-Clause", + "golang.org/x/mod": "BSD-3-Clause", } LICENSE_PREFIXES = ("license", "licence", "copying", "notice", "copyright") diff --git a/third_party/THIRD_PARTY_NOTICES.md b/third_party/THIRD_PARTY_NOTICES.md index 4fb14cdc..2e75288c 100644 --- a/third_party/THIRD_PARTY_NOTICES.md +++ b/third_party/THIRD_PARTY_NOTICES.md @@ -17,6 +17,7 @@ the embedded frontend. Full license and notice texts are included under | go | `github.com/go-ole/go-ole` | `v1.3.0` | windows-arm64, windows-x64 | MIT | `licenses/go/github.com_go-ole_go-ole@v1.3.0/LICENSE` | | go | `github.com/pelletier/go-toml/v2` | `v2.4.3` | macos-arm64, macos-x64, windows-arm64, windows-x64 | MIT | `licenses/go/github.com_pelletier_go-toml_v2@v2.4.3/LICENSE` | | go | `github.com/wailsapp/wails/v3` | `v3.0.0-beta.3` | macos-arm64, macos-x64, windows-arm64, windows-x64 | MIT | `licenses/go/github.com_wailsapp_wails_v3@v3.0.0-beta.3/LICENSE` | +| go | `golang.org/x/mod` | `v0.35.0` | macos-arm64, macos-x64, windows-arm64, windows-x64 | BSD-3-Clause | `licenses/go/golang.org_x_mod@v0.35.0/LICENSE` | | go | `golang.org/x/sys` | `v0.47.0` | windows-arm64, windows-x64 | BSD-3-Clause | `licenses/go/golang.org_x_sys@v0.47.0/LICENSE` | | npm | `@wailsio/runtime` | `3.0.0-alpha2.117` | frontend | MIT | `licenses/npm/wailsio_runtime@3.0.0-alpha2.117/LICENSE` | | npm | `cookie` | `1.0.1` | frontend | MIT | `licenses/npm/cookie@1.0.1/LICENSE` | diff --git a/third_party/licenses/go/golang.org_x_mod@v0.35.0/LICENSE b/third_party/licenses/go/golang.org_x_mod@v0.35.0/LICENSE new file mode 100644 index 00000000..2a7cf70d --- /dev/null +++ b/third_party/licenses/go/golang.org_x_mod@v0.35.0/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/manifest.json b/third_party/manifest.json index 3e9dc696..2ee6261c 100644 --- a/third_party/manifest.json +++ b/third_party/manifest.json @@ -106,6 +106,21 @@ ], "version": "v3.0.0-beta.3" }, + { + "ecosystem": "go", + "license": "BSD-3-Clause", + "license_files": [ + "licenses/go/golang.org_x_mod@v0.35.0/LICENSE" + ], + "name": "golang.org/x/mod", + "platforms": [ + "macos-arm64", + "macos-x64", + "windows-arm64", + "windows-x64" + ], + "version": "v0.35.0" + }, { "ecosystem": "go", "license": "BSD-3-Clause",