Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

How Meghnaad is put together, and why. For what it does, see Using Meghnaad; for the settings it reads, taskrc support.

The idea

Taskwarrior 3 keeps its data in TaskChampion, which syncs through a server: a place where every client pushes its changes (as numbered “versions”) and pulls everyone else’s. With sync.server.type=aws that place is a bucket, and the clients agree on its layout. Meghnaad is one more client of that bucket, written for the browser.

A replica, not a copy

It reads and writes the same bucket in TaskChampion’s own format and encryption, so task sync and the web app see each other’s changes.

The replica lives in your browser

The engine (Rust compiled to WebAssembly, in a Web Worker in the page) holds your tasks in memory, runs Taskwarrior commands against them, and keeps a saved copy in IndexedDB. A small Cloudflare Worker sits between the page and the bucket: it checks Cloudflare Access, holds the encryption secret, and opens and seals the versions that pass through. It never parses a task.

One library for Taskwarrior behaviour

Filters, reports, urgency, recurrence, confirmations and dates live in one Rust library, tc-core. The Svelte app draws what the engine returns and has no Taskwarrior logic of its own. What it does own is the shell level around task: task commands, && || ; chains, bulk actions and review, which only send ordinary commands to the engine.

Why the engine is in the page

It started the other way round: the Worker held the replica and ran every command, so every request spent the free plan’s 10 ms of CPU. Moving the engine into the page took that cost off the Worker and made reads and writes instantaneous. Every current browser has what it needs (Web Workers and WebAssembly), so the Worker no longer runs it.

flowchart TB
  subgraph desktop["Desktop: Taskwarrior 3"]
    direction TB
    sh["shell aliases<br/>alias fail='task done outcome:fail'"]
    tsh["tasksh<br/>review"]
    cli["task CLI (C++)"]
    tcd["TaskChampion (Rust)"]
    db[("SQLite replica<br/>~/.task")]
    rc["~/.taskrc"]
    sh -.-> cli
    tsh -.-> cli
    cli --> tcd --> db
    rc -.-> cli
  end

  subgraph web["Your browser: Meghnaad"]
    direction TB
    cmds["task commands<br/>(settings.json)"]
    rev["tasksh review"]
    spa["Svelte app<br/>(draws results)"]
    eng["Engine in a Web Worker (Rust → WASM)<br/>command engine in tc-core"]
    tcw["TaskChampion (Rust)"]
    mem[("Replica in memory<br/>saved copy in IndexedDB")]
    cmds -.-> spa
    rev -.-> spa
    spa -- "what you type or click" --> eng --> tcw --> mem
  end

  subgraph phone["Your phone: Meghnaad for Android"]
    direction TB
    wg["Widgets<br/>tasks · burndown · calendar"]
    app["The app<br/>Kotlin and Compose"]
    nat["Engine (Rust, native)<br/>tc-core, hooks (Lua)"]
    rep[("Replica in a file<br/>settings and keys on the phone")]
    wg --> nat
    app --> nat --> rep
  end

  subgraph edge["Cloudflare"]
    direction TB
    access{{"Cloudflare Access<br/>sign-in and token check"}}
    assets["Static assets<br/>the app and the engine (.wasm)"]
    wk["Worker (Rust → WASM)<br/>opens and seals versions<br/>runs your hooks (Lua)<br/>key in a Worker secret"]
    access --> assets
    access --> wk
  end

  bucket[("R2 bucket<br/>salt · latest<br/>v-parent-child · s-version<br/>web/config.json · web/settings.json")]

  tcd <-- "task sync (when you run it)<br/>S3 API, key on your machine" --> bucket
  nat <-- "S3 API, signed on the phone<br/>any S3-compatible store" --> bucket
  tcw <-- "after every write, when you return,<br/>every 30 s: /api/tc/*, plain versions" --> access
  spa -. "taskrc settings, settings.json<br/>/api/config, /api/settings" .-> access
  eng -. "a hook call: /api/run" .-> access
  wk <-- "R2 binding" --> bucket
  assets -. "loaded once, then cached" .-> spa

  sh <-. "counterpart" .-> cmds
  tsh <-. "counterpart" .-> rev

  classDef ext fill:transparent,stroke:#999,color:#999,stroke-dasharray:4 3
  class sh,tsh,cmds,rev ext

The dashed grey boxes aren’t Taskwarrior itself. They are what people add around it: shell aliases and tasksh on the desktop, and their counterparts here, task commands (settings.json, expanded in the browser) and tasksh’s review (the Settings page sets up the reviewed attribute and _reviewed report it needs). Both only send ordinary commands to the engine, so it knows nothing about them.

Where things run

In the page

web/src/engine/ and crates/browser: the engine, in a dedicated Web Worker so a big report never blocks the page. The Svelte app sends it what you typed or clicked ({ "line": "…" } or { "args": […] }, with your time zone and the time) and gets back the JSON the app reads. One entry point means one place where Taskwarrior semantics live. A file to import goes the same way, as its text, and tc-core’s import module parses and checks it. The engine answers one message at a time, in order.

