- TypeScript 69.3%
- Svelte 18.1%
- CSS 7.5%
- Shell 4.7%
- Swift 0.1%
| .forgejo/workflows | ||
| deploy/runtime | ||
| docs | ||
| runner | ||
| scripts | ||
| src | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
sak
sak is a task tracker for agents and the people working with them. One binary
serves an HTTP API and the single-page app built on top of it, and keeps
everything in one SQLite file: projects hold tasks; a task carries text, typed
attributes, labels, comments and relations to other tasks. Every API request
requires a bearer token minted by the same binary; the SPA assets are served
without authentication, and the server listens on 127.0.0.1 unless --bind
says otherwise.
Build from source
You need:
- the sx compiler on
PATH, SX_STDLIB_PATHpointing at an sx standard library whose vendored SQLite is compiled with FTS5 — task search is an FTS5 index,- Node.js 26.3+ and npm, for the SPA.
export SX_STDLIB_PATH=/path/to/sx/library
cd web && npm ci && npm run build && cd ..
sx build src/main.sx -o dist/sak
npm run build type-checks the SPA, runs its unit tests and writes
web/dist/, which is what sak serve hands to the browser. web/dist/ is not
tracked, so a checkout serves no SPA until that build runs. The SPA server test
(tests/step6_spa.sx) and every Playwright suite run vite build themselves
before they serve it. A Playwright run serves sak on a free loopback port it
picks, or on SAK_E2E_PORT when that is set, so runs on one host do not share a
port. npm run e2e:perf measures frame rate on the machine's
GPU. npm run e2e:runner also starts dist/sak-runner (see runner/README.md).
Test
bash tests/run.sh
builds every tests/*.sx with sx build, as many at once as the host has
cores, then runs the builds from the repository root four at a time and prints
each one's result and time, the longest first. It exits 1 when a test does not
build, exits non-zero, prints a FAIL line or ends on a line other than one
ending in OK, and prints that test's log. Given test files, it runs only
those; given none, every one but tests/step9_release.sx, which checks the
archives the release script writes. It takes the compiler from $SX when that
is set, and PATH otherwise, and SX_STDLIB_PATH as a build does;
SAK_TEST_BUILDS and SAK_TEST_JOBS set how many build and how many run at
once. The tests that serve sak or start a runner take dist/sak and
dist/sak-runner, so build those first.
Run
dist/sak token create --db sak.db --name laptop
dist/sak serve --db sak.db
token create prints one token — sak_ followed by 64 hex characters — and
that printed line is the only copy: the database stores its SHA-256. Mint one
per client. Once signed in, the SPA's Tokens page, under the ⋯ menu, lists,
creates and revokes tokens.
serve binds --bind (default 127.0.0.1) on --port (default 4345) and
serves the SPA from --assets (default web/dist). --bind takes a numeric
address — 0.0.0.0 and :: reach the whole network, a LAN address one
interface — and anything else exits 2. --threads (default 16) sets how many
requests are served at once, each on a SQLite connection of its own. The first
line serve logs summarizes what it is running: version and build commit,
bound address, database, assets, log settings and worker count.
Open http://127.0.0.1:4345 and paste a token to get in. Reach the API with the
same token:
curl -H "Authorization: Bearer sak_…" http://127.0.0.1:4345/api/health
Every request then leaves one line on stderr — the UTC instant, method, path, status, response bytes and duration, plus the cause on a 4xx or 5xx:
INFO: 2026-08-02T09:14:22Z GET /api/projects -> 200 412b 1ms
ERROR: 2026-08-02T09:14:21Z GET /api/tasks/8123 -> 404 21b 0ms get_task: StoreErr.NotFound
--log-level chooses how much of that is written: quiet keeps the refusals,
requests (the default) adds a line per served request, debug adds the
request size and the response content type to every line. The startup line is
written at every level, and nothing is written to a file — redirect stderr to
keep the lines. The last --log-ring lines (1000 by default, at most 100000)
also stay in memory, where GET /api/logs serves them back newest first;
docs/api.md documents that route.
Each command creates the database if it is missing.
Data
Everything lives in the one SQLite file named by --db. Migrations run when
that file is opened, so upgrading is: stop the server, swap the binary, start
it again. Back up by copying the file with the server stopped; restore by
putting the copy back.
Run with Docker
Build the release archives on the host, then build and start the image:
bash scripts/release.sh
docker build -t git.swipelab.com/sx/sak:latest .
docker compose up -d
The compose recipe publishes port 4345, keeps the database in the sak-data
volume, and restarts the container unless it is stopped. Create a token inside
the container:
docker exec sak sak token create --db /data/sak.db --name laptop
Back up the database and its uploaded files while sak serves them:
docker exec sak sak backup --db /data/sak.db --to /data/backup-1/sak.db
docker cp sak:/data/backup-1 ./backup-1
backup copies the database as it stands when the backup begins, through
SQLite's online backup API, at --rate MiB a second (default 20), so sak's
writes go on beside it, and copies the uploaded files that database records
to <path>.images. It refuses a --to that is already there, ends only once
PRAGMA quick_check, which reads at the same rate, passes on the copy, and
lists any recorded file whose bytes were deleted before it reached them.
sak serve --db <path> serves the backup as it lies.
Deploy
docs/deploy.md covers the manual deploy workflow that
builds and publishes the sak docker image to the registry: how to trigger it,
the runtime the job runs on, the secrets it reads, and how the NAS pulls and
rolls back the published image. scripts/deploy.sh carries that logic and
rehearses it locally with --local or --dry-run; deploy/runtime/manifest
is the single authority on every external command the deploy path uses.
API
docs/api.md documents every route: projects, tasks, attributes, labels, comments, relations, search, saved views, paging and the revision protocol that guards concurrent writes.
Release tarballs
bash scripts/release.sh
builds the SPA and the server and writes three archives, each holding sak,
web/dist/, this README and docs/:
dist/sak-<version>-macos-arm64.tar.gzdist/sak-<version>-linux-x86_64.tar.gzdist/sak-<version>-linux-arm64.tar.gz
It also writes dist/runner/, the runner directory sak serve --runner-dir
serves the install scripts and the sak-runner builds for macOS and Linux from
(see docs/deploy.md). The macOS archive is a native build, so the
script itself runs on a macOS arm64 host; --linux-only drops it and packages the two Linux archives on any
host. The Linux archives and the runner directory's builds are built with
sx --target macos|linux|linux-arm --self-contained on any host, which needs zig
on PATH (or in $SX_ZIG) for its libc. The script takes the compiler from $SX when that
is set, and PATH otherwise.
The version is the VERSION constant in src/version.sx; it names the archives
and answers GET /api/health. To cut a release, edit that constant and rerun
the script. tests/deployed_upgrade.sx starts each build on
tests/fixtures/deployed.sql, a database the deployed release leaves; once a
release is deployed, bash scripts/deployed_fixture.sh <its sak> <version> <commit>
moves the fixture onto it. The release script supplies the short git sha of the checkout it
packages. Direct builds carry a valid SAK_BUILD_COMMIT when supplied and
answer unknown otherwise.
Unpack and run:
mkdir -p sak && tar -xzf sak-0.3.13-macos-arm64.tar.gz -C sak && cd sak
./sak token create --db sak.db --name laptop
./sak serve --db sak.db