Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Normal runs and `service install` default to these provider roots:
```text
claude=~/.claude
codex=~/.codex
copilot=~/.copilot/otel
copilot=~/.copilot
gemini=~/.gemini/tmp
kimi=~/.kimi
qwen=~/.qwen
Expand Down
60 changes: 56 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ Release tags must be semantic versions on an up-to-date `main` branch:
```sh
git switch main
git pull --ff-only
git tag v0.1.1
git push origin v0.1.1
git tag vX.Y.Z
git push origin vX.Y.Z
```

The tag pattern is exactly `v[0-9]+.[0-9]+.[0-9]+`. Suffixed tags — `v1.2.0-rc.1`,
`v1.2` — do not match the workflow trigger, so they build nothing and ship
nothing. There is no prerelease channel.

Never tag `dev`. The workflow's first step rejects a tag whose commit is not an
ancestor of `origin/main`, so tagging `dev` fails the build rather than shipping
untested work — but it leaves a junk tag to clean up.

The tag starts the `Release` workflow. It rejects tags outside `main`, reruns
vet and race-enabled tests, then cross-compiles stripped, reproducible binaries
for every supported target:
Expand All @@ -39,5 +47,49 @@ The workflow verifies the asset set and embedded version, generates
remain raw rather than being wrapped in ZIP or tar archives: the Tokitoki update
server proxies them directly to `tokitoki update`.

Creating the GitHub Release does not publish it to clients. Import and publish
the version from `/admin/releases`; until then the update API ignores it.
The asset names are an API. The server matches the platform and arch tokens in
each filename to answer a download request, so renaming an asset or dropping one
from the matrix breaks clients on that platform. The workflow guards this: it
requires all six files to exist and the count to be exactly six.

Nothing in the repository records the version. `make cross VERSION=X.Y.Z` stamps
it through `-ldflags` into `internal/buildinfo.Version`, and the workflow derives
that value from the tag — so the tag is the only source of truth, and there is no
version file to bump in a commit. The workflow then runs the freshly built
binary and fails the release if `tokitoki version` disagrees with the tag.
Unstamped builds report `dev` and refuse to self-update, which is what keeps a
local build from overwriting itself with a release.

## Pushing the tag ships it

Creating the GitHub Release **is** publishing it. There is no second gate.

The update server answers `/api/updates/check` straight from the GitHub
Releases API (`lib/releases.ts` in `tracklm-nextjs`): no database mirror, no
publish switch, only a short-TTL in-memory cache that serves stale data when
GitHub is unreachable. The newest non-draft tag that parses as stable semver
becomes the answer for every client asking what to install. `/admin/releases`
reports downloads and is deliberately read-only — nothing on that page ships a
version.

So `git push origin vX.Y.Z` is the point of no return. Once the workflow
finishes, `tokitoki update` starts handing that binary to every client, and the
macOS/Windows apps and editor plugins follow, because they all delegate to the
same `tokitoki update`. Verify before pushing the tag, not after.

Backing out means acting before clients poll, and there is no way to recall
what has already been downloaded:

```sh
gh release delete vX.Y.Z --repo tokitoki-dev/tokitoki-cli --yes
git push origin :refs/tags/vX.Y.Z
git tag -d vX.Y.Z
```

Prefer rolling forward with a new patch version. Deleting a release that
clients have already seen means some of them sit on a version the server no
longer offers.

An earlier revision of this document described importing and publishing a
version from `/admin/releases`. That step no longer exists; the server was
changed to read GitHub directly.
127 changes: 105 additions & 22 deletions cmd/tokitoki/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"

Expand All @@ -27,6 +28,10 @@ import (

const (
defaultSyncInterval = 5 * time.Minute
// defaultUploadInterval paces the queue drain. It runs far more often
// than the scan because an empty queue costs one indexed query, and
// because it bounds how long a freshly scanned event waits to be sent.
defaultUploadInterval = 30 * time.Second
// updateInterval paces the service worker's self-update checks. The
// first check runs immediately after start, so a freshly installed or
// relaunched service is current within one loop iteration.
Expand Down Expand Up @@ -337,9 +342,10 @@ type workerFlags struct {
// rather than the built-in defaults. Installed units only bake explicit
// dirs into ExecStart: defaults must resolve from the service user's
// home at run time, not the installer's at install time.
explicitDirs bool
interval time.Duration
checkUpdate bool
explicitDirs bool
interval time.Duration
uploadInterval time.Duration
checkUpdate bool
}

func runServiceWorker(args []string) int {
Expand All @@ -352,39 +358,113 @@ func runServiceWorker(args []string) int {
return runWorkerLoop(ctx, flags)
}

// runWorkerLoop scans and uploads on independent schedules.
//
// The two halves share nothing but the local queue: scanning writes events
// into it, uploading drains them. Running them on one ticker meant an upload
// could not start until a scan finished, so a cold start spent its whole scan
// with events queued and the network idle. Apart they proceed at their own
// pace, and the upload ticker runs faster because draining a queue that is
// usually empty costs one indexed query.
//
// Neither half knows the other exists. A scan that fails does not stop queued
// events from being sent, and an upload that fails does not stop new events
// from being queued.
func runWorkerLoop(ctx context.Context, flags workerFlags) int {
logger := defaultLogger()
ticker := time.NewTicker(flags.interval)

client, err := agentlib.New(agentlib.Options{Logger: logger})
if err != nil {
logger.Error("tokitoki worker failed to start", "error", err)
return 1
}

// A successful self-update replaces the binary on disk while this process
// still runs the old code, so it stops every loop and exits for the
// service manager to restart. Cancelling here is what ends the scan and
// upload loops too.
workerCtx, stopWorkers := context.WithCancel(ctx)
defer stopWorkers()

var wg sync.WaitGroup
wg.Add(2)

go func() {
defer wg.Done()
runIntervalLoop(workerCtx, flags.interval, func(context.Context) {
if err := client.Scan(agentlib.SyncOptions{ProviderDirs: flags.providerDirs}); err != nil {
logger.Error("tokitoki scan failed", "error", err)
}
})
}()

go func() {
defer wg.Done()
runIntervalLoop(workerCtx, flags.uploadInterval, func(runCtx context.Context) {
if err := client.Upload(runCtx); err != nil {
logger.Error("tokitoki upload failed", "error", err)
}
})
}()

runUpdateLoop(workerCtx, logger, flags.interval)
stopWorkers()
wg.Wait()
return 0
}

// runIntervalLoop runs work immediately and then every interval until ctx is
// done. Each run is bounded by its own timeout so one slow pass cannot stall
// the schedule forever.
func runIntervalLoop(ctx context.Context, interval time.Duration, work func(context.Context)) {
// NewTicker panics on a non-positive interval. A caller that never set one
// wants the default cadence, not a crashed worker.
if interval <= 0 {
interval = defaultSyncInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
runCtx, cancel := context.WithTimeout(ctx, agentlib.DefaultUploadTimeout)
work(runCtx)
cancel()

select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

// runUpdateLoop checks for a new binary until ctx is done or one is
// installed. The caller then exits so the service manager restarts into it.
func runUpdateLoop(ctx context.Context, logger *slog.Logger, interval time.Duration) {
// Zero means "never checked", so the first iteration checks right away.
var lastUpdateCheck time.Time
ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
syncCtx, cancel := context.WithTimeout(ctx, agentlib.DefaultUploadTimeout)
if err := runSync(syncCtx, flags.providerDirs, os.Stdout); err != nil {
logger.Error("tokitoki sync failed", "error", err)
}
cancel()

if time.Since(lastUpdateCheck) >= updateInterval {
lastUpdateCheck = time.Now()
updateCtx, cancel := context.WithTimeout(ctx, updateTimeout)
result, err := selfupdate.Upgrade(updateCtx, logger, usageupload.BaseURL(), version)
checkCtx, cancel := context.WithTimeout(ctx, updateTimeout)
result, err := selfupdate.Upgrade(checkCtx, logger, usageupload.BaseURL(), version)
cancel()
if err != nil {
logger.Warn("tokitoki self-update failed", "error", err)
} else if result.Updated {
// The binary on disk is new but this process is still the
// old code. Exit; the service manager restarts us as the
// old code. Stop; the service manager restarts us as the
// new version.
return 0
return
}
}

select {
case <-ctx.Done():
return 0
return
case <-ticker.C:
}
}
Expand All @@ -409,7 +489,8 @@ func parseWorkerFlags(name string, args []string) (workerFlags, bool) {
flags.SetOutput(os.Stderr)
providerDirs := newProviderDirFlags(agentlib.DefaultProviderDirs())
flags.Var(providerDirs, "provider-dir", "provider data directory to scan (provider=dir; repeatable)")
interval := flags.Duration("interval", defaultSyncInterval, "sync interval")
interval := flags.Duration("interval", defaultSyncInterval, "scan interval")
uploadInterval := flags.Duration("upload-interval", defaultUploadInterval, "queue drain interval")
if err := flags.Parse(args); err != nil {
return workerFlags{}, false
}
Expand All @@ -427,8 +508,9 @@ func parseWorkerFlags(name string, args []string) (workerFlags, bool) {
return workerFlags{}, false
}
return workerFlags{
providerDirs: dirs,
interval: *interval,
providerDirs: dirs,
interval: *interval,
uploadInterval: *uploadInterval,
}, true
}

Expand Down Expand Up @@ -456,9 +538,10 @@ func parseServiceFlags(args []string) (workerFlags, bool, bool) {
return workerFlags{}, false, false
}
return workerFlags{
providerDirs: dirs,
explicitDirs: providerDirs.Explicit(),
interval: *interval,
providerDirs: dirs,
explicitDirs: providerDirs.Explicit(),
interval: *interval,
uploadInterval: defaultUploadInterval,
}, !*system, true
}

Expand Down Expand Up @@ -565,7 +648,7 @@ Each invocation scans the provider roots you pass and uploads their usage
events to the Tokitoki server (TOKITOKI_BASE_URL, default
https://tokitoki.dev). By default, tokitoki scans the built-in roots for
claude, codex, copilot, gemini, kimi, qwen, openclaw, pi, amp, droid, kilo,
hermes, codebuff, opencode, and goose. Pass one or more
hermes, codebuff, opencode, goose, and workbuddy. Pass one or more
--provider-dir provider=dir values to scan an explicit provider set. The API
key is read from ~/.tokitoki/api_key; use tokitoki set key <API_KEY> to create
or update that file.
Expand Down
4 changes: 3 additions & 1 deletion cmd/tokitoki/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ func TestRunHeartbeatUploadsUnifiedIDEEvent(t *testing.T) {
if event.SourceType != "ide" || event.SourceProvider != "eclipse" || event.EventKind != "heartbeat" {
t.Fatalf("source fields = %+v, want Eclipse IDE heartbeat", event)
}
if event.Entity != "/repo/src/App.java" || event.Language != "Java" {
// The entity is uploaded relative to the project folder: the absolute
// path would leak the machine layout that project_path_hash hides.
if event.Entity != "src/App.java" || event.Language != "Java" {
t.Fatalf("entity/language = %q/%q, want Java file", event.Entity, event.Language)
}
if event.IsWrite == nil || !*event.IsWrite {
Expand Down
52 changes: 52 additions & 0 deletions cmd/tokitoki/worker_loop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"context"
"sync/atomic"
"testing"
"time"
)

// The two halves must tick on their own schedules: a fast loop keeps running
// while a slow one is still working.
func TestRunIntervalLoopsAreIndependent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
defer cancel()

var slow, fast int32
done := make(chan struct{}, 2)

go func() {
runIntervalLoop(ctx, 10*time.Millisecond, func(context.Context) {
atomic.AddInt32(&slow, 1)
time.Sleep(250 * time.Millisecond) // a slow scan
})
done <- struct{}{}
}()
go func() {
runIntervalLoop(ctx, 20*time.Millisecond, func(context.Context) {
atomic.AddInt32(&fast, 1)
})
done <- struct{}{}
}()

<-done
<-done

s, f := atomic.LoadInt32(&slow), atomic.LoadInt32(&fast)
t.Logf("slow ran %d times, fast ran %d times", s, f)
if f <= s {
t.Errorf("fast loop ran %d times, slow %d: the fast loop was blocked by the slow one", f, s)
}
}

// A zero interval must not panic the worker.
func TestRunIntervalLoopRejectsZeroInterval(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
runs := 0
runIntervalLoop(ctx, 0, func(context.Context) { runs++ })
if runs == 0 {
t.Fatal("work never ran")
}
}
Loading