todo implementation
Migration entry
The manifest entry exports migrate(db: PluginDb<never>): Promise<void> beside register.
The daemon loader always awaits it before registration for this reads: ['db'] plugin;
the DB CLI calls the same export without registering tools, routes, timers or services.
The export runs only the existing transactional plugin SQL through db.migrate.
A failed step rolls back its SQL and ledger entry and prevents registration. Repeated
calls skip applied versions. Runtime store construction never invokes migrations.
Tests that bypass the loader explicitly await this export before using their fixture.
Browser display contract
Todo requires browser UI API 32. The host's ElowenUiRuntime.utils.cardTasks
projects the pushed Todo card once, preserving owner, unresolved blockers,
running labels and measured times. The card uses that helper and the host's
cardTasksAddressable guard; the rail passes the host's already-projected
TaskRailData.tasks directly to TaskList. Neither surface reinterprets
glued text or polls the task route. The host normalizes incoming plugin cards at the emit
boundary; the rail payload is internal host data. Missing cards yield no rows and
empty or partially unaddressable lists have no interactive controls.
The projection is linear in the supplied cards and Todo rows and adds no
network request. Missing or incompatible bundles retain the host's fallback.
The Tasks management picker still reads its authorized route on demand.
The browser bundle requires the host's hooks.useNow member and still declares
API 32. TodoCard calls hooks.useNow(1000, enabled) only
for a live card with a running timestamp; the rail subscribes while one of its
rows has a running timestamp. One host heartbeat serves both surfaces, pauses
notifications while the tab is hidden, catches up on visibility and stops after
the last subscriber leaves. The rail evaluates the current time on each render,
including the render requested by a status action. No plugin timer or network
request is added. A host missing the hook fails explicitly instead of starting
a local clock. Registry UI tests install the candidate's actual hook.
The pinned UI kit predates the host's API 32 declarations. Until that pin is
updated, web-src/runtime.ts derives row fields from the kit's structured card
item type and adds measured duration, rather than defining a second display
projection. UI tests use a test-only host projection port, never plugin logic.
Storage contract
All three mutating HTTP handlers await ctx.writeCard after committing their task changes. A rejected panel write logs todo.panel_write_failed before returning the successful mutation response, including a clear request for a missing conversation.
The internal lib/taskStore.mjs module owns the native PostgreSQL migration, task rows, dependency graph, metadata and completed-list aging. The host awaits the entry export migrate(db) before registration; registration awaits ctx.db() and constructs TaskStore before publishing tools, routes or context providers. Every database operation returns a Promise and uses only PluginDb.query, one, run and the transaction-bound handle. SQL explicitly qualifies the quoted host-selected db.schema; there is no session search path. Account and conversation authorization stays at the registration boundary: keyFor derives the list key from the conversation id and the awaited host identity, and every caller awaits it before awaiting storage.
The PostgreSQL baseline creates task_lists, tasks and task_blockers in plugin_todo, with a unique row_id bigint identity on each table for bounded cleanup. started_at and completed_at are timestamptz; the store returns milliseconds to existing consumers, and duration_ms remains bigint. Five PostgreSQL migration steps hold ledger versions 1–5, which installed ledgers already record. Each step runs only its schema-qualified DDL; the host ledger controls whether it applies. Completed rows written before completion times were recorded keep a null completed_at. metadata_json stays text because unreadable stored bytes have an explicit preservation contract.
Completion records the time once; a repeated completed update or label edit does not restart it, and reopening clears it. ageCompletedAtTurnBoundary uses the live completedRetentionHours setting
with its 4-hour default and 1–168-hour bounds, pruning only dated expired completed rows
in the current conversation through the existing transactional deletion owner. It removes
resolved edges and retains the list's monotonic ID counter. The all-completed grace still
uses the existing turn count. Mid-turn rendering remains read-only. Before a write,
aging reads the list and eligible task state. A missing list, an empty list with a zero
grace counter, or unfinished work with no expired completion and a zero counter takes
no write transaction. The turn-context provider awaits aging and its refreshed snapshot. All conditions are read again after acquiring the list row with SELECT FOR UPDATE; expired task and edge deletion uses that same transaction without nesting.
Registration uses ctx.registerUserRemoved to await TaskStore.removeUser(userId).
Account lists have the exact u<id># key prefix. Indexed range predicates preserve
other accounts, platform identities and shared lists. The shared drainDbRows seam
receives schema: db.schema, the unqualified table name, identity: 'row_id', PostgreSQL $1 range parameters and an octet_length byte estimate. It removes dependency edges, tasks and list counters in that order, with at most 64 rows or 256 KiB per transaction and a yield between batches. Failures propagate
to the host; a later invocation drains remaining rows. There is no core
conversation-removed seam, so deleting a conversation does not remove its Todo rows.
Todo registers its step provider with afterTools naming TaskCreate, TaskUpdate and
TaskDelete. Core owns the administrator interval, native provenance, restoration and
activity gate. The provider emits conditional reconciliation guidance and a public
ID/status/subject snapshot bounded below 900 bytes, omitting private descriptions and
metadata. Core retains its 1024-byte final clamp and append-only prompt-cache behavior.
Both context providers return Promises. The step provider captures its identity and conversation on entry, then awaits a read-only snapshot; it never ages tasks or emits cards.
Batch creation, updates, deletion, clearing and aging lock the same list row before reading mutable task or graph state. Creation inserts the counter with ON CONFLICT DO NOTHING before locking, so simultaneous first batches reserve disjoint task IDs and choose at most one implicit running task. The lock protects partial metadata updates and cycle checks across awaits. Graph traversal stays inside the store, and HTTP and tool entry points share its control-character rule. Public list reads load tasks and edges in one read-only REPEATABLE READ transaction; mutation reads reuse their existing locked transaction. An unreadable metadata column is logged once per task and store instance and left byte-for-byte intact by unrelated updates; an explicit metadata patch replaces it.
The repository-level tests/todo-write-locks.test.mjs exercises idle-turn write avoidance, completed-list aging and bounded account cleanup. tests/todoConcurrency.test.mjs interleaves real transactions to pin ID allocation, partial updates, cycle rejection and snapshot consistency. Every todo database suite takes the repository's shared fixture, tests/helpers/pluginDb.mjs, which supplies the plugin database host binding, an independent database per fixture and its own pool and clone cleanup; it loads through the repository's register loader, so the normal node test command needs no core build. Set ELOWEN_CORE_ROOT to the candidate checkout. The existing node-test glob runs these files; no production module imports the fixtures. The main Vitest configuration resolves host modules to candidate source for the session-isolation and real-TypeBox suites, using the same PostgreSQL setup as the other adopted suites.
Rendering and update contracts
Import xmlEscape from elowen-plugin-shared/xml and call xmlEscape(value) when rendering XML text or attributes. It replaces XML 1.0-forbidden C0 characters, escapes markup and both quote kinds, and preserves Unicode, tab, LF, CR and DEL. It acts only at the XML rendering boundary and does not change stored values or network behavior. It is not single-line label validation: Todo still rejects controls in subjects and owners. Todo's turn context and Twilio's Stream URL attribute are real consumers. The runtime and declarations must be present in the containing core artifact.
Todo task diagnostics use errorText from elowen-plugin-shared/errors. errorText(error) retains Error.message and otherwise uses String(error); conversion failures propagate. It formats only an existing failure and does not log, retry or invent a success result. TaskStore owns UPDATABLE_FIELDS for both update admission and TaskUpdate's corrective hint: supplied scalar fields count as an update, while dependency arrays must be non-empty. Unknown tool keys remain ignored and a patch without a supported change is refused before persistence.
Todo's task picker mounts the shared Modal without the retired intent prop; the host owns overlay presentation and focus. In the turn context, at most 10 finished tasks are listed one by one; older finished tasks fold into one counted line, and their rows and IDs remain until the completed-list grace period ends.
Blockers and list rendering
The internal taskStore module exports unresolvedBlockers(blockedBy, statusById). Build one Map from task IDs to statuses for the current list and pass each row's blockedBy IDs to it. Only completed blockers are resolved; absent IDs remain unresolved, and an empty blocker list returns an empty array. TaskList, the next-runnable hint, taskCard, stalled-list reminders and batch creation use this same rule when producing their output or selecting initial work. The index is local to that operation, with linear setup cost and one lookup per dependency; it adds no query, persistent cache, timer or network request. taskCard is a real consumer and reuses the index for all rows.
The renderer's private displayLabel selects activeForm only for in-progress work with a non-empty activeForm, otherwise subject. taskCard keeps that label unbounded locally. The step snapshot uses public subjects capped at 70 code points. A list with no pending or in-progress task produces no step reminder. The snapshot's anomaly line uses the same counts and blocker index as the turn context. Rendering adds no I/O.
The Todo browser runtime declares only utilities it consumes; duration rendering belongs to the host TaskList and TaskRow components, not to a plugin-local formatDuration requirement. TasksRail uses the required railTitle string supplied by the manifest and cs/sk translations for both compact and expanded displays. Empty and fully completed lists remain hidden. The existing useNow heartbeat and read-only behavior without a sessionId remain unchanged; no timer or request is added. TasksRail is the real consumer of the title and host task-list contract.