Pure helpers. tc_core::helpers holds what needs nothing from the replica, so the page doesn’t ask the Worker for it: a date in the user’s dateformat, a command line split and quoted as the engine reads it, a duration in one unit, what a header click does to a report’s sort, what Tab completes and how a line is coloured, how an urgency coefficient is named, what each cell of a report shows, the numbers a board section shows and where its sections sit on the grid, what a quick-add line would make (preview), and the Markdown of a note. helpers::call runs those with structured arguments by name, as JSON.

web/src/engine/helpers.ts loads the same WebAssembly module again on the page’s own thread (one download, since the browser has the bytes) and calls them at once, because a screen asks for a date while it draws and a message is asynchronous. main.ts loads them before it imports the app, so the first paint waits for the engine’s module, which the engine needs a moment later anyway. The phone calls the same functions through JNI.

On the Worker

crates/worker, behind Cloudflare Access. There is no engine here, so nothing on the Worker runs a command; only your hooks run there (a task in and a task out, never the replica). If the page can’t start the engine (it needs Web Workers, WebAssembly and a Content-Security-Policy that allows 'wasm-unsafe-eval'), it says so instead of asking the server.

  1. Static files (the app shell and the engine’s .wasm) are served by Cloudflare without running the Worker. Only /api/* runs it.
  2. auth.rs checks the Cloudflare Access token on every /api/* request: RS256 signature against your team’s published keys, issuer, audience and expiry. Misconfiguration means 403, never open.
  3. /api/tc/* is the sync protocol between the page’s replica and the bucket, in plain form over HTTPS (see Sync): GET pull, POST push, GET/POST snapshot. The Worker opens and seals each version with the key it derives from the secret (session.rs keeps that key between requests), so the secret never leaves it.
  4. Settings have routes of their own, because they live in the bucket and not in a replica (below).
  5. Hooks (below).
  6. GET /api/health says whether the Worker can reach the bucket and open it with the secret. It holds no tasks.

Settings routes

  • /api/config and /api/config/* serve the taskrc and urgency, and GET/PUT /api/settings serve settings.json.
  • PUT /api/config/engine saves what a config command changed. The engine worked it out in the page; the Worker refuses anything sync- or credential-like and keeps the previous version for a restore.
  • The taskrc is a folder of files, one object each under web/rc/ (text as written, less sync and credential lines), served by GET /api/config/files and GET/PUT/DELETE /api/config/files/<path>, with POST /api/config/files/import taking several at once. web/config.json is what they add up to, rebuilt on every change, and web/rc.prev.json holds the files as they were before the last change.
  • All of these go to tc_core::webapi::WebSettings, the same code the phone runs.
  • Two routes fetch from elsewhere and are the Worker’s alone: POST /api/config/taskrc/url (a taskrc, saved as a pasted one is) and POST /api/config/holidays/calendar (an .ics calendar for Add holidays, returned as text and saved by nothing). Both follow the strict link rules of rc_url.

Hooks routes

The user’s Lua script is its own object in the bucket (web/hooks.lua, with the previous one beside it), not part of settings.json, since the page reads that in full and a script may hold secrets.

  • POST /api/run calls one hook ({ fn, args, now, tz, … }) and answers { ok, value, say }, or { ok: false, error, say } for a refusal or failure.
  • GET /api/hooks/defined says which of the four hooks the script defines, so the engine asks only for those.
  • GET/PUT /api/hooks read and save the script (for the editor alone; a script that doesn’t load is refused), and POST /api/hooks/check says what is wrong with a script and where, as you type.
  • The Lua VM is built once per Worker instance and reused, and rebuilt when the script changes (or given its own when busy waiting on the network for another request).
  • The Worker is the host that gives a script the network and secrets (hooknet.rs: fetch with a timeout, no automatic redirect, a 1 MB cap on the answer, a log line per call). A secret is looked up as a Worker secret HOOK_SECRET_<NAME> first, then in web/hook-secrets.enc, a map sealed with the key derived from TC_ENCRYPTION_SECRET. GET/PUT/DELETE /api/secrets[/<name>] manage that one, and names only ever come back.

Hooks, in order

For a command the engine runs on_launch (if defined) before anything. on_add and on_modify run for each task as the command builds the change, just before it is committed: each is a request that returns the task, which diff turns into changes in the same batch (a refusal ends the command and writes nothing). on_exit runs last. A hook that can’t be reached is skipped with a warning, and the command goes on.

The engine’s HookHost is async for this. RemoteHooks (crates/browser) calls the Worker, and LuaHooks runs a script in-process, which is how a host with no Worker (the Android app) uses the same script and runner. A script’s cold start is 3-10 ms and a warm call under a millisecond (measured under Node’s V8, not on a Worker), which is why the VM is kept, and why a script is bounded by an instruction budget (5 million) and 32 MB.

http and secret are async functions of the Lua runtime (omnilua’s async feature: a script’s coroutine is suspended while the Rust future runs), supplied by a Net the host passes to LuaRunner::load_with. A host with none (a test, a bare runner) gives a script an error. The runner enforces the rules whichever host it is:

  • the script declares the hosts it may call with allow_hosts while it loads, and can’t widen them later;
  • only https:// (or http:// to this machine), at most five requests and 256 KB of body per call, and a timeout of at most 10 seconds;
  • the value of every secret it read is replaced by *** in what it printed, failed with or returned;
  • a failed request comes back to the script as nil and a reason, because an error from an async function ends the call and can’t be caught with pcall.

On Android

crates/android and app/. There is no Worker: the engine is tc-core as a native library, called from Kotlin over JNI. It syncs straight with the bucket, signing S3 requests itself (CloudServer over an S3Store, the same server code the Worker runs over R2), or with a TaskChampion sync server (syncserver.rs, the same sealed versions over HTTP, keyed by the client id). See the Android app.

The app draws that engine natively, not in a WebView. shared holds the engine’s JSON types and API (mirroring web/src/lib/types.ts and api.ts), a Store that mirrors the web’s, and the screens. androidApp holds the activity, the reminder alarm’s receiver and the widgets. Taskwarrior behaviour stays in tc-core, reached through the same JNI library; only the web app’s own view logic is ported. Reminders are the web’s rules (Notify.kt mirrors notify.ts) plus an alarm: after every change the next moment worth a reminder is worked out and AlarmManager wakes the app then, so they work with the app closed. See app/README.md.

With the real task

crates/hook-shim is the program meghnaad-hook: the same Lua script runs for the CLI on a computer. task runs an executable per hook from <data>/hooks/; install links this one program there as on-add.meghnaad and so on, for the hooks the script defines, and it picks the hook to run from the name it was called by.

  • It speaks task’s hook protocol: a line of JSON in, a line out, feedback lines, and a non-zero exit to refuse.
  • convert.rs turns task’s JSON into the engine’s task, so the script gets exactly what task_to_json gives the Worker. What comes back is turned into tc_core::hooks::diff’s changes and applied to the JSON task sent, with the same checks, so a field the script may not touch can’t be changed here either.
  • It has a LuaRunner and a Net of its own (net.rs: ureq, the script’s timeout, no redirects, a 1 MB cap; secret(name) is the environment variable of that exact name). The clock is the computer’s zone and the taskrc’s dateformat, date.iso and weekstart.
  • Nothing about Taskwarrior is decided here.

tc-core: the engine

Pure Rust with no I/O of its own, tested natively. Its hosts are the browser engine, the Node harness (scripts/engine-cli.mjs) and the tests. The Worker uses its settings, report and protocol code but runs no commands.

AreaModulesWhat they do
Protocolcloud, crypto, names, store, wireTaskChampion’s cloud server over an object store; PBKDF2 → ChaCha20-Poly1305; object names; the storage trait the Worker implements over R2; and wire, the plain protocol between the page’s replica and the Worker (a batched pull, a push, snapshots), with both halves so a test can run a whole sync through them
Replica storageliveThe in-memory database. TaskChampion’s own copies every task on the first write of each transaction; this one changes its data in place and keeps a short journal to undo a dropped transaction. A test runs random transactions against both and compares them. It can be saved to bytes and loaded again (save, load), which is what the browser keeps in IndexedDB
Tasksmodel, filter, modify, dates, rx, recurA plain view of a task; filter expressions; planning modify and add; Taskwarrior’s date and duration parsing and regular expressions; recurring tasks
CommandscliParsing a command line (aliases, abbreviations, contexts, rc. overrides), the write path with its confirmations, undo, the commands listing, and dispatch to everything below
Reportsreport, run, urgency, historyBuilt-in and custom reports, running one (filter, sort, limit, columns), urgency, and a task’s change history
ColourcolorColour specifications and how they blend, the rules that colour a task, the colors command and the history graph’s colours; checked against the real task’s escape codes
Viewssummary, calendar, burndown, activity, stats, calc, export, importEach a port of its Taskwarrior counterpart: summary, calendar, burndown.*, history.*/ghistory.*/timesheet (activity), stats, calc (and _get, which reads the same DOM), export (Taskwarrior’s own JSON) and import (a port of CmdImport, checking the whole file before writing). The app’s pages use a separate hidden _rows, and the detail view’s History a hidden _history <uuid>
Hookshooks, lua_hooks (+ lua/prelude.lua)hooks runs user code at Taskwarrior’s four hook points through an async HookHost or plain Hooks, with what they print returned to the Console, the task as JSON (task_to_json / task_from_json) and diff turning what came back into changes. lua_hooks (the lua feature) runs a script in a sandbox (LuaRunner: JSON in, JSON out) and as in-process hooks (LuaHooks); the prelude is the Lua library a script gets
Boardsboard (see below)The saved boards of settings.json and what a board section shows, for every front end
Add previewpreviewWhat add <line> would make without making it: the line is split as add reads it and run through parse_mods and plan (same default project, UDA defaults, context write rule, date and priority checks and repeat rules). A bad attribute is left out and named, so the rest still shows, which lets the quick add draw a card while you type. It needs only the config and the time, so the page and Android call it through helpers::call
Taskrc highlightrc_highlightThe pieces of a taskrc file as the parser cuts each line: a setting’s name, =, its value, a comment, the [unsupported] marker, include and its path, a line that is no setting (bad) and a credential-like name (secret). Shape only, so a half-typed file is coloured too. The web’s CodeMirror and the phone’s text field draw them
Holiday clashclash (with calendar::holidays_on)Whether a due or scheduled date falls on a holiday. The date is resolved as add and modify read it (so eod, som, eow-2d, abbreviations and the user’s zone mean what they mean there), then looked up in the holiday.* settings whatever calendar.holidays says. value answers for one form field, and line finds the date words of a command line in the commands that set attributes. A pure function of the config and the time
Holidaysholidays, holidata, nager, icsThe holiday file Add holidays makes, from three sources, as holiday.<id>.name / .date (or .start and .end) lines under a header recording the source, regions, left-out names and (when it holds no secret) the link, so settings_of can read it back. All pure; each front end does the fetching (the page’s connect-src allows https://holidata.net and https://date.nager.at; a calendar goes through the Worker’s POST /api/config/holidays/calendar; the phone uses HttpURLConnection with rc_url’s checks on every redirect) and saves the result through the folder’s routes
Settingstaskrc, rcfiles, rc_url, settings, appsettingsThe allowlisted subset of a taskrc (what is accepted, what is refused, a verdict per line) and the typed Config. rcfiles is the taskrc as a folder: path rules, sanitize (removes sync and credential lines, comments out what can’t be used), resolve (follows include as Taskwarrior does) and the text edits config makes. rc_url is which links the Worker may fetch (HTTPS to a public host, redirects included) and the GitHub/GitLab page-to-raw rewrite. show, config and context list and edit it under the same rules. appsettings checks that settings.json is a JSON object of a sensible size

Boards

board is pure, with no replica; the page asks for it through helpers::call, as an Android screen does. Sorting and keeping the first few groups is each screen’s job.

  • normalize keeps the valid boards and sections, fits them to the 12-column grid and says what it dropped.
  • aggregate reads an export (Taskwarrior’s JSON: 20261007T123000Z dates, ISO durations, numbers) and counts, sums, averages or compares it as a whole and per value of an attribute or per day, week, month, quarter or year of a date. A period with nothing in it is kept; weekstart and the zone come from the caller. A series may carry a span (from, to, unless: dates of a task), and chart then counts the tasks in that state as of the end of each period, which is how task burndown counts pending, started and done.
  • chart lays several series (each its own export and measure) on one axis in Chart.js’s shape: labels, and a dataset of values per series, with every period from the first to the last so a series with nothing in one still has a place.
  • ranges works out a board’s named date ranges (a start and end, each a date as Taskwarrior reads one, in the caller’s zone and week) and writes $name.start, $name.end and the counts of periods it touches ($name.day_count, for a chart’s limit, resolved by board_limit). $name.lg_group.name is the step a chart over the range is cut into (the largest of day, week, month and year with two whole periods in it); a by.time.step may be written as it, kept as TimeBy.var until the page has worked it out. A by.time.range (a range’s name, or two dates of the chart’s own) makes the axis every period of that range, with a state series stopping at today: board_chart takes the board’s ranges as a fourth argument and works the range into TimeBy.window, which is never saved. A name that is no range, or a date that can’t be read, is refused.
  • layout places, moves and resizes a section so that what it lands on is pushed down and everything floats up, and orders sections for a narrow screen.
  • heat makes a heatmap’s grids (reached as board_heatmap). The columns are chart‘s; the rows are the value of an attribute or a part of a date (weekday, hour, day of the month, month). Each series’ tasks are cut into the rows and each row is laid on one axis by chart, so the columns agree. It makes a grid per series, or one with them added up.
  • flow lays sections out on a phone’s 4 or 6 columns (reading order kept, a number as wide as its share of the row, a table or chart half a row or a whole one, none under two of the 12). resize_flow and reorder change the saved layout from a phone’s steps.

How a command runs

execute does the same things in the same order every time:

  1. Apply the command line’s rc.<key>:<value> overrides to a copy of the config, then the active context’s own settings (which win, as in Taskwarrior).
  2. Read the first day of the week, date.iso and dateformat into the clock, and expand aliases (typed lines only).
  3. Parse into a filter, a command and modifications. A word shorter than abbreviation.minimum is just a word.
  4. Run the on_launch hook, which can refuse the command (show, config and context skip it: they are about settings).
  5. Do Taskwarrior’s housekeeping first: create the recurring instances that are due and expire tasks past until.
  6. Load the tasks as plain “facts”, number the pending ones, and run the command. on_add and on_modify fire inside it, just before a write is committed.
  7. Run the on_exit hook, and return what the hooks printed with the result (feedback) for the Console to show.

Writes

Writes go through one path (write_selected), so the safety rules are in one place.

Questions are data, not prompts. A write that needs an answer returns a Confirm result and changes nothing. There are three kinds: plain (yes/no: undo, a command with no filter), permission (one row per task the command would change: Taskwarrior’s yes/no/all/quit becomes ticks in a table) and extras (follow-ups that arise only once those are answered: repair a dependency chain, carry a change to a recurring series). The client re-sends the command with the answers (Options { confirmed, approved, extras }).

Hooks run inside this path, before the commit: on_add and on_modify see the task as the command leaves it, and what they hand back is diffed and applied into the same batch, or the command is refused and nothing is saved.

One batch, one undo step. Everything the command changes is built as one batch of operations and committed once. TaskChampion’s own undo only covers unsynced changes, and the page syncs after every write, so the inverse operations are committed as new changes instead. The undo stack lives in the page’s memory.

Sync

CloudServer is TaskChampion’s server interface over the bucket, wire-compatible with the CLI’s own AWS/GCP backends (salt, latest, v-<parent>-<child>, s-<version>; encrypted with a key from your secret and the bucket’s salt). The Worker runs it. Because the page’s engine is a replica, it never needs the CLI’s cooperation.

Cheap when idle

One GET latest. Catching up lists the versions once and fetches them in batches of 32, rather than one at a time.

Conditional writes

latest is swapped with its ETag, so two clients pushing at once can’t both win; the loser re-syncs and retries.

Snapshots, like the CLI

After a push the Worker occasionally (about one push in ten) writes a snapshot and removes the ones it supersedes, so a cold start restores one snapshot plus a few versions instead of replaying history. A failed snapshot is ignored on purpose: failing the sync would make the replica send an already-sent version again.

Tidying up, like the CLI

About one push in twenty is followed by a cleanup, as in TaskChampion’s own server. It deletes versions that lost a race (but not one a writer may be about to make latest), every snapshot but the newest, and versions older than about 180 days that the newest snapshot covers.

It uses each object’s upload time from R2’s listing and deletes up to 1,000 objects per cleanup in one request, so a cleanup costs a handful of requests however much it removes; the next one carries on. The oldest versions go first, so what is left is always one unbroken chain up to latest: a replica that is behind finds the next version or none, never a gap. A failure is ignored, since the push it follows has already happened. A replica last synced before the oldest surviving version can no longer catch up from versions alone, which is the same for the CLI’s own server.

Between the browser and the Worker

The page’s replica syncs through RemoteServer, a TaskChampion Server that speaks tc_core::wire to the Worker instead of touching the bucket. The Worker runs CloudServer and answers from it, so everything above (conditional writes, snapshots, cleanup) happens there, and the page never holds the key.

  • Pull in batches. TaskChampion asks for the version after the one it has, one at a time. GET /api/tc/pull?since= answers with up to 24 versions in one body (each 16 bytes of id, 16 of parent, a length and the version), and RemoteServer hands them out as asked, so a replica 80 versions behind costs about four requests. The limit is the 50 subrequests a request may make on the free plan: each version is a read from R2.
  • Push is POST /api/tc/push?parent=, answered with the new version and how much the server would like a snapshot, or with behind and the newest version when somebody else got there first, which makes TaskChampion pull and push again. Snapshots go the same way (GET/POST /api/tc/snapshot) and are what a new device starts from.
  • When. After every write, when you return to the tab, every 30 seconds while it is visible, and when another tab of the same browser says it pushed (over a BroadcastChannel; only a write is announced, so tabs don’t answer each other forever). Before the first command there is nothing to answer from unless a saved copy was found, so the first pull is waited for.
  • Failing. A sync that can’t reach the Worker (offline, or Access signed you out) changes nothing: the writes are in the replica and the saved copy, and go up with the next sync that works. The page says so and offers a retry and, as a last resort, to discard the saved copy and start from the bucket.

The saved copy

LiveStorage::save writes the tasks, the working set, the version it is based on and the changes not yet pushed, as JSON, in one IndexedDB record, soon after a change and after every sync. The browser gzips it when it can (CompressionStream; about 17 times smaller, because most of it is the log of changes made on this device, which TaskChampion keeps for a task’s history), and an older uncompressed record is still read. It is only the browser’s own copy: nothing in the bucket or the sync format depends on it. A copy that can’t be read (another format, damaged) is dropped and fetched again.

Limits of the platform

A Worker request has hard limits, and this app is shaped by them. They bind the Worker, and since the engine moved into the page the Worker does very little: it checks a token, opens or seals a few objects and, on a cold instance, derives the key. The measurements below are of the engine from when it ran in the Worker; they show what the engine costs (now in the browser, where there is no such limit) and what it took to keep it under 10 ms.

Workers FreeWorkers Paid
CPU time per request10 ms30 s (up to 5 min)
Memory per isolate128 MB128 MB
Subrequests per request (R2 calls count)5010,000

What it costs

Measured with crates/tc-core/tests/memory.rs and a local workerd:

  • The key. Deriving it (PBKDF2, 600,000 rounds) is the heaviest thing a cold instance does. The Worker asks the runtime’s native Web Crypto, which takes about 40 ms; the same in WebAssembly took about 250 ms. A warm instance reuses it.
  • A warm request was a few milliseconds of CPU for a hundred tasks and, before the cache below, tens for thousands (filtering, sorting, urgency, serialising).
  • Memory is small. Restoring a snapshot of 3,000 tasks peaks near 6 MB, replaying 1,200 versions from nothing near 6 MB, and a request over 3,000 tasks 8 MB (19 MB for a full export). An import of the most one takes (5,000 tasks, a 2 MB file) peaks near 51 MB, and its undo step keeps about 8 MB while the instance lives, which is why the request is capped. The test fails if a change makes any of these much worse.

It runs on Workers Free

So does its author’s deployment, with R2 and Access on their free plans too. What is left for the Worker is a token check, a read or two from R2 and, on the first request after an idle spell, deriving the key. One such request on a real deployment, back when the Worker also restored the snapshot and ran the command, used about 550 ms and still completed, because Cloudflare tolerates a Worker that only infrequently runs over, and ends requests with error 1102 only when it keeps doing so. The app retries a read-only request once, because the retry meets a warm instance. Workers Paid ($5/month) lifts the limit if that ever bothers you. R2 calls count toward the 50 subrequests, which is why a pull carries at most 24 versions (see Sync).

For scale, one personal deployment over its first nine days of a billing cycle used about 1,100 requests a day (1% of the free plan’s 100,000), about 460 R2 writes and listings and 1,100 R2 reads a day (roughly 1.4% and 0.35% of R2’s free 1 million and 10 million a month) and next to no storage, and was billed $0.00.

What is done about CPU

Turning every stored task into the engine’s form (Facts) took about 2 µs a task, and every command began with it, which at 3,000 tasks was most of a read. The engine now keeps that list between commands (tc_core::warm, next to the replica) and rebuilds it only when the storage’s change counter (LiveStorage::generation, bumped when a transaction that changed anything commits or is taken back) has moved. Most requests change nothing, so they skip it. tests/warm.rs checks, after every step of a long random run of commands, direct writes and pulls, that the cached list equals a fresh read.

The page’s own requests were trimmed to match: _names is _tags and _projects in one request, and the reminder poll asks _remind (seven fields) instead of _rows (a full row with every virtual tag for each task). Measured natively at 3,000 tasks (WebAssembly is somewhat slower, but the proportions hold):

RequestBeforeAfter
next, list, countabout 8 msabout 2 ms
the name lists (_tags and _projects)2 × 6.2 msone request, 0.5 ms
the reminder poll13.3 ms (_rows)0.9 ms (_remind)
a write (modify on one task)about 8 msabout 1.7 ms

A write doesn’t rebuild the list either. The storage keeps which tasks each of its last 32 transactions touched (Generation::touched_since), and a list a few changes behind is brought up to date by reading just those tasks and moving them into place (warm::patch). That is done only when no touched task depends on another or has another depending on it, since a dependency changes whether tasks are blocked or blocking, which is worked out for the whole list; then the list is rebuilt, as after a long pull or an import. The same random run checks the patched list against a fresh read after every step, including steps that add and remove dependencies.

The web app

A Svelte 5 single-page app (web/), built with Vite and served as static assets.

The engine in the page

web/src/engine/:

  • engine.worker.ts is the Web Worker that loads the engine’s .wasm (crates/browser, built by scripts/build-engine.mjs into src/engine/pkg, typed by hand in tc-engine.d.ts so type-checking and tests don’t need it built), restores the saved replica from IndexedDB (persist.ts), and answers protocol.ts messages one at a time.
  • client.svelte.ts is the page’s side: it starts the worker once, sends commands, runs the syncs, and exposes status / error for the banner shown when a sync fails.
  • api.ts makes the choice: runCli and importTasks go to the engine in the page when it is available and to the Worker’s endpoints when it isn’t. getConfig starts it (the engine needs the settings first) and hands it the settings again whenever they are read again.

Start-up isn’t a chain of requests. main.ts loads the engine’s Worker (preload: it fetches the .wasm and reads the saved copy) and asks the server for what the page needs (prefetch: whether it is set up, the settings, which hooks the script defines, and settings.json) before the app’s own code has loaded, so they all travel at once. Each answer is used once, by the call that would have asked, and asked for again if it was refused (a deployment that isn’t set up refuses all of them). A read of the settings already on its way is joined, not repeated. A config command’s new settings are saved on the Worker (PUT /api/config/engine) before the engine takes them up. The CSP allowance is 'wasm-unsafe-eval', which lets the page compile WebAssembly and nothing else.

Stores and pages

store.svelte.ts is the one store: the current page, the config, the console’s entries, the live report, the answers to questions in flight, and the toast.

The pages (TasksView, ProjectsView, TagsView, SummaryPage, CalendarPage, BurndownPage, ConsoleView, SettingsPage) are thin. Each asks the engine for a result and hands it to a shared component, so the console and the pages draw the same thing. ResultView turns any CliResult into UI: ReportTable, SummaryView, CalendarView, BurndownView, TaskInfo, ConfirmView, and the lines hooks printed under the result (a toast when a button, not the console, ran the command). A report’s cells are written by the engine (tc_core::cells), in one call for the whole table through formatCells in format.ts, which only tells it the browser’s zone at each moment so summer time is right.

The filter box (FilterInput, FilterChips) and the prompt share completion.ts; both feed the same engine.

Settings

One page, SettingsPage, with a tab per panel: TaskrcPanel (the folder of taskrc files: a list, the text of one, themes, import of files, a folder or a link; rcpaths.ts has the path helpers; HolidaysForm and holidays.ts fetch a country’s holidays and save them as a file), TaskshPanel, CommandsPanel, HooksPanel and UrgencyPanel. Each has its own Apply and tells the page when it has edits, and all stay mounted so edits survive a change of tab. tasksh.ts decides what the reviewed UDA and _reviewed report need and which config lines write them; the panel sends those to the engine like any command, so nothing about tasksh is in the Worker.

The hooks editor

HooksPanel and hookEditor.ts: CodeMirror with Lua highlighting, loaded only when the tab opens (about 107 KB compressed, its own chunk). The panel asks the Worker whether the script is all right a moment after typing (POST /api/hooks/check, the same Lua that will run it) and marks the line. A script that doesn’t load can’t be saved, and an empty one removes the hooks. api.ts tells the engine which hooks the saved script defines (refreshHooks).

Review

tasksh’s review is the browser’s too, and only a client of the engine. review.svelte.ts is the session: it asks for the _reviewed report (the uuids), shows each task with info, and carries out the menu with ordinary commands (modify reviewed:now, done, delete, with rc.confirmation:no), so undo, hooks and the settings work as for any command. A question Taskwarrior asks mid-step (the recurring series) comes back as a confirm result and is put to the person (asking), then the same command is sent again with the answer.

Two surfaces drive it, with one ReviewBar of buttons between them:

  • ConsoleInput: tasksh’s menu is keybindings, so while review.captures a key is the answer, the prompt is off, and only what to change is typed. store.addEntry prints the banner, the info and the messages into the scrollback.
  • ReviewCard: buttons and the same keys, opened from the top bar or by Open as card, which moves a console review there.

tasksh.ts has what is pure: reading review N, the menu keys, tasksh’s wording and the setup’s questions. The editor the e choice opens reports its closing through App.svelte (review.editClosed).

Addresses

Every page has one, in route.ts: a Place (a report, a plain page, the boards or one board, Settings or one tab, a task) with parsePath and placePath to turn it into a path and back. The Worker serves the app for them all (single-page fallback). store.place is where the app is, derived from the page, report, board, tab and drawer. store.openFromAddress goes where an address says (a page that needs the reports or boards waits until they are read, and the start asks again).

One effect in App.svelte keeps the address in step with store.place the other way, pushing a history entry for each move but replacing the address when the front page finds where it is, when the drawer closes over the page under it, and when Settings changes tab. A new page adds a Place, a test of its round trip, and a row in docs/using/pages.md.

A task is /task/<8 characters of its uuid>; store.openFromAddress finds it with uuid.startswith:<prefix> uuids. DepLink is the id of another task (a dependency, a parent) with a hover, focus or tap card; in the drawer DepItem is a line that expands to the same TaskCard, and the tasks that wait on this one are looked up with depends.has:<uuid> only when asked. A project part or tag chip jumps to its entry on the Projects or Tags page (store.showProject / showTag).

The home page

A homepage key of settings.json ({ report } or { board }, read by parseHomepage). store.goHome() runs once the settings have loaded, from the front page only, and does nothing for a task’s address or a report or board that is gone. The General tab of Settings writes it.

Boards

The browser’s pages over the engine’s board module.

  • appsettings.ts reads settings.json‘s boards with board_normalize (the problems are kept apart from the commands’) and serializeBoards writes them back with the other keys as they were.
  • boards.ts runs <filter> export for each distinct filter (sections with the same one share it) and a table’s report, hands the text to board_aggregate, and sorts and trims the groups. Before it runs a filter it writes the board’s date ranges into it (board_substitute; the dates typed above the board take the place of the board’s own).
  • BoardView is the grid and the drag, resize and keyboard handling. Each step is worked out from where the drag began, on the layout as it was then, by board_place, or on a narrow screen by board_resize_flow, which resizes in the phone’s columns; sections are drawn where board_flow puts them there. A section’s settings are in Drawer, a modal panel that slides in from the side.
  • SectionInfo, a modal <dialog>, is drawn from what the load already had: loadBoard keeps, beside each result, the filter each series ran and the export text it got, so the panel parses and lists the tasks only when opened.

Charts. BoardChart is Chart.js on a canvas at the size of its box, loaded as a chunk of its own (chartjs.ts: only the controllers and scales the boards use, and the matrix plugin that makes a heatmap; about 67 KB compressed, fetched when a board first draws a chart). Bar, line, area, pie, donut, radar, polar and heatmap charts are one library’s work, and only the table is the app’s own. (On Android a heatmap is the one chart the app lays out itself, as Koala Plot has none, and the widget’s BoardRenderer draws it on its canvas.)

What BoardChart is given, chartConfig in boardChart.ts, is a change of names and colours from the engine’s Chart (labels, and a dataset of values per series), which already has Chart.js’s shape on purpose. It is the contract any front end draws from, mapping it to its own chart library. Nothing reads the replica.

Task commands

The browser’s own. appsettings.ts reads and writes settings.json ({ "taskCommands": [{ name, command, icon? }] }, other keys kept), which the Worker only stores (GET/PUT /api/settings; tc-core’s appsettings checks that it is a JSON object under 64 KB and nothing more).

taskCommands.ts writes a command out on the line you typed: the first word that is not part of the filter, as the engine reads a line, is the command word, and only that word can be one, so add fail the test is untouched. A name is written out once per line, so a command can extend the Taskwarrior command of the same name. ConsoleInput expands before it decides where the result goes and hands store.run what was typed for the history. The same commands are the ⋯ menu (MoreMenu) in a table row, the task drawer and the bulk bar, which send <tasks> <the command's words> like any button.

Icons are Lucide’s, picked in IconPicker. The app’s own are bundled and found by name (bundledIcons in lucide.ts). Any other is fetched as one SVG file from jsdelivr at the Lucide version pinned there (LUCIDE_VERSION), then read into a short list of plain shapes (svgToShapes: only path, circle, rect, line, polyline, polygon and ellipse, with numeric or path attributes; anything else refuses the whole file) and drawn inline by NodeIcon in the page’s colour. So the CDN supplies data and never code, and the CSP needs only connect-src for that host (and for holidata.net and date.nager.at, which supply holiday dates as text). lucideLib.svelte.ts keeps what was fetched (in memory, and the last 150 in localStorage) and the picker’s list of names and search words (tags.json, about 50 KB compressed, fetched when the picker opens).

Bulk actions

BulkBar, BulkModify, selection.svelte.ts and bulk.ts: the table’s checkboxes feed one selection, and the bar sends ordinary commands (the whole uuids of the ticked tasks side by side, which Taskwarrior reads as one group, then done, delete or modify with the words the editor made), so the engine needs nothing of its own. CLI hands the prompt a line through store.requestPrompt.

Colours

The engine sends each row’s colour as palette indexes (Row.style) and the chart colours with the config. colors.ts draws them (the soft basic sixteen per light/dark page, exact xterm colours otherwise, a contrast guard), scheme.svelte.ts says which page it is, and themes.ts and the taskrc tab’s picker put a bundled theme’s lines into the taskrc (the theme files are static assets).

What the browser stores

The replica (one IndexedDB record, see Sync) and small conveniences (command history, reminder settings). Settings live in the bucket: web/config.json (with one backup) and the app’s own web/settings.json (none).

How correctness is kept

The goal is Taskwarrior’s behaviour, not a lookalike, so the method is to read the C++, port it, and check it against the real task 3.5.0: run both on a scratch data directory and compare. Examples: calendar layouts and week numbers, burndown charts bar by bar, summary/projects/tags output, calc results, confirmation prompts, and the dates each date.iso/dateformat combination accepts.

LayerWhat it proves
cargo test (unit)Each port against fixtures taken from the real task
tests/cli_exec.rsReal command lines against a real replica: writes, reports, confirmations, recurrence, calc, show/config, hooks (injected through Options.hooks)
tests/sync_cost.rs, replica_sync.rsR2 call counts per sync; two replicas converging
tests/memory.rsPeak memory of a cold start and of requests
tests/lua_net.rshttp and secret() against a fake host: the allowlist (exact, wildcard, no user-info tricks), https only, request limits, a failed request as nil and a reason, a secret’s value kept out of everything, and a second call while one waits on the network
tests/lua_hooks.rsLua hooks through the real execute: each hook point, the tag set, dates and durations, UDAs, refusing, what a script may not change, hooks=off, syntax errors with their line, and the sandbox (no files or loading, an endless loop is stopped)
crates/hook-shim/tests/protocol.rsmeghnaad-hook as task runs it, from a task on standard input to what it prints and exits with: each hook, the changes written back in task’s form, refusing, a script that doesn’t load, secrets by exact name, http against a local server, the zone, install and uninstall
tests/saved.rsA replica saved to bytes and loaded again is the same replica, carries on the same way and syncs; unreadable copies are refused
tests/remote.rs, wire unit testsA replica syncing through the plain protocol (both halves, bytes encoded and decoded) ends where a direct one does: batches longer than one pull, pushing, losing a race, snapshots both ways, an empty bucket
board unit tests, and a_board_reads_what_export_writes, finished_tasks_split_into_succeeded_and_failed_by_a_uda and spans_over_an_export_are_what_the_weekly_burndown_counts in cli_execGrid placement (the moved section wins, the overlapped are pushed down, everything floats up, clamping, the two-column minimum, keyboard steps, reading order, the phone’s flow and resizing), the saved format (what is valid, what is dropped and named), counts, sums, averages and comparisons of numbers and durations, grouping by value, by each tag and by a date in each step, zone and week start with empty periods kept, and the refusals. The last of the three checks pending, started and done against burndown.weekly week by week
tc_core::helpers, sortspec, complete, highlight, urgency_settings, cells, preview and markdown unit testsWhat every front end calls instead of keeping a copy: splitting and quoting a command line, a date in each dateformat token and week rule, a duration in one unit, how a recurrence period reads, every header click, grouping and sort-override case, what Tab completes, how a line is coloured, how urgency terms are named, what every column format shows, what a typed add line comes to, and the Markdown subset (nesting, checklists, safe links, the limits that keep a hostile text quick)
npm --prefix web testThe browser-side logic (it loads the built engine for the helpers): formatting, completion, the store, the API client, and the engine client (with a stand-in Web Worker: one sync at a time, the other tabs are told of a write and never answer each other, a failed sync shows and clears)
scripts/interop-local.shThe real task CLI and the Worker against one local S3 bucket, both directions: tasks, UDAs, recurring series, snapshots
scripts/auth-check.mjsForged, expired and wrong-audience Access tokens are all refused
scripts/*.test.mjsThe build and deploy scripts, including package-release.mjs: the prebuilt release has the built Worker and app, the scripts, and a config with no build step, and none of the sources